diff --git a/package-lock.json b/package-lock.json index 6fd82587..ccf92c7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "2.10.0", + "version": "2.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "2.10.0", + "version": "2.10.1", "workspaces": [ "packages/*" ], @@ -5690,7 +5690,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "2.10.0", + "version": "2.10.1", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -5709,15 +5709,14 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "2.10.0", + "version": "2.10.1", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.10.0", - "@magmacomputing/tempo-plugin-_core": "file:./test/support/license-stub-pkg", + "@magmacomputing/library": "2.10.1", "@rollup/plugin-alias": "^6.0.0", "esbuild": "^0.25.12", "javascript-obfuscator": "^5.4.2", @@ -5729,14 +5728,10 @@ "vitepress": "^1.6.4" } }, - "packages/tempo/node_modules/@magmacomputing/tempo-plugin-_core": { - "resolved": "packages/tempo/test/support/license-stub-pkg", - "link": true - }, "packages/tempo/test/support/license-stub-pkg": { "name": "@magmacomputing/tempo-plugin-_core", "version": "1.0.0", - "dev": true + "extraneous": true } } } diff --git a/package.json b/package.json index a2d58984..0c68de25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.10.0", + "version": "2.10.1", "private": true, "description": "Magma Computing Monorepo", "repository": { @@ -17,6 +17,7 @@ "clean": "tsc -b --clean", "version:sync": "node -e \"require('child_process').execSync('npm version ' + process.env.npm_package_version + ' -w @magmacomputing/tempo -w @magmacomputing/library --no-git-tag-version', {stdio:'inherit'})\"", "repl": "npm run repl --workspace=@magmacomputing/tempo", + "repl:dist": "npm run repl:dist --workspace=@magmacomputing/tempo", "core": "npm run core --workspace=@magmacomputing/tempo", "docs:dev": "npm run docs:dev --workspace=@magmacomputing/tempo", "docs:build": "npm run docs:build --workspace=@magmacomputing/tempo", diff --git a/packages/library/package.json b/packages/library/package.json index 23f07625..73a2b455 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.10.0", + "version": "2.10.1", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/proxy.library.ts b/packages/library/src/common/proxy.library.ts index 57a87a9e..8ed14440 100644 --- a/packages/library/src/common/proxy.library.ts +++ b/packages/library/src/common/proxy.library.ts @@ -2,7 +2,7 @@ import { sym } from '#library/symbol.library.js'; import { allObject } from '#library/reflection.library.js'; import { deepFreeze } from '#library/utility.library.js'; import { unwrap } from '#library/primitive.library.js'; -import { isFunction, isSymbol, isDefined } from '#library/assertion.library.js'; +import { isString, isFunction, isSymbol, isDefined } from '#library/assertion.library.js'; import { registerType, type Constructor } from '#library/type.library.js'; const boundMethodCache = new WeakMap>(); @@ -172,3 +172,21 @@ export function delegator(keys: K[] | Record, const keyList = Array.isArray(keys) ? keys : Reflect.ownKeys(keys) as K[]; return factory({} as any, { keys: keyList, onGet: fn as any, frozen: true }); } + +/** + * ## indexedArray + * Augments a standard array with a Proxy-based lookup delegate. + * Allows index/enumerable array methods to function natively (e.g. map, filter, [0], length), + * while redirecting non-numeric string keys to a custom finder function to lookup items. + */ +export function indexedArray( + list: T[], + finder: (key: string) => T | undefined, + readonly = true +): T[] & Record { + return delegate(list, (key) => { + return (isString(key) && key !== 'length' && !(key in Array.prototype) && isNaN(Number(key))) + ? finder(key) + : undefined; + }, readonly) as any; +} diff --git a/packages/library/src/common/storage.library.ts b/packages/library/src/common/storage.library.ts index 4eb068ac..b16880d4 100644 --- a/packages/library/src/common/storage.library.ts +++ b/packages/library/src/common/storage.library.ts @@ -65,7 +65,7 @@ export function getStorage(key?: string, dflt?: T): T | undefined { } return isString(store) - ? objectify(store) // rebuild object from its stringified representation + ? objectify(store) // rebuild object from its stringified representation : dflt; } @@ -87,6 +87,12 @@ export function setStorage(key: string, val?: T) { : (delete context.global.process.env[key]) break; + case CONTEXT.Deno: + set + ? context.global.Deno.env.set(key, stash) + : context.global.Deno.env.delete(key); + break; + case CONTEXT.GoogleAppsScript: set ? context.global.PropertiesService?.getUserProperties().setProperty(key, stash) diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 6cf3b2da..07fdda29 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.10.1] - 2026-05-22 + +### Added +- **Build Pipeline Visuals**: Upgraded the `rollup.config.js` build output with high-visibility, color-coded ANSI banners to clearly distinguish between `PREMIUM` and `COMMUNITY` build targets. + +### Changed +- **Discovery Architecture**: Deprecated and purged the legacy `__TEMPO_DISCOVERY__` global discovery approach in favor of a strict `TEMPO_LICENSE_KEY` and Symbol-based registry pipeline. +- **Security Hardening**: Redacted the raw JWT key from the public `Tempo.license` snapshot to prevent accidental exposure of credentials in debug logs. + +### Fixed +- **License Snapshot Resilience**: Implemented a safety guard in `Tempo.license` to normalize `raw.scopes` before iteration, eliminating potential runtime exceptions when scopes are absent. +- **Cross-Platform Build Stability**: Updated the `resolve-types.ts` build script to utilize Node's native `fileURLToPath` for deterministic `__dirname` resolution across all operating systems. +- **Local Dev Extensibility**: Allowed `LIC_SRC_DIR` to be overridden via environment variables in the type resolution script, supporting customized development workflows while maintaining strict production fallbacks. + ## [2.10.0] - 2026-05-11 ### Added diff --git a/packages/tempo/bin/resolve-types.ts b/packages/tempo/bin/resolve-types.ts index 08bd1fe5..7a18f09c 100644 --- a/packages/tempo/bin/resolve-types.ts +++ b/packages/tempo/bin/resolve-types.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; /** * resolve-types.ts @@ -13,7 +14,15 @@ const DIST_DIR = path.resolve('dist'); const LIB_SRC_DIR = path.resolve('../library/dist/common'); const LIB_DEST_DIR = path.resolve(DIST_DIR, 'lib'); -const LIC_SRC_DIR = path.resolve('../../../tempo-plugin/packages/@core/dist'); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const LIC_SRC_DIR = process.env.LIC_SRC_DIR || path.resolve(__dirname, '../../../../tempo-plugin/internal/@core/dist'); + +if (!fs.existsSync(LIC_SRC_DIR)) { + console.error(`\n⚠️ ERROR: External license directory not found: ${LIC_SRC_DIR}`); + console.error(`⚠️ Cannot build premium module without proprietary @core types.\n`); + process.exit(1); +} + const LIC_DEST_DIR = path.resolve(DIST_DIR, 'lic'); console.log('Resolving type definitions...'); @@ -38,25 +47,13 @@ usedModules.forEach(mod => { }); // 4. Copy licensing core types -if (fs.existsSync(LIC_SRC_DIR)) { - if (!fs.existsSync(LIC_DEST_DIR)) fs.mkdirSync(LIC_DEST_DIR, { recursive: true }); - const licFiles = fs.readdirSync(LIC_SRC_DIR).filter(f => f.endsWith('.d.ts')); - licFiles.forEach(file => { - fs.copyFileSync(path.join(LIC_SRC_DIR, file), path.join(LIC_DEST_DIR, file)); - }); -} else { - console.warn(`\n⚠️ WARNING: External license directory not found: ${LIC_SRC_DIR}`); - console.warn(`⚠️ Creating fallback minimal types in ${LIC_DEST_DIR}\n`); - if (!fs.existsSync(LIC_DEST_DIR)) fs.mkdirSync(LIC_DEST_DIR, { recursive: true }); - const fallbackSrc = path.join(DIST_DIR, 'support', 'support.license.d.ts'); - if (fs.existsSync(fallbackSrc)) { - fs.copyFileSync(fallbackSrc, path.join(LIC_DEST_DIR, 'index.d.ts')); - } else { - fs.writeFileSync(path.join(LIC_DEST_DIR, 'index.d.ts'), 'export {};\n'); - } -} +if (!fs.existsSync(LIC_DEST_DIR)) fs.mkdirSync(LIC_DEST_DIR, { recursive: true }); +const licFiles = fs.readdirSync(LIC_SRC_DIR).filter(f => f.endsWith('.d.ts')); +licFiles.forEach(file => { + fs.copyFileSync(path.join(LIC_SRC_DIR, file), path.join(LIC_DEST_DIR, file)); +}); -// 4. Walk through all .d.ts files in dist/ to rewrite aliases +// 5. Walk through all .d.ts files in dist/ to rewrite aliases function walk(dir: string) { const files = fs.readdirSync(dir); for (const file of files) { diff --git a/packages/tempo/doc/comparison.md b/packages/tempo/doc/comparison.md index 9c8c5776..675a6690 100644 --- a/packages/tempo/doc/comparison.md +++ b/packages/tempo/doc/comparison.md @@ -13,6 +13,7 @@ If you are choosing a date library today, you are likely looking at **Day.js**, | **Foundation** | **Native Temporal** | Native Temporal | Legacy `Date` | Legacy `Intl` + `Date` | Legacy `Date` | | **Precision** | **Nanoseconds** | Nanoseconds | Milliseconds | Milliseconds | Milliseconds | | **Parsing** | **Human-Centric** | Strict ISO Only | Strict / Plugin | Strict | Modular / Strict | +| **Formatting** | **Smart Tokens & Getters** | Verbose `Intl` Only | Token-Based (Moment style) | Token-Based (Unicode) | Token-Based (Unicode helper) | | **Business Logic** | **Terms System** | Manual Math | Manual Math | Manual Math | Manual Math | | **Time Zones** | **First-Class** | First-Class | Plugin-based | Built-in | Separate Lib | | **Future-Proof** | **100% (Native)** | 100% (Native) | Deprecated/Legacy | Legacy Bridge | Legacy Bridge | @@ -22,7 +23,7 @@ If you are choosing a date library today, you are likely looking at **Day.js**, ## 💎 Why Tempo Wins ### 1. The "Terms" Engine (Business Intelligence) -Most libraries stop at "adding 2 days." Tempo introduces the **Terms** system, allowing you to encode domain-specific logic (Fiscal Quarters, Meteorological Seasons, Academic terms, Zodiac Signs) directly into the tempo `term` object. +Most libraries stop at "adding 2 days." Tempo introduces the **Terms** system, allowing you to encode domain-specific logic (Fiscal Quarters, Meteorological Seasons, Academic terms, Zodiac Signs) directly into the Tempo `term` object. > *Competition:* You have to write custom utility functions and import them everywhere. ### 2. Human-Centric Parsing @@ -36,6 +37,12 @@ Native `Date` (and thus Day.js/Luxon/date-fns) is limited to milliseconds. For h ### 4. Zero "Leaky Abstractions" When you use a legacy library, you are often fighting the weirdness of the 1995 `Date` object (like months being 0-indexed). Tempo is built on `Temporal`, which was designed from the ground up to be mathematically sound and developer-friendly. +### 5. Intelligent Formatting & Shorthands +While native Temporal is highly precise, formatting dates for a UI in native Temporal is extremely verbose. It relies entirely on the native `Intl` API, requiring you to construct complex option objects or use long `.toLocaleString()` boilerplate just to output simple strings. + +Tempo solves this by offering a **Smart Token Engine** (using `{yyyy}-{mm}-{dd}` placeholders) alongside built-in, highly optimized format getters like `.fmt.date`, `.fmt.time`, or `.fmt.dateTime`. In addition, Tempo automatically memoizes internal formatters under the hood, delivering top-tier performance without developer boilerplate. +> *Competition:* Raw Temporal requires verbose `Intl` configurations; legacy libraries require large plugins or separate helper imports. + --- ## Which should you choose? diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index b39b6e98..00e1f7b2 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -1,6 +1,21 @@ # 📜 Version 2.x History -## [v2.10.0] = 2026-05-20 +## [v2.10.11] - 2026-05-23 +### New Features + +- Added support for license key discovery via global browser context +- Enhanced license object output with formatted timestamps +- Improved terms registry with better key-based indexing +- Introduced Free Showcase Astronomical Seasons premium plugin option + +### Documentation + +- Expanded license application guidance for browser, Node.js, and micro-frontend environments +- Added network synchronization and offline behavior documentation +- Clarified logStamp format customization examples +- Improved relative time and duration string documentation + +## [v2.10.0] - 2026-05-20 ### New Features - Added licensing system with JWT validation and revocation checks diff --git a/packages/tempo/doc/tempo-vs-temporal.md b/packages/tempo/doc/tempo-vs-temporal.md index 8996467e..e45c3cdb 100644 --- a/packages/tempo/doc/tempo-vs-temporal.md +++ b/packages/tempo/doc/tempo-vs-temporal.md @@ -40,7 +40,7 @@ date.toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' } **Tempo 🚀** ```javascript -const t = new Tempo(); +const t = new Tempo('2026-01-24T12:00:00'); // Use the format method to create custom formats, or use the pre-built getters (on the 'fmt' property) t.format('{dd} {mmm} {yyyy}'); // Output: "24 Jan 2026" @@ -67,11 +67,11 @@ const fiscalQuarter = `Q${Math.ceil(month / 3)}`; // Manual math Tempo solves this elegantly using the **Terms** plugin system. Terms are lazy-loaded plugins that evaluate the current date against semantic boundaries without adding memory bloat. ```javascript -const t = new Tempo(); +const t = new Tempo('2026-01-24T12:00:00', { sphere: 'north' }); // Built-in complex Terms via the standard plugin t.term.qtr; // → 'Q1' (Calculates fiscal quarter) -t.term.szn; // → 'Summer' (Calculates meteorological season, respecting hemisphere) +t.term.szn; // → 'Winter' (Calculates meteorological season, respecting hemisphere) ``` For more information on adding your own business logic, see the [Terms Guide](tempo.term.md). @@ -80,6 +80,21 @@ For more information on adding your own business logic, see the [Terms Guide](te Calculating the difference between two dates in native Temporal is mathematically sound, but it strictly returns a `Temporal.Duration` object. Tempo gives you the flexibility to return a `Duration` object, a precise floating-point number, or a human-readable string. +Tempo also provides a built-in **log-stamp** format for dropping a compact, sortable timestamp into a log entry: + +```javascript +const t = new Tempo('2026-05-20T13:55:19.623319620'); +t.fmt.logStamp; // → "20260520T135519.623319620" +// ^^^^^^^^ ^^^^^^ ^^^^^^^^^ +// date time sub-seconds (nanosecond precision) +``` + +This format (`Tempo.FORMAT.logStamp`) is configurable via `Tempo.init`: +```javascript +Tempo.init({ formats: { logStamp: '{yyyy}-{mm}-{dd} {hh}:{mi}:{ss}' } }); +new Tempo('2026-05-20T13:55:19.623319620').fmt.logStamp; // → "2026-05-20 13:55:19" +``` + **Native Temporal 🐢** ```javascript const now = Temporal.Now.plainDateTimeISO(); @@ -91,18 +106,39 @@ const duration = now.until(target); // Returns a complex Duration object Tempo understands natural language targets and can format the resulting difference flexibly. `t.until()` and `t.since()` have distinct return types: ```javascript -const t = new Tempo(); +const t = new Tempo('2026-05-20T11:35:33'); +const t2 = new Tempo('2026-05-22T11:35:33'); -// t.until(target, unit) → number (precise floating-point) -t.until('afternoon', 'minutes'); // → 302.57749424408334 -t.until('xmas', 'days'); // → 289 +// t.until(target, unit) → number +t.until('afternoon', 'minutes'); // → 84.45 (fractional: 'afternoon' has a fixed time, e.g. 13:00) +t.until('xmas', 'days'); // → 219 (whole number — see note below) +t.until('xmas', 'weeks'); // → 31.28 (fractional — weeks don't divide evenly into days) +t.until(t2, 'hours'); // → 48 (targets can also be other Tempo instances) +t.until(Temporal.Now.plainDateISO(), 'days'); // → 1 (same day = 1 day) // t.until(target) → Tempo.Duration object (with .iso, .years, .days, … fields) -t.until('xmas'); // → { iso: "P289DT14H22M9.102S", years: 0, months: 9, days: 14, ... } +t.until('xmas'); // → { iso: "P219DT0H0M0S", years: 0, months: 7, days: 4, ... } // t.since(target, unit) → human-readable string via Intl.RelativeTimeFormat t.since('yesterday', 'days'); // → "1d ago" // t.since(target) → ISO 8601 Duration string t.since('yesterday afternoon'); // → "-P1DT9H32M19.402S" -``` \ No newline at end of file +``` + +> **💡 Date-only targets inherit the current time.** +> When a target resolves to a **date without a time component** (e.g. `'xmas'`, `'tomorrow'`, `'next friday'`), +> Tempo copies the current time-of-day from the anchor into the target. This means: +> +> - `t.until('xmas', 'days')` → a **whole number** — the time components cancel out exactly. +> - `t.until('xmas', 'hours')` → a **whole number** — same reason. +> - `t.until('xmas', 'weeks')` → **fractional** — 219 days does not divide evenly into weeks. +> +> This matches natural-language intuition: *"How many days until Christmas?"* expects `219`, not `219.43`. +> If you need the precise elapsed duration including sub-day components, omit the unit: +> ```javascript +> t.until('xmas'); // → { days: 219, hours: 0, minutes: 0, seconds: 0, ... } +> ``` +> +> Targets with an **explicit time** (e.g. `'afternoon'`, `'9am'`, `'2026-12-25T08:00'`) always produce +> fractional values because the target time differs from the anchor's current time-of-day. \ No newline at end of file diff --git a/packages/tempo/doc/tempo.cookbook.md b/packages/tempo/doc/tempo.cookbook.md index d2fc7250..27a4a603 100644 --- a/packages/tempo/doc/tempo.cookbook.md +++ b/packages/tempo/doc/tempo.cookbook.md @@ -5,9 +5,9 @@ A collection of recipes for solving common date and time challenges using Tempo. ## Table of Contents 1. [The Basics](#the-basics) 2. [Parsing Challenges](#parsing-challenges) -3. [Manipulation & Calculations](#manipulation--calculations) -4. [Timezones & Locales](#timezones--locales) -5. [Business Logic & Terms](#business-logic--terms) +3. [Manipulation and Calculations](#manipulation-and-calculations) +4. [Timezones and Locales](#timezones-and-locales) +5. [Business Logic and Terms](#business-logic-and-terms) 6. [Interoperability](#interoperability) --- @@ -37,7 +37,7 @@ if (t.isValid) { } ``` -### Global Configuration & Initialization +### Global Configuration and Initialization You can initialize global defaults that apply to all future `Tempo` instances. ```typescript Tempo.init({ @@ -108,7 +108,7 @@ new Tempo(1716163200000000000n); // Nanoseconds --- -## Manipulation & Calculations +## Manipulation and Calculations ### Add or Subtract Time Tempo instances are immutable; `add()` returns a new instance. @@ -143,7 +143,7 @@ const qtrMid = new Tempo().set({ mid: '#qtr' }); --- -## Timezones & Locales +## Timezones and Locales ### Convert Time to Another Zone ```typescript @@ -175,7 +175,7 @@ for (const entry of logEntries) { --- -## Business Logic & Terms +## Business Logic and Terms ### Is it the weekend? ```typescript @@ -297,7 +297,7 @@ const tempo = new Tempo(new Date()); ```typescript const zdt = new Tempo().toDateTime(); // Temporal.ZonedDateTime const instant = new Tempo().toInstant(); // Temporal.Instant -const pDate = new Tempo().toPlainDate(); // Temporal.PlainDate +const pdt = new Tempo().toPlainDate(); // Temporal.PlainDate ``` ### Sorting an array of Tempos diff --git a/packages/tempo/doc/tempo.license.md b/packages/tempo/doc/tempo.license.md index e52df346..6ba0a1b9 100644 --- a/packages/tempo/doc/tempo.license.md +++ b/packages/tempo/doc/tempo.license.md @@ -41,55 +41,112 @@ console.log(t.term.astronomy); **How to get it:** 1. Run `npm install @magmacomputing/tempo-plugin-astro` in your project. -2. Send us an email at [contact@magmacomputing.com.au](mailto:contact@magmacomputing.com.au) with your preferred email address, and we will issue a **one-year expiry key** straight to your inbox. +2. Visit [magmacomputing.com.au/tempo/license.html](https://magmacomputing.com.au/tempo/license.html) to request your **one-year expiry key**, which will be instantly issued to your inbox. ## ⚙️ Applying Your License Key -Once you receive your License Key, you must provide it to your Tempo-enabled codebase so the engine can verify and unlock the premium features. +Once you receive your License Key, you must provide it to your Tempo-enabled codebase so the engine can verify and unlock the premium features. -There are two supported ways to provide your key: +There are three supported ways to provide your key: ### 1. Environment Variable (Recommended for Node/SSR) The easiest method for backend or server-side rendered environments is to expose the key via an environment variable. Tempo will automatically detect it during initialization. ```bash # Set this in your deployment environment or .env file -export TEMPO_LICENSE_KEY="key..." +export TEMPO_LICENSE_KEY="ey..." +``` + +Then in your application, simply import Tempo and the plugin via side-effect. Because the license key is automatically discovered from the environment variable, no manual initialization is required: + +```typescript +import { Tempo } from '@magmacomputing/tempo/core'; +import '@magmacomputing/tempo-plugin-astro'; // Automatically registers AstroTerm + +const t = new Tempo('21-Mar-2026'); +console.log(t.term.astro); +// → 'Vernal' ``` ### 2. Programmatic Initialization (Recommended for Browsers) -If you are running Tempo in a client-side browser environment or prefer explicit configuration, you can pass the key directly into the `Tempo.init()` method before instantiating any dates or registering the premium plugins. +If you are running Tempo in a client-side browser environment or prefer explicit configuration, you can pass the license key and any premium plugins directly into the `Tempo.init()` method. ```typescript import { Tempo } from '@magmacomputing/tempo/core'; import { AstroTerm } from '@magmacomputing/tempo-plugin-astro'; -// 1. Initialize the core engine with your license key +// Initialize the core engine and register the plugin in one step Tempo.init({ - license: 'key...' + license: 'ey...', + plugins: [AstroTerm] }); -// 2. Extend Tempo with the premium plugin -Tempo.extend(AstroTerm); - -// 3. The premium Term is unlocked and ready to use! +// The premium Term is unlocked and ready to use! const t = new Tempo('21-Mar-2026'); console.log(t.term.astro); -// → 'Spring' +// → 'Vernal' console.log(t.term.astronomy); -// → { key: 'Spring', year: 2026, month: 3, day: 20, hour: 14, minute: 45, ... } +// → { key: 'Vernal', season: 'Spring', year: 2026, month: 3, day: 20, hour: 14, minute: 45, ... } ``` ::: tip ESM Hoisting & Registration Order -In standard JavaScript (ESM) environments, `import` statements are hoisted and executed before any regular code. +In standard JavaScript (ESM) environments, `import` statements are hoisted and executed before any regular code. -Because of this, the plugin's automatic self-registration runs *before* your synchronous `Tempo.init({ license: '...' })` call. Since `Tempo.init()` resets the active registry, you **must explicitly call `Tempo.extend(AstroTerm)` after initialization** (as shown in Step 2 above) to re-register the plugin into the licensed registry. +Because of this, any automatic self-registration from a plugin runs *before* your synchronous `Tempo.init()` call. Since `Tempo.init()` resets the active registry, the self-registration will be wiped out. -Alternatively, you can initialize Tempo in a separate entry/bootstrap file (e.g., `bootstrap.ts`) before loading the rest of your application. +Passing plugins directly into the `plugins` configuration array in `Tempo.init()` (as shown above) is the cleanest way to guarantee proper registration order. Alternatively, you can call `Tempo.extend(AstroTerm)` explicitly after calling `Tempo.init()`. ::: +### 3. Global Context (Fallback for specific browser environments) + +This is a browser-native variation of Method 1. While Method 1 targets the process-level environment (`process.env`) available in Node/SSR, this method sets the key on the JavaScript `globalThis` object (which maps to `window` in browsers), enabling the same auto-discovery behaviour without any build-time configuration. + +**Use this method in the following scenarios:** + +#### Direct ` + + +``` + +#### Frontend Bundlers without `process.env` Polyfills +Modern browser bundlers (e.g., Vite) do not inject Node's `process` object by default. Assign the key to `globalThis` in your entry file *before* importing Tempo: + +```typescript +// entry.ts — must run before any Tempo import +globalThis.TEMPO_LICENSE_KEY = import.meta.env.VITE_TEMPO_LICENSE_KEY; + +// Now safe to import — license auto-discovered +import { Tempo } from '@magmacomputing/tempo/core'; +import '@magmacomputing/tempo-plugin-astro'; +``` + +#### Micro-frontends / Shared Global Space +In architectures where multiple independently-bundled applications share a single browser tab, set the key once in the host container. All dynamically-loaded sub-applications will auto-discover it without needing individual configuration: + +```typescript +// host-container.ts +globalThis.TEMPO_LICENSE_KEY = 'ey...'; + +// Sub-apps loaded later will automatically run in licensed mode +``` + +## 📡 Network Requests & Offline Behavior + +To verify license validity and prevent abuse, Tempo's licensing engine performs background synchronization with our revocation registry: + +* **Outbound Request:** When a license key is active, Tempo asynchronously fetches a cryptographically signed revocation list (JWS). +* **Endpoint:** `https://api.magmacomputing.com.au/tempo/v1/revoked.jws` (useful for configuring Content Security Policies (CSP) or egress firewall rules). +* **Frequency:** The revocation check occurs once every **7 days**. The last-checked state is cached to avoid redundant network traffic on subsequent startups. +* **Offline Resilience (Fail-Open):** If your application is offline, behind a strict firewall, or the registry server is temporarily unreachable, the validation **fails open**. Tempo logs a warning in the console but continues to grant access to premium features (relying on the local cryptographic expiration of the JWT). + ## 🤝 Commercialize Your Own Plugin Are you a developer who has built an incredibly useful, domain-specific Tempo plugin (e.g., medical billing cycles, legal discovery windows, complex religious calendars)? diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 91d572f6..8534ac8c 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "2.10.0", + "version": "2.10.1", "description": "The Tempo core library", "author": "Magma Computing Solutions", "license": "MIT", @@ -202,11 +202,12 @@ "test:ci": "cross-env TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 vitest run", "test:ci:prefilter": "cross-env TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 TEMPO_PREFILTER_CI=true vitest run", "repl": "tsx --conditions=development -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", - "node": "tsx --conditions=development -i --harmony-temporal --import ./bin/repl.ts", - "bare": "tsx --conditions=development -i --harmony-temporal", - "core": "cross-env TEMPO_LITE=true tsx --conditions=development -i --harmony-temporal --import ./bin/core.ts", + "repl:dist": "tsx -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", + "repl:node": "tsx --conditions=development -i --harmony-temporal --import ./bin/repl.ts", + "repl:bare": "tsx --conditions=development -i --harmony-temporal", + "repl:core": "cross-env TEMPO_LITE=true tsx --conditions=development -i --harmony-temporal --import ./bin/core.ts", "parse": "cross-env TEMPO_LITE=true tsx --conditions=development -i --harmony-temporal --import ./bin/parse.ts", - "build": "npm run clean && tsc -b && npm run build:bundle && npm run build:resolve && if [ ! -f dist/support/support.license.js ]; then echo '🚨 ERROR: dist/support/support.license.js is missing from dist!'; exit 1; fi", + "build": "npm run clean && tsc -b && npm run build:bundle && npm run build:resolve", "build:bundle": "rollup -c", "build:resolve": "tsx bin/resolve-types.ts", "clean": "rm -rf dist && (tsc -b --clean || true)", @@ -226,7 +227,7 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.10.0", + "@magmacomputing/library": "2.10.1", "@rollup/plugin-alias": "^6.0.0", "esbuild": "^0.25.12", "javascript-obfuscator": "^5.4.2", @@ -241,4 +242,4 @@ "doc": "doc", "test": "test" } -} \ No newline at end of file +} diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index 2402bee4..44aec9e7 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -20,8 +20,16 @@ const isPremiumAvailable = !!( ); const licensePath = isPremiumAvailable ? licensePremium : licenseDefault; -console.log(`\n📦 Building Tempo [${isPremiumAvailable ? '💎 PREMIUM' : '🍃 COMMUNITY'}]`); -if (isPremiumAvailable) console.log(`🛡️ Engine: ${licensePath}\n`); +if (isPremiumAvailable) { + console.log('\n\x1b[45m\x1b[37m\x1b[1m =========================================== \x1b[0m'); + console.log('\x1b[45m\x1b[37m\x1b[1m 📦 BUILDING TEMPO: 💎 PREMIUM \x1b[0m'); + console.log('\x1b[45m\x1b[37m\x1b[1m =========================================== \x1b[0m'); + console.log(`\x1b[35m🛡️ Engine: ${licensePath}\x1b[0m\n`); +} else { + console.log('\n\x1b[42m\x1b[30m\x1b[1m =========================================== \x1b[0m'); + console.log('\x1b[42m\x1b[30m\x1b[1m 📦 BUILDING TEMPO: 🍃 COMMUNITY \x1b[0m'); + console.log('\x1b[42m\x1b[30m\x1b[1m =========================================== \x1b[0m\n'); +} /** * Rollup Configuration for Tempo diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 949e7241..77adf10f 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -128,12 +128,9 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int // 4. Discovery Cascade (License) let key = options.license; - if (!key) { - const discovery = (globalThis as any).__TEMPO_DISCOVERY__; - key = discovery?.license || discovery?.options?.license; - } - if (!key) key = getStorage('TEMPO_LICENSE'); - if (!key) key = (globalThis as any).TEMPO_LICENSE; + + if (!key) key = getStorage('TEMPO_LICENSE_KEY'); + if (!key) key = (globalThis as any).TEMPO_LICENSE_KEY; if (key) setLicense(state, key); } diff --git a/packages/tempo/src/support/support.license.ts b/packages/tempo/src/support/support.license.ts index 98b8ae38..698fb558 100644 --- a/packages/tempo/src/support/support.license.ts +++ b/packages/tempo/src/support/support.license.ts @@ -1,3 +1,5 @@ +import { decodeJWT } from '#library/utility.library.js'; + /** * # Tempo Licensing Engine (Open Core) * This is the default no-op implementation for the public repository. @@ -7,9 +9,10 @@ export class Validator { constructor(public key: string) { } async verify() { + const claims = decodeJWT(this.key); return { status: 'active' as const, - scopes: {} as Record, + scopes: (claims?.permissions || {}) as Record, } } async syncRevocation(_jwsUrl: string, _currentJti: string): Promise { diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 99737123..b474ed4e 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -4,7 +4,7 @@ import { Logify } from '#library/logify.class.js'; import { Immutable, Serializable } from '#library/class.library.js'; import { asArray } from '#library/coercion.library.js'; import { getStorage, setStorage } from '#library/storage.library.js'; -import { secure, proxify, delegate } from '#library/proxy.library.js'; +import { secure, proxify, delegate, indexedArray } from '#library/proxy.library.js'; import { getContext, CONTEXT } from '#library/utility.library.js'; import { enumify } from '#library/enumerate.library.js'; import { ownKeys, ownEntries, unwrap } from '#library/primitive.library.js'; @@ -94,7 +94,28 @@ export class Tempo { /** Tempo state for the global configuration */ static #global = {} as Internal.State; /** cache for next-available 'usr' Token key */ static #usrCount = 0; /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return getRuntime().pluginsDb.terms } - /** global license state */ static get license() { return getRuntime().license } + /** @internal raw license state */ static get #license() { return getRuntime().license } + /** human-readable formatted license state */ static get license() { + const { jws, key, ...raw } = Tempo.#license; // omit internal Pledge and JWT string from user-facing snapshot + const ss = { timeStamp: 'ss' } as const; // JWT timestamps are always in seconds (RFC 7519) + const scopesSource = (raw.scopes && typeof raw.scopes === 'object') ? raw.scopes : {}; + const scopes = Object.fromEntries( + Object.entries(scopesSource).map(([key, scope]) => { + const s = scope as any; + return [key, { + ...s, + ...(typeof s.exp === 'number' && { exp: new Tempo(s.exp, ss).fmt.weekTime }), + ...(typeof s.updated_at === 'number' && { updated_at: new Tempo(s.updated_at, ss).fmt.weekTime }), + }]; + }) + ); + return secure({ + ...raw, + scopes, + ...(typeof raw.expires === 'number' && { expires: new Tempo(raw.expires, ss).fmt.weekTime }), + ...(typeof raw.issuedAt === 'number' && { issuedAt: new Tempo(raw.issuedAt, ss).fmt.weekTime }), + }); + } /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); /** flag to prevent recursion during init */ static #lifecycle = { bootstrap: true, initialising: false, extendDepth: 0, ready: false }; /** Master Guard predicate (implements RegExp-like interface) */static #guard: { test(str: string): boolean } = { test: () => true }; @@ -947,14 +968,8 @@ export class Tempo { } }); - // `delegate` returns an array-like proxy that also supports string lookups; use - // an `unknown` bridge to assert the combined intersection type so the compiler // treats `Tempo.terms` as array-like and indexable by key. - return delegate(list, (key) => { - return (isString(key) && !['length', 'map', 'find', 'forEach', 'includes'].includes(key)) - ? list.find(t => t.key === key || t.scope === key) - : undefined; - }) as unknown as Secure[]> & Record>; + return indexedArray(list, key => list.find(t => t.key === key || t.scope === key)) as unknown as Secure[]> & Record>; } /** static Tempo.formats (registry) */ diff --git a/packages/tempo/test/plugins/licensing.full.test.ts b/packages/tempo/test/plugins/licensing.full.test.ts index 34a7f3ab..941537dd 100644 --- a/packages/tempo/test/plugins/licensing.full.test.ts +++ b/packages/tempo/test/plugins/licensing.full.test.ts @@ -16,13 +16,26 @@ vi.mock('#tempo/license', () => { const licenseModule = '#tempo/license'; describe('Tempo Licensing Strategy', () => { + let originalLicenseKeyEnv: string | undefined; + beforeEach(() => { + originalLicenseKeyEnv = process.env.TEMPO_LICENSE_KEY; + delete process.env.TEMPO_LICENSE_KEY; + // 🏛️ Hard reset the global runtime to ensure test isolation resetRuntimeForTesting(); vi.clearAllMocks(); }); + afterEach(() => { + if (originalLicenseKeyEnv !== undefined) { + process.env.TEMPO_LICENSE_KEY = originalLicenseKeyEnv; + } else { + delete process.env.TEMPO_LICENSE_KEY; + } + }); + test('Tempo is ready-to-receive a license via init options', () => { const payload = { iss: 'Magma Computing', @@ -96,23 +109,40 @@ describe('Tempo Licensing Strategy', () => { // Local instance should reflect the global license state via its runtime bridge expect(Tempo.license.status).toBe(LICENSE.Pending); - expect(Tempo.license.key).toBe(mockToken); + expect((Tempo.license as any).key).toBeUndefined(); }); - test('Discovery cascade: picks up license from globalThis.TEMPO_LICENSE', () => { - const payload = { permissions: { discovered: {} } }; + test('Discovery cascade: picks up license from globalThis.TEMPO_LICENSE_KEY', () => { + const payload = { permissions: { discovered_key: {} } }; const mockToken = `a.${base64Encode(JSON.stringify(payload))}.c`; // Set global variable - (globalThis as any).TEMPO_LICENSE = mockToken; + (globalThis as any).TEMPO_LICENSE_KEY = mockToken; + + try { + Tempo.init(); + const rt = getRuntime(); + expect(rt.license.key).toBe(mockToken); + expect(rt.license.scopes).toHaveProperty('discovered_key'); + } finally { + delete (globalThis as any).TEMPO_LICENSE_KEY; + } + }); + + test('Discovery cascade: picks up license from process.env.TEMPO_LICENSE_KEY', () => { + const payload = { permissions: { env_key: {} } }; + const mockToken = `a.${base64Encode(JSON.stringify(payload))}.c`; + + // Set env variable + process.env.TEMPO_LICENSE_KEY = mockToken; try { Tempo.init(); const rt = getRuntime(); expect(rt.license.key).toBe(mockToken); - expect(rt.license.scopes).toHaveProperty('discovered'); + expect(rt.license.scopes).toHaveProperty('env_key'); } finally { - delete (globalThis as any).TEMPO_LICENSE; + delete process.env.TEMPO_LICENSE_KEY; } });