diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 00e1934f..5fb5f5d9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,6 +19,7 @@ on: - '@magmacomputing/tempo-plugin-snap' - '@magmacomputing/tempo-plugin-sync' - '@magmacomputing/tempo-plugin-ticker' + - '@magmacomputing/tempo-plugin-geo' - 'all' release: types: [published] @@ -57,7 +58,7 @@ jobs: # Input validation case "$TARGET" in - @magmacomputing/tempo|@magmacomputing/tempo-fns|@magmacomputing/tempo-plugin-astro|@magmacomputing/tempo-plugin-celestial|@magmacomputing/tempo-plugin-ai|@magmacomputing/tempo-plugin-batch|@magmacomputing/tempo-plugin-finance|@magmacomputing/tempo-plugin-snap|@magmacomputing/tempo-plugin-sync|@magmacomputing/tempo-plugin-ticker|all) + @magmacomputing/tempo|@magmacomputing/tempo-fns|@magmacomputing/tempo-plugin-astro|@magmacomputing/tempo-plugin-celestial|@magmacomputing/tempo-plugin-ai|@magmacomputing/tempo-plugin-batch|@magmacomputing/tempo-plugin-finance|@magmacomputing/tempo-plugin-snap|@magmacomputing/tempo-plugin-sync|@magmacomputing/tempo-plugin-ticker|@magmacomputing/tempo-plugin-geo|all) ;; *) echo "❌ Error: Invalid target '$TARGET'" @@ -130,6 +131,7 @@ jobs: publish_pkg "@magmacomputing/tempo-plugin-snap" publish_pkg "@magmacomputing/tempo-plugin-sync" publish_pkg "@magmacomputing/tempo-plugin-ticker" + publish_pkg "@magmacomputing/tempo-plugin-geo" else publish_pkg "$TARGET" fi diff --git a/package-lock.json b/package-lock.json index 9addbcc9..59846ea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "4.1.0", + "version": "4.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "4.1.0", + "version": "4.1.1", "workspaces": [ "packages/*", "packages/plugins/*", @@ -1065,6 +1065,10 @@ "resolved": "packages/plugins/finance", "link": true }, + "node_modules/@magmacomputing/tempo-plugin-geo": { + "resolved": "packages/plugins/geo", + "link": true + }, "node_modules/@magmacomputing/tempo-plugin-snap": { "resolved": "packages/plugins/snap", "link": true @@ -9957,7 +9961,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "4.1.0", + "version": "4.1.1", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -9975,13 +9979,13 @@ }, "packages/plugins/ai": { "name": "@magmacomputing/tempo-plugin-ai", - "version": "1.1.1", + "version": "1.2.1", "license": "MIT", "devDependencies": { "@js-temporal/polyfill": "^0.5.1" }, "peerDependencies": { - "@magmacomputing/tempo": "^4.0.0" + "@magmacomputing/tempo": "^4.1.0" } }, "packages/plugins/astro": { @@ -10024,6 +10028,17 @@ "@magmacomputing/tempo": "^4.0.0" } }, + "packages/plugins/geo": { + "name": "@magmacomputing/tempo-plugin-geo", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, + "peerDependencies": { + "@magmacomputing/tempo": "^4.1.1" + } + }, "packages/plugins/snap": { "name": "@magmacomputing/tempo-plugin-snap", "version": "1.3.3", @@ -10044,7 +10059,7 @@ }, "packages/plugins/ticker": { "name": "@magmacomputing/tempo-plugin-ticker", - "version": "2.3.0", + "version": "2.3.1", "license": "MIT", "peerDependencies": { "@magmacomputing/tempo": "^4.0.0" @@ -10052,7 +10067,7 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "4.1.0", + "version": "4.1.1", "license": "MIT", "dependencies": { "tslib": "^2.8.1" diff --git a/package.json b/package.json index eee285a9..1b24ab30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "4.1.0", + "version": "4.1.1", "private": true, "engines": { "node": ">=20.0.0" @@ -28,6 +28,10 @@ "version:sync": "tempo-cli version-sync", "catalog:sync": "tempo-cli catalog-sync", "providers:sync": "npm run build:library && tempo-cli sync-providers", + "plugins:check-versions": "./packages/plugins/.bin/check-versions.sh", + "plugins:check-diff": "./packages/plugins/.bin/check-branch-diff.sh", + "check:versions": "npm run plugins:check-versions", + "check:diff": "npm run plugins:check-diff", "repl": "npm run repl --workspace=@magmacomputing/tempo", "repl:plugins": "tsx --import ./packages/plugins/.bin/temporal-polyfill.mts ./packages/plugins/.bin/repl.mts", "repl:dist": "npm run repl:dist --workspace=@magmacomputing/tempo", diff --git a/packages/library/package.json b/packages/library/package.json index 87a3af8f..aaf2bc78 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "4.1.0", + "version": "4.1.1", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/primitives/assertion.library.ts b/packages/library/src/common/primitives/assertion.library.ts index 6982990b..2262f278 100644 --- a/packages/library/src/common/primitives/assertion.library.ts +++ b/packages/library/src/common/primitives/assertion.library.ts @@ -76,6 +76,21 @@ export const isSymbol = (obj: unknown): obj is symbol => isType(obj, 'Sy export const isSymbolFor = (obj: unknown): obj is symbol => isType(obj, 'Symbol') && Symbol.keyFor(obj as symbol) !== undefined; export const isPropertyKey = (obj: unknown): obj is PropertyKey => isType(obj, 'String', 'Number', 'Symbol'); +/** + * Asserts if a property key is safe against prototype pollution and prototype hijacking. + * Returns false for '__proto__', 'constructor', and 'prototype'. + * + * @param key - The property key to check + * @returns True if the key is safe to assign or merge + * @example + * ```ts + * isSafeKey('name'); // true + * isSafeKey('__proto__'); // false + * ``` + */ +export const isSafeKey = (key: PropertyKey): boolean => + key !== '__proto__' && key !== 'constructor' && key !== 'prototype'; + export const isNull = (obj: unknown): obj is null => isType(obj, 'Null'); export const isNullish = (obj: unknown): obj is Nullish => isType(obj, 'Null', 'Undefined', 'Void', 'Empty'); export const isUndefined = (obj: unknown): obj is undefined => isType(obj, 'Undefined', 'Void', 'Empty'); diff --git a/packages/library/src/common/primitives/object.library.ts b/packages/library/src/common/primitives/object.library.ts index 33379a85..227bfbb7 100644 --- a/packages/library/src/common/primitives/object.library.ts +++ b/packages/library/src/common/primitives/object.library.ts @@ -1,5 +1,5 @@ import { ownKeys, ownEntries } from '#library/primitive.library.js'; -import { isObject, isArray, isFunction, isDefined, isNullish, isMap, isSet } from '#library/assertion.library.js'; +import { isObject, isArray, isFunction, isDefined, isNullish, isMap, isSet, isSafeKey } from '#library/assertion.library.js'; import { getType } from '#library/type.library.js'; import type { Extend, Property } from '#library/type.library.js'; @@ -41,7 +41,10 @@ export const asObject = (obj?: Record) => { const temp: any = isArray(obj) ? [] : {}; ownKeys(obj) - .forEach(key => temp[key] = asObject(obj[key])); + .forEach(key => { + if (!isSafeKey(key)) return; + temp[key] = asObject(obj[key]); + }); return temp as T; } @@ -142,7 +145,7 @@ export const getMethods = (obj: any, all = false) => { export function ifDefined>(obj: T) { return ownEntries(obj) .reduce((acc, [key, val]) => { - if (isDefined(val)) + if (isSafeKey(key) && isDefined(val)) acc[key] = val; return acc as T; }, {} as T) @@ -221,7 +224,7 @@ export const deepMerge = >(...objects: Partia if (!isObject(obj)) return prev; Object.entries(obj).forEach(([key, value]) => { - if (key === '__proto__' || key === 'constructor' || key === 'prototype') return; + if (!isSafeKey(key)) return; const pVal = prev[key]; if (isObject(pVal) && isObject(value)) { prev[key as keyof T] = deepMerge(pVal, value) as any; diff --git a/packages/library/src/common/runtime/mapper.library.ts b/packages/library/src/common/runtime/mapper.library.ts index ab63dc84..8e40bd9a 100644 --- a/packages/library/src/common/runtime/mapper.library.ts +++ b/packages/library/src/common/runtime/mapper.library.ts @@ -1,7 +1,5 @@ import { CONTEXT, getContext } from '#library/utility.library.js'; -import { isNullish, isNumber } from '#library/assertion.library.js'; -import { geoLocation } from '#browser/mapper.library.js'; -import { serverGeoLocation } from '#server/mapper.library.js'; +import { isNullish, isNumber, isString, isSafeKey } from '#library/assertion.library.js'; export interface GeoLookupResult { lat?: number; @@ -98,8 +96,15 @@ export const coerceGeo = (input?: any): GeoConfig | undefined => { if (isNumber(lng)) result.longitude = lng; if (isNumber(elevation)) result.elevation = elevation; if (sphere === 'north' || sphere === 'south') result.sphere = sphere; - if (typeof country === 'string') result.country = country; - if (typeof city === 'string') result.city = city; + if (isString(country)) result.country = country; + if (isString(city)) result.city = city; + + if (geoObj && typeof geoObj === 'object') { + for (const key of Object.keys(geoObj)) { + if (isSafeKey(key) && !['latitude', 'lat', 'longitude', 'lng', 'lon', 'long', 'elevation', 'sphere', 'country', 'city'].includes(key)) + (result as any)[key] = geoObj[key]; + } + } return Object.keys(result).length > 0 ? result : undefined; }; @@ -139,6 +144,7 @@ export const geoLookup = async (opts: Record = {}): Promise = {}): Promise = {} ): Promise<{ lat: number; lng: number } | null> => { - const coerced = coerceGeo(input) ?? getStashedGeo(); + const coerced = coerceGeo(input); if (coerced && isNumber(coerced.latitude) && isNumber(coerced.longitude)) return { lat: coerced.latitude, lng: coerced.longitude }; + const stashed = getStashedGeo(); + if (stashed && isNumber(stashed.latitude) && isNumber(stashed.longitude)) + return { lat: stashed.latitude, lng: stashed.longitude }; + const lookup = await geoLookup(opts); if (isNullish(lookup.error) && isNumber(lookup.lat) && isNumber(lookup.lng)) return { lat: lookup.lat, lng: lookup.lng }; diff --git a/packages/library/src/common/runtime/request.library.ts b/packages/library/src/common/runtime/request.library.ts index d7465c28..9c467dd5 100644 --- a/packages/library/src/common/runtime/request.library.ts +++ b/packages/library/src/common/runtime/request.library.ts @@ -182,9 +182,13 @@ export const fetchRequest = (url: string | URL, init = {} as RequestInit, con let errorBody: any = null; try { - const errorText = await res.text(); + const errorText = isDefined(config.maxBytes) + ? await readBoundedBody(res, config.maxBytes) + : (isFunction(res.text) ? await res.text() : ''); try { errorBody = JSON.parse(errorText); } catch { errorBody = errorText; } - } catch { } + } catch (err) { + if (err instanceof HttpError && err.status === 413) throw err; + } throw new HttpError(res.status, res.statusText, errorBody); // fetch not successful }) diff --git a/packages/library/src/common/runtime/utility.library.ts b/packages/library/src/common/runtime/utility.library.ts index df32e314..3846c0ae 100644 --- a/packages/library/src/common/runtime/utility.library.ts +++ b/packages/library/src/common/runtime/utility.library.ts @@ -1,5 +1,5 @@ import { ownEntries } from '#library/primitive.library.js'; -import { isDefined, isFunction, isPrimitive } from '#library/assertion.library.js'; +import { isDefined, isFunction, isPrimitive, isSafeKey } from '#library/assertion.library.js'; import { sym } from '#library/symbol.library.js'; import type { Secure, ValueOf } from '#library/type.library.js'; @@ -150,7 +150,7 @@ export function deepFreeze(obj: T, options?: { skip?: We seen.add(obj); ownEntries(obj as any).forEach(([key, val]) => { - if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') + if (isSafeKey(key)) deepFreeze(val, { skip }, seen); }); diff --git a/packages/library/test/common/primitives/assertion.library.test.ts b/packages/library/test/common/primitives/assertion.library.test.ts index d5f86695..83ac3c55 100644 --- a/packages/library/test/common/primitives/assertion.library.test.ts +++ b/packages/library/test/common/primitives/assertion.library.test.ts @@ -1,4 +1,4 @@ -import { isNumber, isNumeric, isText, isArrayLike, isPlainObject, isEmpty, isFunction } from '#library/assertion.library.js'; +import { isNumber, isNumeric, isText, isArrayLike, isPlainObject, isEmpty, isFunction, isSafeKey } from '#library/assertion.library.js'; describe('Assertion Library', () => { @@ -172,4 +172,20 @@ describe('Assertion Library', () => { expect(isFunction(undefined)).toBe(false); }); }); + + describe('isSafeKey', () => { + it('should return true for valid object property keys', () => { + expect(isSafeKey('name')).toBe(true); + expect(isSafeKey('id')).toBe(true); + expect(isSafeKey('latitude')).toBe(true); + expect(isSafeKey(0)).toBe(true); + expect(isSafeKey(Symbol('custom'))).toBe(true); + }); + + it('should return false for prototype pollution and hijacking keys', () => { + expect(isSafeKey('__proto__')).toBe(false); + expect(isSafeKey('constructor')).toBe(false); + expect(isSafeKey('prototype')).toBe(false); + }); + }); }); diff --git a/packages/library/test/common/primitives/object.library.test.ts b/packages/library/test/common/primitives/object.library.test.ts new file mode 100644 index 00000000..9a555b08 --- /dev/null +++ b/packages/library/test/common/primitives/object.library.test.ts @@ -0,0 +1,90 @@ +import { asObject, ifDefined, deepMerge, unQuoteObj, countProperties, pick, pluck, extend } from '#library/object.library.js'; + +describe('Object Library', () => { + describe('asObject', () => { + it('should clone plain objects recursively', () => { + const original = { a: 1, b: { c: 2 } }; + const cloned = asObject(original); + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + expect((cloned as any).b).not.toBe(original.b); + }); + + it('should exclude dangerous prototype keys (__proto__, constructor, prototype)', () => { + const payload = JSON.parse('{"a": 1, "__proto__": {"polluted": true}, "constructor": {"polluted": true}, "prototype": {"polluted": true}}'); + const copy = asObject(payload); + expect((copy as any).a).toBe(1); + expect(Object.prototype.hasOwnProperty.call(copy ?? {}, '__proto__')).toBe(false); + expect((copy as any)?.polluted).toBeUndefined(); + expect(({} as any).polluted).toBeUndefined(); + }); + + it('should return primitives and nullish values as-is', () => { + expect(asObject(null as any)).toBe(null); + expect(asObject(undefined as any)).toBe(undefined); + expect(asObject(42 as any)).toBe(42); + expect(asObject('test' as any)).toBe('test'); + }); + }); + + describe('ifDefined', () => { + it('should omit undefined and nullish properties', () => { + const input = { a: 1, b: undefined, c: null, d: false }; + const result = ifDefined(input); + expect(result).toEqual({ a: 1, d: false }); + }); + + it('should exclude dangerous prototype keys', () => { + const payload = JSON.parse('{"valid": 10, "__proto__": {"polluted": true}, "constructor": {"evil": true}}'); + const result = ifDefined(payload); + expect(result.valid).toBe(10); + expect(Object.prototype.hasOwnProperty.call(result ?? {}, '__proto__')).toBe(false); + expect((result as any)?.polluted).toBeUndefined(); + expect(({} as any).polluted).toBeUndefined(); + }); + }); + + describe('deepMerge', () => { + it('should merge nested objects cleanly', () => { + const res = deepMerge({ a: { x: 1 } }, { a: { y: 2 } }, { b: 3 }); + expect(res).toEqual({ a: { x: 1, y: 2 }, b: 3 }); + }); + + it('should guard against prototype pollution', () => { + const payload = JSON.parse('{"__proto__": {"admin": true}}'); + const merged = deepMerge({}, payload); + expect(({} as any).admin).toBeUndefined(); + expect((merged as any).admin).toBeUndefined(); + }); + }); + + describe('unQuoteObj', () => { + it('should format object keys without quotes', () => { + expect(unQuoteObj({ a: 1 })).toBe('{a: 1}'); + }); + }); + + describe('countProperties', () => { + it('should return number of own properties', () => { + expect(countProperties({ a: 1, b: 2, c: 3 })).toBe(3); + }); + }); + + describe('pick', () => { + it('should pick specified keys', () => { + expect(pick({ a: 1, b: 2, c: 3 }, 'a', 'c')).toEqual({ a: 1, c: 3 }); + }); + }); + + describe('pluck', () => { + it('should pluck key from array of objects', () => { + expect(pluck([{ id: 1 }, { id: 2 }], 'id')).toEqual([1, 2]); + }); + }); + + describe('extend', () => { + it('should extend target object', () => { + expect(extend({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); + }); + }); +}); diff --git a/packages/library/test/common/runtime/mapper.common.test.ts b/packages/library/test/common/runtime/mapper.common.test.ts index 04999ebf..76798a1e 100644 --- a/packages/library/test/common/runtime/mapper.common.test.ts +++ b/packages/library/test/common/runtime/mapper.common.test.ts @@ -1,6 +1,10 @@ -import { geoLookup, resolveGeoCoordinates } from '../../../src/common/runtime/mapper.library.js'; +import { coerceGeo, geoLookup, resolveGeoCoordinates } from '../../../src/common/runtime/mapper.library.js'; describe('common/runtime/mapper.library', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('resolveGeoCoordinates extracts coordinates synchronously if present', async () => { const coords = await resolveGeoCoordinates({ latitude: -33.8688, longitude: 151.2093 }); expect(coords).toEqual({ lat: -33.8688, lng: 151.2093 }); @@ -11,6 +15,34 @@ describe('common/runtime/mapper.library', () => { expect(coords).toEqual({ lat: 40.7128, lng: -74.0060 }); }); + it('coerceGeo excludes dangerous prototype keys (__proto__, constructor, prototype)', () => { + const dangerous = JSON.parse('{"city":"Sydney","__proto__":{"polluted":"yes"},"constructor":{"polluted":"yes"},"prototype":{"polluted":"yes"}}'); + const result = coerceGeo(dangerous); + expect(result?.city).toBe('Sydney'); + expect(Object.prototype.hasOwnProperty.call(result ?? {}, '__proto__')).toBe(false); + expect((result as any)?.polluted).toBeUndefined(); + expect(({} as any).polluted).toBeUndefined(); + }); + + it('resolveGeoCoordinates falls back to getStashedGeo when input only has metadata without coordinates', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + vi.stubGlobal('window', { document: {} }); + vi.stubGlobal('localStorage', { + getItem: vi.fn().mockReturnValue(JSON.stringify({ + geolocation: { coords: { latitude: -33.8688, longitude: 151.2093 } }, + })), + }); + + try { + const coords = await resolveGeoCoordinates({ city: 'Sydney', elevation: 150 }); + expect(coords).toEqual({ lat: -33.8688, lng: 151.2093 }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + it('geoLookup dispatches to server environment handler in Node.js', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, @@ -24,11 +56,13 @@ describe('common/runtime/mapper.library', () => { vi.stubGlobal('fetch', mockFetch); - const result = await geoLookup(); - expect(result.lat).toBe(-33.8688); - expect(result.lng).toBe(151.2093); - expect(result.city).toBe('Sydney'); - - vi.unstubAllGlobals(); + try { + const result = await geoLookup(); + expect(result.lat).toBe(-33.8688); + expect(result.lng).toBe(151.2093); + expect(result.city).toBe('Sydney'); + } finally { + vi.unstubAllGlobals(); + } }); }); diff --git a/packages/plugins/.bin/README.md b/packages/plugins/.bin/README.md index cd815ea0..b9db091d 100644 --- a/packages/plugins/.bin/README.md +++ b/packages/plugins/.bin/README.md @@ -1,11 +1,13 @@ # Plugin Support Binaries -This directory (`packages/plugins/bin/`) contains internal support scripts and utilities for developing and testing Tempo plugins within the monorepo. +This directory (`packages/plugins/.bin/`) contains internal support scripts and utilities for developing and testing Tempo plugins within the monorepo. It includes: - **REPL Environment (`repl.mts`)**: Scripts to initialize an interactive Node.js REPL session with Tempo and Temporal pre-loaded, making it easy to experiment with plugins from the CLI. - **Polyfill Setup (`temporal-polyfill.mts`)**: Initialization scripts to ensure the `@js-temporal/polyfill` is correctly loaded into the global scope during testing or REPL sessions, allowing plugins to work with native `Temporal` APIs before they are officially adopted by all runtimes. - **Catalog Synchronization (`catalog-sync.mjs`)**: A developer utility that scans all local and external plugin `package.json` files and extracts their metadata into a centralized `catalog.json` file. Run via `npm run catalog:sync`. +- **Version Check (`check-versions.sh`)**: Compares published NPM versions against local workspace versions to determine which plugins need to be published or re-published. Run via `npm run check:versions` or `npm run plugins:check-versions`. +- **Branch Diff Check (`check-branch-diff.sh`)**: Compares plugin files on the current branch against `main` (or a specified branch) to verify if modified plugins had their semantic versions bumped. Run via `npm run check:diff` or `npm run plugins:check-diff`. - **TypeScript Configuration (`tsconfig.json`)**: Specific compiler options for running these support scripts directly via tools like `tsx`. These files are meant for local monorepo development and testing purposes only. They are not published or distributed with any NPM packages. diff --git a/packages/plugins/.bin/check-versions.sh b/packages/plugins/.bin/check-versions.sh index 85ea4022..16d06498 100755 --- a/packages/plugins/.bin/check-versions.sh +++ b/packages/plugins/.bin/check-versions.sh @@ -3,18 +3,23 @@ set -e +# Resolve repository root path (3 levels up from packages/plugins/.bin) +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + packages=( "tempo:packages/tempo/package.json" - "tempo-plugin-ai:packages/plugins/ai/package.json" - "tempo-plugin-astro:packages/plugins/astro/package.json" - "tempo-plugin-batch:packages/plugins/batch/package.json" - "tempo-plugin-finance:packages/plugins/finance/package.json" - "tempo-plugin-snap:packages/plugins/snap/package.json" - "tempo-plugin-sync:packages/plugins/sync/package.json" ) -# Resolve repository root path (3 levels up from packages/plugins/.bin) -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +for plugin_dir in "${REPO_ROOT}/packages/plugins"/*; do + if [ -d "${plugin_dir}" ] && [ -f "${plugin_dir}/package.json" ]; then + is_private=$(node --input-type=module -e "import fs from 'fs'; console.log(JSON.parse(fs.readFileSync(process.argv[2], 'utf8')).private ? 'true' : 'false')" dummy "${plugin_dir}/package.json" 2>/dev/null || echo "false") + if [ "${is_private}" = "true" ]; then + continue + fi + plugin_name=$(basename "${plugin_dir}") + packages+=("tempo-plugin-${plugin_name}:packages/plugins/${plugin_name}/package.json") + fi +done printf "%-38s | %-16s | %-16s | %-12s\n" "Package Name" "Published (NPM)" "Local Workspace" "Status" printf "%-38s-+-%-16s-+-%-16s-+-%-12s\n" "--------------------------------------" "----------------" "----------------" "------------" diff --git a/packages/plugins/.setup/catalog.json b/packages/plugins/.setup/catalog.json index ab0ec4a9..4d557044 100644 --- a/packages/plugins/.setup/catalog.json +++ b/packages/plugins/.setup/catalog.json @@ -60,7 +60,7 @@ "packageName": "@magmacomputing/tempo-plugin-ai", "plan": "community", "status": "active", - "version": "1.1.1" + "version": "1.2.1" }, { "id": "ticker", @@ -69,7 +69,16 @@ "packageName": "@magmacomputing/tempo-plugin-ticker", "plan": "community", "status": "active", - "version": "2.3.0" + "version": "2.3.1" + }, + { + "id": "geo", + "name": "Geo Plugin", + "description": "Tempo community plugin for IP geolocation lookup, browser hardware location services, and coordinate resolution.", + "packageName": "@magmacomputing/tempo-plugin-geo", + "plan": "community", + "status": "active", + "version": "0.1.0" }, { "id": "_std", diff --git a/packages/plugins/.setup/community-plugin-template.md b/packages/plugins/.setup/community-plugin-template.md index 935bd109..aa787879 100644 --- a/packages/plugins/.setup/community-plugin-template.md +++ b/packages/plugins/.setup/community-plugin-template.md @@ -6,7 +6,7 @@ This template outlines the standard operating procedure for preparing and publis Ensure the plugin's `package.json` contains the correct community configuration: -- **Version**: Set to a fresh semantic version (e.g., `"1.0.0"` for the first release). +- **Version**: Set to `"0.1.0"` for the initial bootstrap release (allowing the official `1.0.0` GA release to be published via CI with full Sigstore provenance). - **License**: Must strictly be `"MIT"`. - **Type**: Set `"type": "module"`. - **Files**: Include the published files array: @@ -143,20 +143,42 @@ All exported components (functions, interfaces, classes, and types) must be prop export function myExportedFunction(input: string): string { ... } ``` -## 7. Release & CI Configuration (`.github/workflows/publish.yml`) +## 7. Monorepo & CI Configuration -When adding a new plugin to the monorepo, update `.github/workflows/publish.yml` to enable manual `workflow_dispatch` provenance releases: +### A. Update Monorepo Lockfile (`package-lock.json`) -1. **Add to Package Selector**: Add `@magmacomputing/tempo-plugin-[name]` to the `options` array under `inputs.package`. -2. **Add to Bulk Publish**: Add the workspace to the `all` branch in the publishing step: - ```bash - npm publish --workspace=@magmacomputing/tempo-plugin-[name] $PROVENANCE_FLAG - ``` +When adding a new workspace package, you **must** update the root monorepo lockfile so that `npm ci` in CI workflows recognizes the new workspace symlink: +```bash +npm install --package-lock-only +``` + +### B. Release Workflow Configuration (`.github/workflows/publish.yml`) + +Update `.github/workflows/publish.yml` to enable manual `workflow_dispatch` provenance releases: + +1. **Add to Package Selector**: Add `@magmacomputing/tempo-plugin-[name]` to the `options` array under `inputs.target`. +2. **Add to Target Validation**: Add `@magmacomputing/tempo-plugin-[name]` to the `case "$TARGET" in` validation pattern. +3. **Add to Bulk Publish**: Add `publish_pkg "@magmacomputing/tempo-plugin-[name]"` to the `if [ "$TARGET" = "all" ]` block. -## 8. NPM Registry Trusted Publisher Configuration (OIDC & Provenance) +## 8. Initial Release & Trusted Publisher Configuration (OIDC & Provenance) -When introducing a new plugin or helper package to the ecosystem, you **must** configure a **Trusted Publisher** on `npmjs.com` to enable CI publishing with cryptographic provenance (`--provenance`): +NPM Trusted Publishing (OIDC) requires that a package **already exists** on the npm registry before its access settings can be configured. Therefore, introducing a new plugin involves a one-time bootstrap step followed by configuring automated CI releases: +### Step 1: Manual Initial Publish (Bootstrap) +Because npm cannot configure Trusted Publishers for non-existent packages, the initial bootstrap release (`v0.1.0`) must be published manually by an authenticated maintainer: +1. Build the plugin and navigate to its workspace directory: + ```bash + npm run build --workspace=@magmacomputing/tempo-plugin-[name] + cd packages/plugins/[name] + ``` +2. Authenticate and publish the initial public version: + ```bash + npm login + npm publish --access public + ``` + +### Step 2: Configure NPM Trusted Publisher +Once the package exists on `npmjs.com`, configure GitHub Actions OIDC for all future releases: 1. **Navigate to Package Access**: Go to `https://www.npmjs.com/package/@magmacomputing/tempo-plugin-[name]/access`. 2. **Add Publisher**: Under **Publishing Access** $\rightarrow$ **Trusted Publishers**, click **Add GitHub Actions Publisher**. 3. **Configure Settings**: @@ -164,4 +186,6 @@ When introducing a new plugin or helper package to the ecosystem, you **must** c - **Repository**: `magma` - **Workflow filename**: `publish.yml` - **Environment**: *(leave blank unless using environment-gated deployments)* -4. **Why this is mandatory**: The Tempo monorepo uses GitHub Actions OIDC (`id-token: write`) to sign and publish packages with Sigstore provenance. Without an explicit Trusted Publisher binding for each new package on `npmjs.com`, NPM will reject `--provenance` publish attempts with `E404` or `E403` permission errors. + +### Step 3: Subsequent Releases via CI (`1.0.0`+) +Once configured, bump the package version to `1.0.0` (or subsequent versions) and trigger `.github/workflows/publish.yml` (`workflow_dispatch` or batch release). The release will be cryptographically signed and published with Sigstore provenance (`--provenance`) without requiring long-lived npm tokens. diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 64f08c5c..5c0a6101 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to the `@magmacomputing/tempo-plugin-ai` project will be doc 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). +## [1.2.1] - 2026-09-07 + +### Security & Reliability +- **Self-Contained Network Transport (`fetch.ts`)**: + - Decoupled network request utilities from `@magmacomputing/tempo/library` into a self-contained local transport helper. + - Added chunk-by-chunk stream consumption via `res.body.getReader()` with proactive byte accounting and immediate reader cancellation (`await reader.cancel()`). + - Enforced upfront `Content-Length` checks against `maxBytes` and strictly bounded stream reads to prevent unbounded memory allocation and OS thread starvation. + ## [1.2.0] - 2026-09-06 ### Added diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index a3ff231f..3b78cccb 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-ai", - "version": "1.2.0", + "version": "1.2.1", "description": "Tempo community plugin for LLM-powered natural language parsing.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/plugins/ai/src/core/fetch.ts b/packages/plugins/ai/src/core/fetch.ts new file mode 100644 index 00000000..d044f0ed --- /dev/null +++ b/packages/plugins/ai/src/core/fetch.ts @@ -0,0 +1,110 @@ +export class HttpError extends Error { + constructor( + public status: number, + public statusText: string, + public body: any = null + ) { + super(`${status}: ${statusText}`); + this.name = 'HttpError'; + } +} + +export interface FetchRequestConfig { + timeout?: number; + maxBytes?: number; + prefix?: string; + rawText?: boolean; +} + +async function readResponseBody(res: Response, maxBytes?: number): Promise { + if (maxBytes) { + const contentLength = res.headers?.get?.('content-length'); + if (contentLength) { + const parsed = parseInt(contentLength, 10); + if (!Number.isNaN(parsed) && parsed > maxBytes) { + try { await res.body?.cancel?.(); } catch { } + throw new HttpError(413, `Payload length exceeds limit (${maxBytes} bytes)`, null); + } + } + } + + if (maxBytes && res.body && typeof res.body.getReader === 'function') { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + const chunks: string[] = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel('maxBytes exceeded'); + throw new HttpError(413, `Payload length exceeds limit (${maxBytes} bytes)`, null); + } + chunks.push(decoder.decode(value, { stream: true })); + } + } + chunks.push(decoder.decode()); + return chunks.join(''); + } catch (err) { + try { await reader.cancel(); } catch { } + throw err; + } finally { + try { reader.releaseLock(); } catch { } + } + } else { + const text = await res.text(); + if (maxBytes && new TextEncoder().encode(text).byteLength > maxBytes) + throw new HttpError(413, `Payload length exceeds limit (${maxBytes} bytes)`, null); + + return text; + } +} + +/** + * Perform a bounded HTTP fetch request with timeout and error handling. + */ +export async function fetchRequest( + url: string | URL, + init: RequestInit = {}, + config: FetchRequestConfig = {} +): Promise { + const timeout = config.timeout ?? 5000; + const signal = init.signal + ? AbortSignal.any([init.signal, AbortSignal.timeout(timeout)]) + : AbortSignal.timeout(timeout); + + const res = await fetch(url, { ...init, signal }); + if (!res.ok) { + let errorBody: any = null; + try { + const errorText = await readResponseBody(res, config.maxBytes); + try { errorBody = JSON.parse(errorText); } catch { errorBody = errorText; } + } catch (err) { + if (err instanceof HttpError && err.status === 413) throw err; + } + throw new HttpError(res.status, res.statusText, errorBody); + } + + const text = await readResponseBody(res, config.maxBytes); + + if (config.rawText) + return text as unknown as T; + + const contentType = res.headers?.get?.('content-type') || ''; + if (contentType.includes('application/json')) { + try { + return JSON.parse(text) as T; + } catch { + return text as unknown as T; + } + } + + try { + return JSON.parse(text) as T; + } catch { + return text as unknown as T; + } +} diff --git a/packages/plugins/ai/src/core/manifest.ts b/packages/plugins/ai/src/core/manifest.ts index cfc44ce7..24f17bfb 100644 --- a/packages/plugins/ai/src/core/manifest.ts +++ b/packages/plugins/ai/src/core/manifest.ts @@ -1,4 +1,5 @@ -import { asText, evaluate, fetchRequest, isObject, isString, parseJSONC } from '@magmacomputing/tempo/library'; +import { asText, evaluate, isObject, isString, parseJSONC } from '@magmacomputing/tempo/library'; +import { fetchRequest } from './fetch.js'; import { DEFAULT_PROVIDERS } from './config.js'; import type { AiProvider } from '../types/index.js'; diff --git a/packages/plugins/ai/src/core/models.ts b/packages/plugins/ai/src/core/models.ts index 410592e5..9387b8c8 100644 --- a/packages/plugins/ai/src/core/models.ts +++ b/packages/plugins/ai/src/core/models.ts @@ -1,7 +1,8 @@ import { TempoAiError } from './error.js'; import { isValidManifestUrl } from './manifest.js'; import { RE_SAFE_PROVIDER_ID } from './patterns.js'; -import { asText, asNumber, fetchRequest, HttpError, isString, parseJSONC } from '@magmacomputing/tempo/library'; +import { fetchRequest, HttpError } from './fetch.js'; +import { asText, asNumber, isString, parseJSONC } from '@magmacomputing/tempo/library'; export interface ProviderModelInfo { id: string; diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts index 26c44050..1100b07c 100644 --- a/packages/plugins/ai/src/functions/diff.ts +++ b/packages/plugins/ai/src/functions/diff.ts @@ -36,8 +36,8 @@ function calculateGroundingMetrics(startTempo: Tempo, endTempo: Tempo, holidays? const holidaySet = new Set(holidays ?? []); const matchedHolidays: string[] = []; - let curr = from.set({ start: 'day' }); - const limit = to.set({ start: 'day' }); + let curr = from.set({ day: 'start' }); + const limit = to.set({ day: 'start' }); let businessDaysCount = 0; while (curr.epoch.ms < limit.epoch.ms) { diff --git a/packages/plugins/ai/test/fetch.test.ts b/packages/plugins/ai/test/fetch.test.ts new file mode 100644 index 00000000..91cab35f --- /dev/null +++ b/packages/plugins/ai/test/fetch.test.ts @@ -0,0 +1,60 @@ +import { fetchRequest, HttpError } from '../src/core/fetch.js'; + +describe('fetchRequest error body handling and maxBytes limit', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should parse error body on non-OK response within maxBytes', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'invalid_prompt' }), { + status: 400, + statusText: 'Bad Request', + headers: { 'content-type': 'application/json' }, + }) + ); + + await expect(fetchRequest('https://example.com/api', {}, { maxBytes: 1000 })).rejects.toMatchObject({ + status: 400, + statusText: 'Bad Request', + body: { error: 'invalid_prompt' }, + }); + }); + + it('should reject non-OK response if Content-Length exceeds maxBytes', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response('large error payload', { + status: 500, + statusText: 'Internal Server Error', + headers: { 'content-length': '5000' }, + }) + ); + + await expect(fetchRequest('https://example.com/api', {}, { maxBytes: 500 })).rejects.toMatchObject({ + status: 413, + message: expect.stringContaining('Payload length exceeds limit'), + }); + }); + + it('should reject non-OK streaming body if payload exceeds maxBytes', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('chunk1: 1234567890')); + controller.enqueue(new TextEncoder().encode('chunk2: 1234567890')); + controller.close(); + }, + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(stream, { + status: 502, + statusText: 'Bad Gateway', + }) + ); + + await expect(fetchRequest('https://example.com/api', {}, { maxBytes: 15 })).rejects.toMatchObject({ + status: 413, + message: expect.stringContaining('Payload length exceeds limit'), + }); + }); +}); diff --git a/packages/plugins/ai/test/manifest.test.ts b/packages/plugins/ai/test/manifest.test.ts index b048794e..67123928 100644 --- a/packages/plugins/ai/test/manifest.test.ts +++ b/packages/plugins/ai/test/manifest.test.ts @@ -300,8 +300,13 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { it('should reject streamed response without Content-Length when cumulative bytes exceed MAX_MANIFEST_BYTES', async () => { let cancelled = false; + let chunksProduced = 0; const stream = new ReadableStream({ pull(controller) { + if (chunksProduced++ > 20) { + controller.close(); + return; + } const chunk = new Uint8Array(256 * 1024); controller.enqueue(chunk); }, diff --git a/packages/plugins/celestial/README.md b/packages/plugins/celestial/README.md index 4706474c..2508e008 100644 --- a/packages/plugins/celestial/README.md +++ b/packages/plugins/celestial/README.md @@ -38,7 +38,11 @@ npm install @magmacomputing/tempo-plugin-celestial ### Obtaining Coordinates -Use `geoLookup()` from `@magmacomputing/tempo/library` to automatically resolve location coordinates across both browser and server environments: +Use `geoLookup()` from `@magmacomputing/tempo-plugin-geo` to automatically resolve location coordinates across both browser and server environments: + +```bash +npm install @magmacomputing/tempo-plugin-geo +``` > [!WARNING] > **Geolocation Behavior**: @@ -47,7 +51,7 @@ Use `geoLookup()` from `@magmacomputing/tempo/library` to automatically resolve ```typescript import { Tempo } from '@magmacomputing/tempo'; -import { geoLookup } from '@magmacomputing/tempo/library'; +import { geoLookup } from '@magmacomputing/tempo-plugin-geo'; import '@magmacomputing/tempo-plugin-celestial'; // Automatically resolves location coordinates via browser hardware or server IP diff --git a/packages/plugins/celestial/doc/index.md b/packages/plugins/celestial/doc/index.md index 5ad90de6..329660c2 100644 --- a/packages/plugins/celestial/doc/index.md +++ b/packages/plugins/celestial/doc/index.md @@ -38,7 +38,11 @@ npm install @magmacomputing/tempo-plugin-celestial ### Obtaining Coordinates -Use `geoLookup()` from `@magmacomputing/tempo/library` to automatically resolve location coordinates across both browser and server environments: +Use `geoLookup()` from `@magmacomputing/tempo-plugin-geo` to automatically resolve location coordinates across both browser and server environments: + +```bash +npm install @magmacomputing/tempo-plugin-geo +``` > [!WARNING] > **Geolocation Behavior**: @@ -47,7 +51,7 @@ Use `geoLookup()` from `@magmacomputing/tempo/library` to automatically resolve ```typescript import { Tempo } from '@magmacomputing/tempo'; -import { geoLookup } from '@magmacomputing/tempo/library'; +import { geoLookup } from '@magmacomputing/tempo-plugin-geo'; import '@magmacomputing/tempo-plugin-celestial'; // Automatically resolves location coordinates via browser hardware or server IP diff --git a/packages/plugins/geo/CHANGELOG.md b/packages/plugins/geo/CHANGELOG.md new file mode 100644 index 00000000..c94a8541 --- /dev/null +++ b/packages/plugins/geo/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to the `@magmacomputing/tempo-plugin-geo` 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). + +## [0.1.0] - 2026-09-07 + +### Added +- **Initial Community Release (Bootstrap)**: + - Decoupled IP geolocation lookup and browser hardware location services from core `@magmacomputing/tempo`. + - Exported functional utilities: `geoLookup()`, `resolveGeoCoordinates()`, `serverGeoLocation()`, `serverGeoCoords()`, `serverMapHemisphere()`, `geoLocation()`, `coerceGeo()`, `getStashedGeo()`. + - Exported `GeoPlugin` installing `geoLocate()` and `geoLookup()` on Tempo instances alongside static helpers on `Tempo`. diff --git a/packages/plugins/geo/LICENSE b/packages/plugins/geo/LICENSE new file mode 100644 index 00000000..dd7db2d9 --- /dev/null +++ b/packages/plugins/geo/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Magma Computing + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugins/geo/README.md b/packages/plugins/geo/README.md new file mode 100644 index 00000000..9df33348 --- /dev/null +++ b/packages/plugins/geo/README.md @@ -0,0 +1,55 @@ +![Tempo Plugin](https://raw.githubusercontent.com/magmacomputing/magma/main/packages/tempo/public/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-geo + +

+ npm version npm peer dependency version License TypeScript Ready Documentation +

+ +A Community plugin for the [Tempo](https://github.com/magmacomputing/magma) library that provides IP geolocation lookup, browser hardware location services, and coordinate resolution. + +👉 **[View the full documentation on our GitHub Pages](https://magmacomputing.github.io/magma/doc/9-plugins/geo.index.html)** + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-geo +``` + +## Usage + +### Functional Paradigm (Tree-Shakeable) + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { geoLookup, resolveGeoCoordinates } from '@magmacomputing/tempo-plugin-geo'; + +// Automatically resolves coordinates via browser hardware GPS or server IP lookup +const geo = await geoLookup(); +const t = new Tempo({ geo }); + +console.log(t.geo?.latitude, t.geo?.longitude); +``` + +### Fluent OOP Paradigm + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { GeoPlugin } from '@magmacomputing/tempo-plugin-geo'; + +Tempo.use(GeoPlugin); + +const t = new Tempo(); + +// Resolve coordinates asynchronously and return a new enriched Tempo instance +const localTime = await t.geoLocate(); +console.log(localTime.geo?.latitude, localTime.geo?.longitude); +``` + +## Documentation + +For full API reference and configuration options, please visit the official **[Geo Plugin Documentation ↗](https://magmacomputing.github.io/magma/doc/9-plugins/geo.index.html)**. + +## Licensing + +This is a **Community** plugin. It is completely free and open-source for personal and commercial use under the MIT license. diff --git a/packages/plugins/geo/doc/index.md b/packages/plugins/geo/doc/index.md new file mode 100644 index 00000000..a108f178 --- /dev/null +++ b/packages/plugins/geo/doc/index.md @@ -0,0 +1,51 @@ +![Tempo Plugin](/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-geo + +

+ npm version npm peer dependency version License TypeScript Ready +

+ +A Community plugin for the [Tempo](https://github.com/magmacomputing/magma) library that provides IP geolocation lookup, browser hardware location services, and coordinate resolution. + +By decoupling network-based geolocation lookup into a dedicated plugin, `@magmacomputing/tempo` remains zero-network and pure, while applications that need automatic location detection can easily install this plugin. + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-geo +``` + +## Features + +- **Cross-Environment Geolocation**: + - **Browser**: Uses hardware Geolocation API (`navigator.geolocation`) with local storage stashing. + - **Server (Node.js/Deno)**: Queries bounded IP geolocation lookup via secure HTTPS endpoints with automatic timeout and size limits. +- **Universal Coordinate Resolution**: + - Extracts coordinates from existing objects or instances, falling back to lookup only when needed. +- **Tree-Shakeable Functional APIs**: + - `geoLookup()`, `resolveGeoCoordinates()`, `serverGeoLocation()`, `geoLocation()`, `coerceGeo()`. +- **Fluent OOP Integration**: + - `t.geoLocate()`, `t.geoLookup()`, `Tempo.geoLookup()`. + +## Usage + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { geoLookup, GeoPlugin } from '@magmacomputing/tempo-plugin-geo'; + +// Functional: +const geo = await geoLookup(); +const t1 = new Tempo({ geo }); +console.log(t1.geo); + +// Fluent OOP: +Tempo.use(GeoPlugin); +const t2 = new Tempo(); +const localTime = await t2.geoLocate(); +console.log(localTime.geo); +``` + +## Licensing + +This is a **Community** plugin. It is completely free and open-source for personal and commercial use under the MIT license. diff --git a/packages/plugins/geo/package.json b/packages/plugins/geo/package.json new file mode 100644 index 00000000..1f10ac15 --- /dev/null +++ b/packages/plugins/geo/package.json @@ -0,0 +1,56 @@ +{ + "name": "@magmacomputing/tempo-plugin-geo", + "version": "0.1.0", + "description": "Tempo community plugin for IP geolocation lookup, browser hardware location services, and coordinate resolution.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/magmacomputing/magma.git", + "directory": "packages/plugins/geo" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "scripts": { + "build": "tsup && tsc", + "test": "vitest run -c ../vitest.shared.ts", + "prepublishOnly": "tempo-cli prepublish" + }, + "tempo": { + "vendorVariantId": "tempo-plugin-geo", + "plan": "community" + }, + "peerDependencies": { + "@magmacomputing/tempo": "^4.1.0" + }, + "devDependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, + "keywords": [ + "tempo", + "tempo-plugin", + "magmacomputing", + "geo", + "geolocation", + "coordinates", + "ipwhois", + "location" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + } +} \ No newline at end of file diff --git a/packages/plugins/geo/src/index.ts b/packages/plugins/geo/src/index.ts new file mode 100644 index 00000000..21ec0c20 --- /dev/null +++ b/packages/plugins/geo/src/index.ts @@ -0,0 +1,107 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { definePlugin, type TempoPlugin } from '@magmacomputing/tempo/plugin/sdk'; +import { + geoLookup, + resolveGeoCoordinates, + coerceGeo, + getStashedGeo, + type GeoLookupResult, + type GeoConfig, + type CoordinateInput, +} from '@magmacomputing/library/runtime/mapper.library.js'; +import { + serverGeoLocation, + serverGeoCoords, + serverMapHemisphere, + type ServerMapOpts, + type ServerGeolocationResult, +} from '@magmacomputing/library/server/mapper.library.js'; +import { + geoLocation, +} from '@magmacomputing/library/browser/mapper.library.js'; + +export { + geoLookup, + resolveGeoCoordinates, + coerceGeo, + getStashedGeo, + serverGeoLocation, + serverGeoCoords, + serverMapHemisphere, + geoLocation, +}; + +export type { + GeoLookupResult, + GeoConfig, + CoordinateInput, + ServerMapOpts, + ServerGeolocationResult, +}; + +type GeoLookupFn = typeof geoLookup; +type ResolveGeoCoordinatesFn = typeof resolveGeoCoordinates; +type ServerGeoLocationFn = typeof serverGeoLocation; +type GeoLocationFn = typeof geoLocation; + +/** + * GeoPlugin installs geolocation lookup and coordinate resolution helpers onto Tempo. + */ +export const GeoPlugin: TempoPlugin = definePlugin({ + name: 'geo', + install(TempoClass: any) { + TempoClass.geoLookup = geoLookup; + TempoClass.resolveGeoCoordinates = resolveGeoCoordinates; + TempoClass.serverGeoLocation = serverGeoLocation; + TempoClass.geoLocation = geoLocation; + + /** + * Asynchronously resolves coordinates for the current instance (or uses existing coordinates), + * returning a new Tempo instance with the resolved `geo` configuration attached. + */ + TempoClass.prototype.geoLocate = async function (this: Tempo, opts?: Record): Promise { + const coords = await resolveGeoCoordinates(this, opts); + if (coords) { + const existingGeo = (typeof this.config.geo === 'object' && this.config.geo !== null) ? this.config.geo : {}; + return new TempoClass(this, { + ...this.config, + geo: { + ...existingGeo, + latitude: coords.lat, + longitude: coords.lng, + }, + }); + } + return this; + }; + + /** + * Resolves coordinates for this instance via explicit coordinates or automatic IP/hardware lookup. + */ + TempoClass.prototype.geoLookup = async function (this: Tempo, opts?: Record): Promise<{ lat: number; lng: number } | null> { + return resolveGeoCoordinates(this, opts); + }; + }, +}); + +export const geoPlugin = GeoPlugin; +export default GeoPlugin; + +declare module '@magmacomputing/tempo' { + interface Tempo { + /** + * Asynchronously resolves coordinates for this instance and returns a new Tempo instance with geo set. + */ + geoLocate(opts?: Record): Promise; + /** + * Resolves coordinates for this instance via explicit coordinates or automatic IP/hardware lookup. + */ + geoLookup(opts?: Record): Promise<{ lat: number; lng: number } | null>; + } + namespace Tempo { + let geoLookup: GeoLookupFn; + let resolveGeoCoordinates: ResolveGeoCoordinatesFn; + let serverGeoLocation: ServerGeoLocationFn; + let geoLocation: GeoLocationFn; + } +} diff --git a/packages/plugins/geo/test/geo.test.ts b/packages/plugins/geo/test/geo.test.ts new file mode 100644 index 00000000..d71a819e --- /dev/null +++ b/packages/plugins/geo/test/geo.test.ts @@ -0,0 +1,161 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { + GeoPlugin, + geoLookup, + resolveGeoCoordinates, + coerceGeo, + serverGeoLocation, +} from '../src/index.js'; + +describe('Tempo Plugin: Geo', () => { + beforeAll(() => { + Tempo.use(GeoPlugin); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Pure functional coordinate utilities', () => { + it('should coerce various coordinate input formats', () => { + expect(coerceGeo({ lat: -33.8688, lng: 151.2093 })).toEqual({ + latitude: -33.8688, + longitude: 151.2093, + }); + + expect(coerceGeo({ latitude: 40.7128, longitude: -74.006 })).toEqual({ + latitude: 40.7128, + longitude: -74.006, + }); + + expect(coerceGeo(undefined)).toBeUndefined(); + expect(coerceGeo(null)).toBeUndefined(); + }); + + it('should resolve coordinates directly from Tempo instance config', async () => { + const t = new Tempo('2026-06-21T12:00:00Z', { + geo: { lat: 51.5074, lng: -0.1278 }, + }); + + const coords = await resolveGeoCoordinates(t); + expect(coords).toEqual({ + lat: 51.5074, + lng: -0.1278, + }); + }); + + it('should perform mocked server geolocation via serverGeoLocation', async () => { + const mockPayload = { + ip: '8.8.8.8', + success: true, + lat: 37.4223, + lon: -122.0848, + country: 'United States', + city: 'Mountain View', + timezone: 'America/Los_Angeles', + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockPayload), { status: 200 }) + ); + + const result = await serverGeoLocation(); + expect(result.status).toBe('success'); + expect(result.lat).toBe(37.4223); + expect(result.lng).toBe(-122.0848); + expect(result.city).toBe('Mountain View'); + }); + + it('should perform mocked geoLookup on server', async () => { + const mockPayload = { + ip: '1.1.1.1', + success: true, + lat: -33.8688, + lon: 151.2093, + country: 'Australia', + city: 'Sydney', + timezone: 'Australia/Sydney', + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockPayload), { status: 200 }) + ); + + const result = await geoLookup(); + expect(result.lat).toBe(-33.8688); + expect(result.lng).toBe(151.2093); + }); + }); + + describe('Tempo OOP Integration via GeoPlugin', () => { + it('should attach static methods onto Tempo class', () => { + expect(typeof Tempo.geoLookup).toBe('function'); + expect(typeof Tempo.resolveGeoCoordinates).toBe('function'); + expect(typeof Tempo.serverGeoLocation).toBe('function'); + }); + + it('should allow instance method .geoLookup() to resolve coordinates', async () => { + const t = new Tempo('2026-01-01', { + geo: { latitude: 35.6762, longitude: 139.6503 }, + }); + + const coords = await t.geoLookup(); + expect(coords).toEqual({ + lat: 35.6762, + lng: 139.6503, + }); + }); + + it('should allow instance method .geoLocate() to return a new Tempo instance with geo set', async () => { + const t = new Tempo('2026-01-01'); + expect(t.geo).toBeUndefined(); + + const mockPayload = { + ip: '1.1.1.1', + success: true, + lat: 48.8566, + lon: 2.3522, + }; + + vi.spyOn(globalThis, 'fetch').mockImplementation( + () => Promise.resolve(new Response(JSON.stringify(mockPayload), { status: 200 })) + ); + + const located = await t.geoLocate(); + expect(located).toBeInstanceOf(Tempo); + expect(located.geo).toBeDefined(); + expect(located.geo?.latitude).toBe(48.8566); + expect(located.geo?.longitude).toBe(2.3522); + }); + + it('should preserve existing geo properties (elevation, sphere, custom keys) when calling .geoLocate()', async () => { + const t = new Tempo('2026-01-01', { + geo: { + elevation: 150, + sphere: 'south', + city: 'Sydney', + customKey: 'customValue', + } as any, + }); + + const mockPayload = { + ip: '1.1.1.1', + success: true, + lat: -33.8688, + lon: 151.2093, + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockPayload), { status: 200 }) + ); + + const located = await t.geoLocate(); + expect(located.geo?.latitude).toBe(-33.8688); + expect(located.geo?.longitude).toBe(151.2093); + expect((located.geo as any)?.elevation).toBe(150); + expect((located.geo as any)?.sphere).toBe('south'); + expect((located.geo as any)?.city).toBe('Sydney'); + expect((located.geo as any)?.customKey).toBe('customValue'); + }); + }); +}); diff --git a/packages/plugins/geo/test/tsconfig.json b/packages/plugins/geo/test/tsconfig.json new file mode 100644 index 00000000..642ee082 --- /dev/null +++ b/packages/plugins/geo/test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.test.json", + "include": [ + "**/*.ts" + ] +} diff --git a/packages/plugins/geo/tsconfig.json b/packages/plugins/geo/tsconfig.json new file mode 100644 index 00000000..2e994979 --- /dev/null +++ b/packages/plugins/geo/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.shared.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "emitDeclarationOnly": true + }, + "include": [ + "src" + ] +} diff --git a/packages/plugins/geo/tsup.config.ts b/packages/plugins/geo/tsup.config.ts new file mode 100644 index 00000000..9281e89b --- /dev/null +++ b/packages/plugins/geo/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; +import { sharedConfig } from '../tsup.shared.js'; + +export default defineConfig({ + ...sharedConfig, + entry: ['src/index.ts'], + noExternal: [ + ...(Array.isArray(sharedConfig.noExternal) ? sharedConfig.noExternal : []), + /^@magmacomputing\/library/, + /^#library/, + /^#server/, + /^#browser/, + ], +}); diff --git a/packages/plugins/tsup.shared.ts b/packages/plugins/tsup.shared.ts index 8405ece5..74e03c31 100644 --- a/packages/plugins/tsup.shared.ts +++ b/packages/plugins/tsup.shared.ts @@ -20,6 +20,7 @@ if (fs.existsSync(pkgPath)) { export const sharedConfig: Options = { format: ['esm', 'iife'], + splitting: false, globalName: `Magma.plugins.${pkgName}`, // @magmacomputing/tempo/plugin* is inlined only for IIFE (self-contained browser bundle). // For ESM the 'esm-external' plugin below re-marks them as external so all plugins diff --git a/packages/plugins/vitest.shared.ts b/packages/plugins/vitest.shared.ts index 15ec9ad3..6110f085 100644 --- a/packages/plugins/vitest.shared.ts +++ b/packages/plugins/vitest.shared.ts @@ -47,6 +47,8 @@ export default defineConfig({ { find: /^@magmacomputing\/tempo-fns$/, replacement: resolve(__dirname, '../functions/src/index.ts') }, { find: /^@magmacomputing\/tempo-fns\/(.*)$/, replacement: resolve(__dirname, '../functions/src/$1.ts') }, { find: /^@magmacomputing\/library$/, replacement: resolve(__dirname, '../library/src/common.index.ts') }, + { find: /^@magmacomputing\/library\/(primitives|temporal|security|scheduling|runtime)\/(.*)$/, replacement: resolve(__dirname, '../library/src/common/$1/$2') }, + { find: /^@magmacomputing\/library\/(browser|server)\/(.*)$/, replacement: resolve(__dirname, '../library/src/$1/$2') }, { find: /^@magmacomputing\/library\/(.*)$/, replacement: resolve(__dirname, '../library/src/$1.ts') }, { find: /^@magmacomputing\/tempo\/plugin\/sdk$/, replacement: resolve(__dirname, '../tempo/src/plugin/plugin.sdk.ts') }, { find: /^@magmacomputing\/tempo\/library$/, replacement: resolve(__dirname, '../tempo/src/library.index.ts') }, diff --git a/packages/tempo/.socketignore b/packages/tempo/.socketignore deleted file mode 100644 index 78a7f6dc..00000000 --- a/packages/tempo/.socketignore +++ /dev/null @@ -1,21 +0,0 @@ -# Socket.dev ignore rules for Tempo -add.day -subtract.day -add.mm -add.ss -add.ms -add.us -subtract.mm -subtract.ss -subtract.ms -subtract.us -set.date -set.day -set.mm -set.ss -set.ms -set.us -start.day -mid.day -end.day -https://ipwho.is/ diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index ba28b459..4d557044 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -60,7 +60,7 @@ "packageName": "@magmacomputing/tempo-plugin-ai", "plan": "community", "status": "active", - "version": "1.2.0" + "version": "1.2.1" }, { "id": "ticker", @@ -71,6 +71,15 @@ "status": "active", "version": "2.3.1" }, + { + "id": "geo", + "name": "Geo Plugin", + "description": "Tempo community plugin for IP geolocation lookup, browser hardware location services, and coordinate resolution.", + "packageName": "@magmacomputing/tempo-plugin-geo", + "plan": "community", + "status": "active", + "version": "0.1.0" + }, { "id": "_std", "name": "_std Plugin", diff --git a/packages/tempo/.vitepress/theme/data/plugins-sidebar.json b/packages/tempo/.vitepress/theme/data/plugins-sidebar.json index 986ba1db..a73c41d1 100644 --- a/packages/tempo/.vitepress/theme/data/plugins-sidebar.json +++ b/packages/tempo/.vitepress/theme/data/plugins-sidebar.json @@ -78,6 +78,10 @@ "text": "Community & Pro Plugins", "collapsed": false, "items": [ + { + "text": "@magmacomputing/tempo-plugin-geo", + "link": "/doc/9-plugins/geo.index" + }, { "text": "Astro (Seasons & Solstices)", "link": "/doc/9-plugins/astro.index" diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 7af10140..e2dff00f 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,34 @@ 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). +## [4.1.1] - 2026-09-07 + +### Added +- **Mutation & Security Test Coverage**: + - Added `mutate_aliases.test.ts` covering unit subtraction aliases (`subtract`, `sub`, `minus`). + - Added boundary symmetry test coverage in `instance.set.test.ts`. + - Added prototype pollution rejection and config inheritance test coverage in `config.remote.test.ts`. + +### Changed +- **Standardized `pluginOptions` Configuration & Type Cleanup**: + - The 'plugins' key as a JSON of plugin-configuration settings has been migrated to `pluginOptions` so as not to overload the `plugins` key (which registers Plugin instances / Terms / Modules / Namespaces), removing the deprecated `Record` dictionary union from `BaseOptions.plugins` and `Config.plugins`. + - Updated default configuration in `support.default.ts` to `pluginOptions: {}`. +- **Streamlined Mutation Engine (`module.mutate.ts`)**: + - **Negative-Add Normalization**: Refactored standard unit subtractions (`subtract`, `sub`, `minus`) to invert numeric offsets (`-adjust`) and normalize operation type to `'add'`, eliminating 21 redundant `subtract:*` cases and ternaries from `switch (slug)` while preserving directional shift semantics for terms. + - **Direct Term Evaluation**: Replaced intermediate string matching (`slug.endsWith(':term')`) with direct property identity evaluation (`single === 'term'`), allowing term mutations to resolve directly before slug interpolation and optimizing dispatch performance. +- **Documentation Alignment**: + - Updated shorthand and mutation guides in `tempo.shorthand.md` and `migration-guide.md` to recommend modern Unit-Key syntax (`{ '#namespace': 'start' | 'mid' | 'end' }`). + - Clarified architecture callouts in `tempo.config.md` regarding the clean separation between code registration (`plugins`) and configuration dictionaries (`pluginOptions`). + +### Security +- **Prototype Pollution Defense in Configuration Merging**: + - Hardened `resolveConfig` and `mergeConfigs` using centralized `isSafeKey` validation, strictly blocking `__proto__`, `constructor`, and `prototype` keys during recursive cascading inheritance of `pluginOptions`. +- **Configuration Resolution Boundaries**: + - Restricted `extends` inheritance targets strictly to static `.json` and `.jsonc` data files, preventing dynamic JS/TS execution from extended files. + - Validated `file://` URLs against remote hostnames and removed remote HTTP(S) config fetching vulnerabilities from `resolveConfig`. +- **Packaging Maintenance**: + - Removed obsolete inline Socket security rules and file tracking from `package.json`. + ## [4.1.0] - 2026-09-04 ### Added diff --git a/packages/tempo/doc/2-core-concepts/tempo.config.md b/packages/tempo/doc/2-core-concepts/tempo.config.md index 59075999..e304cfb6 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.config.md +++ b/packages/tempo/doc/2-core-concepts/tempo.config.md @@ -32,7 +32,7 @@ import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; export default defineConfig({ timeZone: 'Australia/Sydney', // Set your baseline timezone - extends: 'https://central-governance.company.com/tempo-base.config.jsonc', // Inherit base config + extends: './tempo-base.config.jsonc', // Inherit local base config plugins: [ AstroTerm, // 1. Executable plugin or term TickerPlugin, // 2. Plugin singleton (or factory closure) @@ -53,8 +53,8 @@ export default defineConfig({ ::: tip Clean Separation: Feature Registration vs Plugin Configuration Tempo separates code registration from data configuration: - **`plugins`**: Strictly registers executable plugins, namespaces, terms, or factory closures (`(Plugin | Term)[]`). -- **`pluginOptions`**: Dedicated dictionary holding runtime options and configuration defaults for plugins (`Record`), serializable in `tempo.config.json` and cascading across remote `extends`. -- *(Deprecated)* Passing a configuration dictionary directly into `plugins` or supplying `plugins: { ... }` as an object remains supported for backward compatibility, but is marked `@deprecated` in favor of `pluginOptions`. +- **`pluginOptions`**: Dedicated dictionary holding runtime options and configuration defaults for plugins (`Record`), serializable in `tempo.config.json` and cascading across local `extends`. +- The 'plugins' key as a JSON of plugin-configuration settings has been migrated to 'pluginOptions' so as not to overload the 'plugins' key (which registers Plugin instances / Terms / Modules / Namespaces). ::: You can then bootstrap this environment at the very top of your application's entry point (e.g., `main.ts` or `index.js`) to guarantee the configuration is locked in before any other files run: @@ -63,24 +63,24 @@ You can then bootstrap this environment at the very top of your application's en // main.ts import { Tempo } from '@magmacomputing/tempo'; -// Automatically discovers and loads local 'tempo.config.ts' +// Automatically discovers and loads local 'tempo.config.ts' (or .js / .json / .jsonc) await Tempo.bootstrap(); -// OR: Bootstrap from a remote corporate configuration endpoint -await Tempo.bootstrap({ configFile: 'https://config.internal.company.com/tempo.config.jsonc' }); +// OR: Bootstrap from an explicit local configuration file or path +await Tempo.bootstrap({ configFile: './configs/tempo.production.jsonc' }); // Dynamic import ensures domain logic loads ONLY AFTER configuration is complete const { App } = await import('./app.js'); // ... ``` -### Remote & Cascading Configurations (`"extends"`) +### Cascading Configurations (`"extends"`) -Tempo configuration files can inherit settings from parent baseline configurations—including remote HTTP(S) endpoints or `file://` URLs—via the `"extends"` key. Local options automatically override parent baseline options: +Tempo configuration files can inherit settings from parent baseline configurations via local file paths or `file://` URLs using the `"extends"` key. Local options automatically override parent baseline options: ```jsonc // tempo.config.jsonc { - "extends": "https://central-governance.company.com/tempo-base.config.jsonc", + "extends": "./tempo-base.config.jsonc", "timeZone": "Australia/Sydney", // Local override "registry": { "periods": { @@ -92,20 +92,20 @@ Tempo configuration files can inherit settings from parent baseline configuratio ### Benefits vs. Drawbacks -Using `tempo.config.ts` or `Tempo.bootstrap()` is the modern standard, but it introduces specific architectural tradeoffs due to Node.js ES Module and network boundary constraints. +Using `tempo.config.ts` or `Tempo.bootstrap()` is the modern standard, but it introduces specific architectural tradeoffs due to Node.js ES Module and asynchronous I/O boundaries. #### 🌟 Benefits - **TypeScript Autocomplete**: Using `defineConfig` provides instant IDE intellisense and type-safety for all configuration options. - **Plugin Execution**: You can import and instantiate plugins directly inside JS/TS configuration files, keeping your application logic clean. -- **Central Governance & Remote Cascading**: Enables fetching centralized corporate configs over HTTP(S) or `file://` with recursive inheritance and cycle detection. +- **Cascading Configuration Inheritance**: Enables inheriting shared base configs via relative paths, absolute paths, or `file://` URLs with recursive inheritance and cycle detection. - **Dynamic Configuration**: Enables runtime logic (e.g., `debug: process.env.NODE_ENV !== 'production'`) in JS/TS configs that strict JSON cannot provide. #### ⚠️ Drawbacks -- **Asynchronous Requirement**: Because dynamic config loading and remote fetches are asynchronous, you **must** use `await Tempo.bootstrap()` instead of synchronous calls. +- **Asynchronous Requirement**: Because dynamic config loading and file reading are asynchronous, you **must** use `await Tempo.bootstrap()` instead of synchronous calls. #### 🛑 Security & Reliability Bounds -- **Remote Payload Security**: Remote HTTP(S) URLs only load static JSON/JSONC payloads via `parseJSONC`. Dynamic JavaScript execution (`eval` / dynamic `import`) from HTTP URLs is prohibited for supply chain security. -- **Request Safety**: Remote requests feature a strict 3-second timeout and 128KB maximum payload limit to prevent network hangs. +- **Local Data-Only Inheritance**: The `"extends"` mechanism strictly supports static `.json` and `.jsonc` data files parsed via `parseJSONC`. Dynamic JavaScript execution (`eval` or dynamic `import`) from extended configurations is prohibited to prevent unintended code execution in inherited configs. +- **Local Boundary Safety**: Remote HTTP(S) URLs are rejected when specified as `configFile` (returning `undefined`), whereas remote `"extends"` targets are skipped with a warning while local configuration processing continues, protecting against external network dependencies and remote code injection. `file://` URLs are validated to disallow remote hosts. - **Floating Promises**: You must ensure you actually `await` the bootstrap call. If you forget the `await` keyword, your application will continue booting before Tempo finishes reading your config file, leading to race conditions where early instances use default settings. ::: tip @@ -190,8 +190,9 @@ Tempo looks for the following structure: | :--- | :--- | :--- | | `options` | `Options \| (() => Options)` | Configuration options merged into global state. | | `intl` | `IntlOptions` | Internationalization configuration grouping `relativeTimeFormat`, `numberFormat`, `durationFormat`, and `dateTimeFormat`. | -| `extends` | `string \| string[]` | Remote URL(s) or local file path(s) to inherit base configuration from. | -| `plugins` | `(Plugin \| TermPlugin)[] \| Record` | Modular plugins/terms to register, or plugin configuration dictionaries. | +| `extends` | `string \| string[]` | Local file path(s) or `file://` URL(s) to inherit base configuration from. | +| `plugins` | `(Plugin \| TermPlugin)[]` | Modular plugins/terms to register during configuration. | +| `pluginOptions` | `Record` | Plugin configuration defaults and dictionaries keyed by plugin name. | | `timeZones` | `Record` | Custom timezone aliases to be merged. | | `registry` | `{ formats?, locales?, numbers?, events?, periods?, snippets?, layouts?, ignores?, modifiers?, tokens? }` | Custom configuration for internal dictionary registries. | @@ -230,8 +231,9 @@ Tempo.init({ | `sphere` | `Evaluable<'north' \| 'south'>`| Auto-inferred | Hemisphere for seasonal plugins or dynamic supplier. | | `intl` | `IntlOptions` | `undefined` | Internationalization configuration grouping `relativeTimeFormat`, `numberFormat`, and `durationFormat`. | | `registry` | `{ formats?, locales?, numbers?, events?, periods?, snippets?, layouts?, ignores?, modifiers? }` | Built-in registries | Custom data augmentation registries (e.g., format aliases, number-to-word mappings, parsing logic, localization). | -| `extends` | `string \| string[]` | `undefined` | Remote URL(s) or local file path(s) to inherit base configuration from. | -| `plugins` | `(Plugin \| TermPlugin)[] \| Record` | `[]` | Plugins/terms to register during initialization (applied via `Tempo.use(p)`), or plugin options dictionaries. | +| `extends` | `string \| string[]` | `undefined` | Local file path(s) or `file://` URL(s) to inherit base configuration from. | +| `plugins` | `(Plugin \| TermPlugin)[]` | `[]` | Plugins/terms to register during initialization (applied via `Tempo.use(p)`). | +| `pluginOptions` | `Record` | `{}` | Plugin configuration defaults and dictionaries keyed by plugin name. | | `store` | `string` | `'$Tempo'` | Persistent storage key used by `readStore`/`writeStore`. | | `discovery` | `string \| symbol` | `'$Tempo'` symbol key | Discovery slot used to resolve global discovery config. | | `debug` | `number \| string` | `'info'` | Controls log verbosity via direct `LOG` levels (`0=Off ... 5=Trace`) or string labels (`'trace'`, `'info'`, etc). | diff --git a/packages/tempo/doc/4-advanced-reference/tempo.shorthand.md b/packages/tempo/doc/4-advanced-reference/tempo.shorthand.md index 701eef0f..5926828b 100644 --- a/packages/tempo/doc/4-advanced-reference/tempo.shorthand.md +++ b/packages/tempo/doc/4-advanced-reference/tempo.shorthand.md @@ -30,14 +30,15 @@ t.subtract({ '#qtr': 1 }); // Resolves backwards to 20 days into the previous Qu t.set({ '#qtr': 2 }); // Aligns to the start of the 2nd quarter of the current year ``` -### C. Structural Mode (Key Shorthand) +### C. Structural Mode (Boundary Shorthand) **Used in:** `.set()`. -**Pattern:** `{ start: '#namespace', end: '#namespace' }` -**Best for:** Snapping a date to the precise boundaries of its current Term. +**Pattern:** `{ '#namespace': 'start' | 'mid' | 'end' }` +**Best for:** Snapping a date to the precise boundaries or midpoint of its current Term. ```javascript -t.set({ start: '#qtr' }); // Snaps to the exact start of the current quarter -t.set({ end: '#qtr' }); // Snaps to the final nanosecond of the current quarter +t.set({ '#qtr': 'start' }); // Snaps to the exact start of the current quarter +t.set({ '#qtr': 'end' }); // Snaps to the final nanosecond of the current quarter +t.set({ '#qtr': 'mid' }); // Snaps to the midpoint of the current quarter ``` --- diff --git a/packages/tempo/doc/8-project-and-support/migration-guide.md b/packages/tempo/doc/8-project-and-support/migration-guide.md index 9f4ea4c0..c6e6a27d 100644 --- a/packages/tempo/doc/8-project-and-support/migration-guide.md +++ b/packages/tempo/doc/8-project-and-support/migration-guide.md @@ -64,16 +64,16 @@ Tempo v4.1.0 introduces cascading configuration inheritance (`extends`), a dedic In v4.1.0, configuration, plugin registration, and runtime options have been cleanly separated: -- **Configuration Inheritance (`extends`)**: The `extends` option in `Tempo.init()` or `tempo.config.json` is strictly reserved for cascading configuration inheritance via URLs or file paths (mirroring `tsconfig.json` and ESLint conventions): `extends: 'https://company.org/tempo-base.json'`. +- **Configuration Inheritance (`extends`)**: The `extends` option in `Tempo.init()` or `tempo.config.json` is strictly reserved for cascading configuration inheritance via local file paths or `file://` URLs (mirroring `tsconfig.json` and ESLint conventions): `extends: './tempo-base.json'`. - **Plugin Registration (`plugins`)**: Pass executable plugins, terms, and modules into `plugins: [TickerPlugin, AstroTerm]`. -- **Plugin Configuration Slot (`pluginOptions`)**: Pass runtime configuration defaults for plugins into `pluginOptions: { ticker: { interval: 500 } }`. Passing plain configuration dictionaries directly under `plugins` is `@deprecated`. +- **Plugin Configuration Slot (`pluginOptions`)**: Pass runtime configuration defaults for plugins into `pluginOptions: { ticker: { interval: 500 } }`. The 'plugins' key as a JSON of plugin-configuration settings has been migrated to `pluginOptions` so as not to overload the `plugins` key (which registers Plugin instances / Terms / Modules / Namespaces). - **Imperative Registration (`Tempo.use`)**: Use the standard `Tempo.use(Plugin)` static method to register plugins, terms, or modules at runtime. `Tempo.extend()` is `@deprecated Use Tempo.use(...) instead.`. ### Example: ```javascript // ✅ v4.1.0: Clean Separation Tempo.init({ - extends: 'https://central-governance.company.com/tempo-base.json', // Configuration inheritance + extends: './tempo-base.json', // Configuration inheritance plugins: [TickerPlugin, AstroTerm], // Feature & Term registration pluginOptions: { // Plugin runtime options ticker: { interval: 500 } @@ -201,7 +201,7 @@ The way Terms (Quarters, Seasons, Zodiacs, etc.) are handled has been unified. Example of new syntax: ```javascript // Snap to start of quarter -t.set({ start: '#quarter' }); +t.set({ '#quarter': 'start' }); // Add two quarters while preserving day-of-quarter t.add({ '#quarter': 2 }); diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 6fe3493b..d0c4f8b2 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "4.1.0", + "version": "4.1.1", "engines": { "node": ">=20.0.0" }, @@ -271,38 +271,8 @@ "CHANGELOG.md", "SECURITY.md", "LICENSE", - ".socketignore", "dist/" ], - "socket": { - "issueRules": { - "url": { - "action": "ignore", - "ignore": [ - "add.day", - "subtract.day", - "add.mm", - "add.ss", - "add.ms", - "add.us", - "subtract.mm", - "subtract.ss", - "subtract.ms", - "subtract.us", - "set.date", - "set.day", - "set.mm", - "set.ss", - "set.ms", - "set.us", - "start.day", - "mid.day", - "end.day", - "https://ipwho.is/" - ] - } - } - }, "dependencies": { "tslib": "^2.8.1" }, diff --git a/packages/tempo/public/esm_sh.index.html b/packages/tempo/public/esm_sh.index.html index ac55d5d9..c65e79b9 100644 --- a/packages/tempo/public/esm_sh.index.html +++ b/packages/tempo/public/esm_sh.index.html @@ -270,7 +270,7 @@

Tempo

{ "imports": { "@js-temporal/polyfill": "https://esm.sh/@js-temporal/polyfill@0.5.1", - "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4.1.0" + "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4.1.1" } } diff --git a/packages/tempo/public/llms.txt b/packages/tempo/public/llms.txt index 75f648b6..f305002e 100644 --- a/packages/tempo/public/llms.txt +++ b/packages/tempo/public/llms.txt @@ -1,6 +1,6 @@ -# Tempo: Immutable Date-Time Engine & AI Syntax Rules (v4.1.0) +# Tempo: Immutable Date-Time Engine & AI Syntax Rules (v4.1.1) -> Tempo (v4.1.0) is an immutable TypeScript date-time engine built around the ECMAScript Temporal API. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments. +> Tempo (v4.1.1) is an immutable TypeScript date-time engine built around the ECMAScript Temporal API. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments. ## Core Architectural Rules & Philosophy - **Temporal Engine**: Tempo uses native `Temporal` in modern runtimes or `@js-temporal/polyfill`. Never instantiate legacy JavaScript `Date`. diff --git a/packages/tempo/src/config/config.resolve.ts b/packages/tempo/src/config/config.resolve.ts index 609cc446..3b450977 100644 --- a/packages/tempo/src/config/config.resolve.ts +++ b/packages/tempo/src/config/config.resolve.ts @@ -1,4 +1,4 @@ -import { isFunction, isString, isObject } from '#library/assertion.library.js'; +import { isFunction, isString, isObject, isSafeKey } from '#library/assertion.library.js'; import { parseJSONC } from '#library/json.library.js'; import { getContext, CONTEXT } from '#library/utility.library.js'; import type { Options } from '../tempo.type.js'; @@ -36,14 +36,6 @@ function resolveSpecifier(specifier: string, baseLocation?: string, pathMod?: an if (isHttpUrl(specifier) || isFileUrl(specifier)) return specifier; - if (baseLocation && isHttpUrl(baseLocation)) { - try { - return new URL(specifier, baseLocation).href; - } catch { - return specifier; - } - } - if (baseLocation && pathMod) { const baseDir = (baseLocation.endsWith('/') || !pathMod.extname(baseLocation)) ? baseLocation @@ -79,28 +71,6 @@ function checkAndWarnBudget(budget: ExtendsBudget): boolean { return false; } -/** - * Fetches and parses a remote JSON or JSONC configuration. - * - * @param url - The HTTP(S) URL of the configuration - * @returns The parsed configuration, or `undefined` if fetching or parsing fails - */ -async function fetchRemoteConfig(url: string): Promise { - try { - const { fetchRequest } = await import('#library/request.library.js'); - const data = await fetchRequest( - url, - { headers: { Accept: 'application/json, text/plain, */*' } }, - { timeout: 3000, maxBytes: 128 * 1024, rawText: true } - ); - - if (isObject(data)) return data as Options; - if (isString(data)) return parseJSONC(data) as Options; - } catch (err: any) { - console.warn(`[Tempo] Failed to fetch remote config from ${url}:`, err?.message || err); - } - return undefined; -} /** * Merges parent and child configuration options, with child values taking precedence. @@ -138,6 +108,7 @@ function mergeConfigs(parent: Options, child: Options): Options { const allKeys = new Set([...Object.keys(parentOpts), ...Object.keys(childOpts)]); const mergedPluginOpts: Record = {}; for (const key of allKeys) { + if (!isSafeKey(key)) continue; const pVal = (parentOpts as any)[key]; const cVal = (childOpts as any)[key]; if (isObject(pVal) || isObject(cVal)) { @@ -159,25 +130,6 @@ function mergeConfigs(parent: Options, child: Options): Options { const pList = Array.isArray(parentPlugins) ? parentPlugins : (parentPlugins ? [parentPlugins] : []); const cList = Array.isArray(childPlugins) ? childPlugins : (childPlugins ? [childPlugins] : []); merged.plugins = [...pList, ...cList]; - } else if (isObject(parentPlugins) || isObject(childPlugins)) { - /** @deprecated Providing configuration dictionaries under 'plugins' is deprecated. Use 'pluginOptions' instead. */ - const pObj = isObject(parentPlugins) ? parentPlugins : {}; - const cObj = isObject(childPlugins) ? childPlugins : {}; - const allKeys = new Set([...Object.keys(pObj), ...Object.keys(cObj)]); - const mergedObj: Record = {}; - for (const key of allKeys) { - const pVal = (pObj as any)[key]; - const cVal = (cObj as any)[key]; - if (isObject(pVal) || isObject(cVal)) { - mergedObj[key] = { - ...(isObject(pVal) ? pVal : {}), - ...(isObject(cVal) ? cVal : {}), - }; - } else { - mergedObj[key] = cVal !== undefined ? cVal : pVal; - } - } - merged.plugins = mergedObj; } } @@ -223,12 +175,13 @@ async function processExtends( if (checkAndWarnBudget(budget)) break; - const targetUrlOrPath = resolveSpecifier(specifier, baseLocation, path); - if (baseLocation && isHttpUrl(baseLocation) && !isHttpUrl(targetUrlOrPath)) { - console.warn(`[Tempo] Remote configuration cannot extend non-HTTP(S) target: ${targetUrlOrPath}`); + if (isHttpUrl(specifier)) { + console.warn(`[Tempo] Remote HTTP(S) config extends is not supported, skipping: ${specifier}`); continue; } + const targetUrlOrPath = resolveSpecifier(specifier, baseLocation, path); + if (loadedSet.has(targetUrlOrPath)) { console.warn(`[Tempo] Circular extends detected for config target: ${targetUrlOrPath}`); continue; @@ -246,7 +199,7 @@ async function processExtends( } /** - * Loads and resolves a configuration target from a local path, `file://` URL, or HTTP(S) URL. + * Loads and resolves a configuration target from a local path or `file://` URL. * * @param target - The configuration path or URL to load * @param currentDir - The base directory for resolving relative paths @@ -264,6 +217,11 @@ async function loadConfigTarget( ): Promise { if (loadedSet.has(target)) return undefined; + if (isHttpUrl(target)) { + console.warn(`[Tempo] Remote HTTP(S) config target is not supported: ${target}`); + return undefined; + } + if (depth > 0) { if (checkAndWarnBudget(budget)) return undefined; budget.remaining--; @@ -271,15 +229,19 @@ async function loadConfigTarget( loadedSet.add(target); - if (isHttpUrl(target)) { - const fetched = await fetchRemoteConfig(target); - if (fetched) - return processExtends(fetched, target, fs, path, urlMod, loadedSet, depth, budget); - return undefined; - } - let localPath = target; if (isFileUrl(target)) { + try { + const parsed = new URL(target); + if (parsed.hostname && parsed.hostname !== 'localhost') { + console.warn(`[Tempo] Remote file URL with host is not supported: ${target}`); + return undefined; + } + } catch { + console.warn(`[Tempo] Invalid file URL config target: ${target}`); + return undefined; + } + if (urlMod?.fileURLToPath) localPath = urlMod.fileURLToPath(target); else @@ -296,9 +258,12 @@ async function loadConfigTarget( if (ext === '.json' || ext === '.jsonc') { const content = await fs.promises.readFile(localPath, 'utf8'); loaded = parseJSONC(content) as Options; - } else if (urlMod) { + } else if (depth === 0 && urlMod) { + // Only allow JS/TS module execution for top-level project config files, never for extends targets const imported = await import(/* @vite-ignore */ urlMod.pathToFileURL(localPath).href); loaded = imported.default || imported; + } else { + console.warn(`[Tempo] Config extends only supports static .json and .jsonc data files, skipping: ${localPath}`); } if (loaded) @@ -438,8 +403,24 @@ export function resolveConfigSync(options?: { cwd?: string, configFile?: string * @returns The loaded configuration, or `undefined` when no configuration is found or the environment cannot load one. */ export async function resolveConfig(options?: { cwd?: string, configFile?: string }): Promise { - if (options?.configFile && isHttpUrl(options.configFile)) - return loadConfigTarget(options.configFile, typeof process !== 'undefined' ? process.cwd() : ''); + if (options?.configFile) { + if (isHttpUrl(options.configFile)) { + console.warn(`[Tempo] Remote HTTP(S) configFile is not supported: ${options.configFile}`); + return undefined; + } + if (isFileUrl(options.configFile)) { + try { + const parsed = new URL(options.configFile); + if (parsed.hostname && parsed.hostname !== 'localhost') { + console.warn(`[Tempo] Remote file URL with host is not supported: ${options.configFile}`); + return undefined; + } + } catch { + console.warn(`[Tempo] Invalid file URL configFile: ${options.configFile}`); + return undefined; + } + } + } const ctx = getContext(); if (ctx.type !== CONTEXT.NodeJS || !isFunction(ctx.global.process?.cwd)) @@ -459,7 +440,7 @@ export async function resolveConfig(options?: { cwd?: string, configFile?: strin let currentDir = options?.cwd || process.cwd(); if (options?.configFile) { - const target = isHttpUrl(options.configFile) || isFileUrl(options.configFile) + const target = isFileUrl(options.configFile) ? options.configFile : path.resolve(currentDir, options.configFile); return await loadConfigTarget(target, currentDir, fs, path, urlMod); diff --git a/packages/tempo/src/library.index.ts b/packages/tempo/src/library.index.ts index 4711ff3d..9a097f5b 100644 --- a/packages/tempo/src/library.index.ts +++ b/packages/tempo/src/library.index.ts @@ -9,10 +9,8 @@ export * as cipher from '#library/cipher.library.js'; export * as webToken from '#library/webtoken.library.js'; export { enumify, type Enum } from '#library/enumerate.library.js'; -export { fetchRequest, fetchHead, HttpError } from '#library/request.library.js'; export { stringify, objectify, cloneify } from '#library/serialize.library.js'; export { parseJSONC, stripJSONC, cleanify, isJSON, rawJSON, isRawJSON } from '#library/json.library.js'; -export { geoLookup } from '#library/mapper.library.js'; export { getContext, CONTEXT } from '#library/utility.library.js'; export { Interval } from '#library/scheduling/interval.class.js'; diff --git a/packages/tempo/src/module/module.mutate.ts b/packages/tempo/src/module/module.mutate.ts index 3dd44fd0..439feaa2 100644 --- a/packages/tempo/src/module/module.mutate.ts +++ b/packages/tempo/src/module/module.mutate.ts @@ -1,6 +1,6 @@ import { evaluate } from '#library/evaluation.library.js'; -import { isDefined, isObject, isString, isUndefined, isZonedDateTime } from '#library/assertion.library.js'; -import { asArray } from '#library/coercion.library.js'; +import { isDefined, isNumber, isObject, isString, isUndefined, isZonedDateTime } from '#library/assertion.library.js'; +import { asArray, ifNumeric } from '#library/coercion.library.js'; import { singular } from '#library/string.library.js'; import { normaliseFractionalDurations } from '#library/temporal.library.js'; @@ -173,12 +173,14 @@ function mutate(this: Tempo, type: 'add' | 'subtract' | 'set' | 'plus' | 'minus' if (type === 'add' || type === 'subtract') { const isTermPlugin = !isTerm && isDefined(findTermPlugin(key as string, state)); const isStandard = ['period', 'event', 'time', 'date', 'dow', 'wkd'].includes(key as string); + const isTermUnit = isTerm || (isTermPlugin && !isStandard); + const val = ifNumeric(adjust); return { - mutate: type, - offset: adjust, - single: isTerm || (isTermPlugin && !isStandard) ? 'term' : singular(key), + mutate: isTermUnit ? type : 'add', + offset: (type === 'subtract' && !isTermUnit && isNumber(val)) ? -val : val, + single: isTermUnit ? 'term' : singular(key), term: isTerm ? (key as string) : (isTermPlugin ? key : undefined) - } + }; } if (type === 'set' && isString(adjust) && (adjust === 'start' || adjust === 'mid' || adjust === 'end')) { @@ -191,7 +193,7 @@ function mutate(this: Tempo, type: 'add' | 'subtract' | 'set' | 'plus' | 'minus' offset: adjust, single: isTermVal || (isTermPlugin && !isStandard) ? 'term' : singular(unitKey as string), term: isTermVal ? (unitKey as string) : (isTermPlugin ? unitKey : undefined) - } + }; } switch (key) { @@ -199,8 +201,16 @@ function mutate(this: Tempo, type: 'add' | 'subtract' | 'set' | 'plus' | 'minus' case 'mid': case 'end': { const val = adjust?.toString() ?? ''; - const isTermVal = val.startsWith('#'); - return { mutate: key as any, offset: val, single: isTermVal ? 'term' : singular(val), term: isTermVal ? val : undefined }; + const unitVal = (enums.ELEMENT as any)[val] ?? val; + const isTermVal = (unitVal as string).startsWith('#'); + const isTermPlugin = !isTermVal && isDefined(findTermPlugin(unitVal as string, state)); + const isStandard = ['period', 'event', 'time', 'date', 'dow', 'wkd'].includes(unitVal as string); + return { + mutate: key as any, + offset: val, + single: isTermVal || (isTermPlugin && !isStandard) ? 'term' : singular(unitVal as string), + term: isTermVal ? (unitVal as string) : (isTermPlugin ? unitVal : undefined) + }; } default: { const isTermPlugin = !isTerm && isDefined(findTermPlugin(key as string, state)); @@ -210,12 +220,19 @@ function mutate(this: Tempo, type: 'add' | 'subtract' | 'set' | 'plus' | 'minus' offset: adjust, single: isTerm || (isTermPlugin && !isStandard) ? 'term' : singular(key as string), term: isTerm ? (key as string) : (isTermPlugin ? key : undefined) - } + }; } } })(key, adjust, type); - const slug = `${op}.${single}`; + // Term-based mutations + if (single === 'term') { + const res = resolveTermMutation((this.constructor as any), this, op as any, term!, adjust, currZdt); + if (res === null) state.errored = true; + return res ?? currZdt; + } + + const slug = `${op}:${single}`; const parseInner = (input: any, anchor?: any) => { const res = (this.constructor as any).from(input, { ...this.config, anchor }); if (res.isValid) { @@ -225,88 +242,72 @@ function mutate(this: Tempo, type: 'add' | 'subtract' | 'set' | 'plus' | 'minus' return undefined; }; - // Term-based mutations - if (slug.endsWith('.term')) { - const res = resolveTermMutation((this.constructor as any), this, op as any, term!, adjust, currZdt); - if (res === null) state.errored = true; - return res ?? currZdt; - } - // Standard temporal units switch (slug) { - case 'add.year': case 'add.month': case 'add.week': case 'add.day': - case 'add.hour': case 'add.minute': case 'add.second': - case 'add.millisecond': case 'add.microsecond': case 'add.nanosecond': - case 'subtract.year': case 'subtract.month': case 'subtract.week': case 'subtract.day': - case 'subtract.hour': case 'subtract.minute': case 'subtract.second': - case 'subtract.millisecond': case 'subtract.microsecond': case 'subtract.nanosecond': - return op === 'subtract' ? currZdt.subtract({ [`${single}s`]: offset }) : currZdt.add({ [`${single}s`]: offset }); - - case 'add.yy': case 'add.mm': case 'add.dd': case 'add.hh': - case 'add.mi': case 'add.ss': case 'add.ms': case 'add.us': case 'add.ns': - case 'add.wy': case 'add.ww': - case 'subtract.yy': case 'subtract.mm': case 'subtract.dd': case 'subtract.hh': - case 'subtract.mi': case 'subtract.ss': case 'subtract.ms': case 'subtract.us': case 'subtract.ns': - case 'subtract.wy': case 'subtract.ww': { - const value = enums.ELEMENT[single as t.Element]; - return op === 'subtract' ? currZdt.subtract({ [`${value}s`]: offset }) : currZdt.add({ [`${value}s`]: offset }); - } + case 'add:year': case 'add:month': case 'add:week': case 'add:day': + case 'add:hour': case 'add:minute': case 'add:second': + case 'add:millisecond': case 'add:microsecond': case 'add:nanosecond': + return currZdt.add({ [`${single}s`]: offset }); + + case 'add:yy': case 'add:mm': case 'add:dd': case 'add:hh': + case 'add:mi': case 'add:ss': case 'add:ms': case 'add:us': case 'add:ns': + case 'add:wy': case 'add:ww': + { + const value = enums.ELEMENT[single as t.Element]; + return currZdt.add({ [`${value}s`]: offset }); + } - case 'set.period': case 'set.time': case 'set.date': case 'set.event': - case 'set.dow': case 'set.wkd': { - const res = parseInner(offset, currZdt); - if (isUndefined(res)) state.errored = true; - return res ?? currZdt; - } + case 'set:period': case 'set:time': case 'set:date': case 'set:event': + case 'set:dow': case 'set:wkd': + { + const res = parseInner(offset, currZdt); + if (isUndefined(res)) state.errored = true; + return res ?? currZdt; + } - case 'set.year': case 'set.month': case 'set.day': - case 'set.hour': case 'set.minute': case 'set.second': - case 'set.millisecond': case 'set.microsecond': case 'set.nanosecond': + case 'set:year': case 'set:month': case 'set:day': + case 'set:hour': case 'set:minute': case 'set:second': + case 'set:millisecond': case 'set:microsecond': case 'set:nanosecond': return currZdt.with({ [single]: offset }); - case 'set.yy': case 'set.mm': case 'set.dd': case 'set.hh': - case 'set.mi': case 'set.ss': case 'set.ms': case 'set.us': case 'set.ns': { + case 'set:yy': case 'set:mm': case 'set:dd': case 'set:hh': + case 'set:mi': case 'set:ss': case 'set:ms': case 'set:us': case 'set:ns': { const value = enums.ELEMENT[single as t.Element]; return currZdt.with({ [value]: offset }); } - case 'start.year': return currZdt.with({ month: enums.MONTH.Jan, day: 1 }).startOfDay(); - case 'start.month': return currZdt.with({ day: 1 }).startOfDay(); - case 'start.week': return currZdt.add({ days: -(currZdt.dayOfWeek - enums.WEEKDAY.Mon) }).startOfDay(); - case 'start.day': return currZdt.startOfDay(); - case 'start.hour': - case 'start.minute': - case 'start.second': - case 'start.millisecond': - case 'start.microsecond': - case 'start.nanosecond': + case 'start:year': return currZdt.with({ month: enums.MONTH.Jan, day: 1 }).startOfDay(); + case 'start:month': return currZdt.with({ day: 1 }).startOfDay(); + case 'start:week': return currZdt.add({ days: -(currZdt.dayOfWeek - enums.WEEKDAY.Mon) }).startOfDay(); + case 'start:day': return currZdt.startOfDay(); + + case 'start:hour': case 'start:minute': case 'start:second': + case 'start:millisecond': case 'start:microsecond': case 'start:nanosecond': return currZdt.round({ smallestUnit: (enums.ELEMENT[single as t.Element] ?? single) as any, roundingMode: 'trunc' }); - case 'mid.year': return currZdt.with({ month: enums.MONTH.Jul, day: 1 }).startOfDay(); - case 'mid.month': return currZdt.with({ day: Math.trunc(currZdt.daysInMonth / 2) }).startOfDay(); - case 'mid.week': return currZdt.add({ days: -(currZdt.dayOfWeek - enums.WEEKDAY.Thu) }).startOfDay(); - case 'mid.day': return currZdt.round({ smallestUnit: 'day', roundingMode: 'trunc' }).add({ hours: 12 }); - case 'mid.hour': return currZdt.round({ smallestUnit: 'hour', roundingMode: 'trunc' }).add({ minutes: 30 }); - case 'mid.minute': return currZdt.round({ smallestUnit: 'minute', roundingMode: 'trunc' }).add({ seconds: 30 }); - case 'mid.second': return currZdt.round({ smallestUnit: 'second', roundingMode: 'trunc' }).add({ milliseconds: 500 }); - case 'mid.millisecond': return currZdt.round({ smallestUnit: 'millisecond', roundingMode: 'trunc' }).add({ microseconds: 500 }); - case 'mid.microsecond': return currZdt.round({ smallestUnit: 'microsecond', roundingMode: 'trunc' }).add({ nanoseconds: 500 }); - case 'mid.nanosecond': return currZdt; - - case 'end.year': return currZdt.add({ years: 1 }).with({ month: enums.MONTH.Jan, day: 1 }).startOfDay().subtract({ nanoseconds: 1 }); - case 'end.month': return currZdt.add({ months: 1 }).with({ day: 1 }).startOfDay().subtract({ nanoseconds: 1 }); - case 'end.week': return currZdt.add({ days: (enums.WEEKDAY.Sun - currZdt.dayOfWeek) + 1 }).startOfDay().subtract({ nanoseconds: 1 }); - case 'end.day': - case 'end.hour': - case 'end.minute': - case 'end.second': - case 'end.millisecond': - case 'end.microsecond': { - const unit = (enums.ELEMENT[single as t.Element] ?? single) as any; - const pluralUnit = `${unit}s`; - return currZdt.round({ smallestUnit: unit, roundingMode: 'trunc' }).add({ [pluralUnit]: 1 }).subtract({ nanoseconds: 1 }); - } - case 'end.nanosecond': + case 'mid:year': return currZdt.with({ month: enums.MONTH.Jul, day: 1 }).startOfDay(); + case 'mid:month': return currZdt.with({ day: Math.trunc(currZdt.daysInMonth / 2) }).startOfDay(); + case 'mid:week': return currZdt.add({ days: -(currZdt.dayOfWeek - enums.WEEKDAY.Thu) }).startOfDay(); + case 'mid:day': return currZdt.round({ smallestUnit: 'day', roundingMode: 'trunc' }).add({ hours: 12 }); + case 'mid:hour': return currZdt.round({ smallestUnit: 'hour', roundingMode: 'trunc' }).add({ minutes: 30 }); + case 'mid:minute': return currZdt.round({ smallestUnit: 'minute', roundingMode: 'trunc' }).add({ seconds: 30 }); + case 'mid:second': return currZdt.round({ smallestUnit: 'second', roundingMode: 'trunc' }).add({ milliseconds: 500 }); + case 'mid:millisecond': return currZdt.round({ smallestUnit: 'millisecond', roundingMode: 'trunc' }).add({ microseconds: 500 }); + case 'mid:microsecond': return currZdt.round({ smallestUnit: 'microsecond', roundingMode: 'trunc' }).add({ nanoseconds: 500 }); + case 'mid:nanosecond': return currZdt; + + case 'end:year': return currZdt.add({ years: 1 }).with({ month: enums.MONTH.Jan, day: 1 }).startOfDay().subtract({ nanoseconds: 1 }); + case 'end:month': return currZdt.add({ months: 1 }).with({ day: 1 }).startOfDay().subtract({ nanoseconds: 1 }); + case 'end:week': return currZdt.add({ days: (enums.WEEKDAY.Sun - currZdt.dayOfWeek) + 1 }).startOfDay().subtract({ nanoseconds: 1 }); + + case 'end:day': case 'end:hour': case 'end:minute': case 'end:second': + case 'end:millisecond': case 'end:microsecond': + { + const unit = (enums.ELEMENT[single as t.Element] ?? single) as any; + const pluralUnit = `${unit}s`; + return currZdt.round({ smallestUnit: unit, roundingMode: 'trunc' }).add({ [pluralUnit]: 1 }).subtract({ nanoseconds: 1 }); + } + case 'end:nanosecond': return currZdt; default: diff --git a/packages/tempo/src/support/support.default.ts b/packages/tempo/src/support/support.default.ts index 8b20218e..c6d107b2 100644 --- a/packages/tempo/src/support/support.default.ts +++ b/packages/tempo/src/support/support.default.ts @@ -233,7 +233,7 @@ export const Default = secure({ /** hemisphere for term.qtr or term.szn */ sphere: undefined, /** regional date-parsing configuration */ monthDay: MONTH_DAY, /** internationalization configuration */ intl: IntlDefault, - /** plugin configurations */ plugins: {}, + /** plugin options and configurations */ pluginOptions: {}, /** global data augmentation registries */ registry: { /** Format string templates */ formats: FORMAT, /** Locale-specific configurations */ locales: LOCALE, diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index a649d994..d422fb2d 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -377,9 +377,8 @@ export namespace Internal { * * @remarks * To provide configuration options or defaults for plugins, use `pluginOptions` instead. - * @deprecated Providing a configuration dictionary directly under 'plugins' is deprecated. Use 'pluginOptions' instead. */ - plugins?: (TempoPlugin | TermPlugin | any) | (TempoPlugin | TermPlugin | any)[] | Record; + plugins?: (TempoPlugin | TermPlugin | any) | (TempoPlugin | TermPlugin | any)[]; /** Plugin configuration defaults and dictionaries keyed by plugin name */ pluginOptions?: Record; /** supplied value to parse */ value?: DateTime; @@ -487,9 +486,8 @@ export namespace Internal { * * @remarks * To provide configuration options or defaults for plugins, use `pluginOptions` instead. - * @deprecated Providing a configuration dictionary directly under 'plugins' is deprecated. Use 'pluginOptions' instead. */ - plugins?: (TempoPlugin | TermPlugin | any) | (TempoPlugin | TermPlugin | any)[] | Record; + plugins?: (TempoPlugin | TermPlugin | any) | (TempoPlugin | TermPlugin | any)[]; /** Plugin configuration defaults and dictionaries keyed by plugin name */ pluginOptions?: Record; } diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 65767258..5a81149f 100644 --- a/packages/tempo/src/tempo.version.ts +++ b/packages/tempo/src/tempo.version.ts @@ -5,4 +5,4 @@ * ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`). * Do NOT edit manually — your changes will be overwritten on the next build. */ -export const TEMPO_VERSION = '4.1.0'; +export const TEMPO_VERSION = '4.1.1'; diff --git a/packages/tempo/test/core/__fixtures__/config/base.jsonc b/packages/tempo/test/core/__fixtures__/config/base.jsonc new file mode 100644 index 00000000..617c411e --- /dev/null +++ b/packages/tempo/test/core/__fixtures__/config/base.jsonc @@ -0,0 +1,10 @@ +{ + // Base configuration + "timeZone": "UTC", + "locale": "en-US", + "registry": { + "formats": { + "custom": "{yyyy}-{mm}-{dd}" + } + } +} diff --git a/packages/tempo/test/core/__fixtures__/config/child.jsonc b/packages/tempo/test/core/__fixtures__/config/child.jsonc new file mode 100644 index 00000000..0360d060 --- /dev/null +++ b/packages/tempo/test/core/__fixtures__/config/child.jsonc @@ -0,0 +1,4 @@ +{ + "extends": "./base.jsonc", + "locale": "en-AU" +} diff --git a/packages/tempo/test/core/__fixtures__/config/circular-a.jsonc b/packages/tempo/test/core/__fixtures__/config/circular-a.jsonc new file mode 100644 index 00000000..5bf5dd50 --- /dev/null +++ b/packages/tempo/test/core/__fixtures__/config/circular-a.jsonc @@ -0,0 +1,4 @@ +{ + "extends": "./circular-b.jsonc", + "timeZone": "UTC" +} diff --git a/packages/tempo/test/core/__fixtures__/config/circular-b.jsonc b/packages/tempo/test/core/__fixtures__/config/circular-b.jsonc new file mode 100644 index 00000000..b6d7dd96 --- /dev/null +++ b/packages/tempo/test/core/__fixtures__/config/circular-b.jsonc @@ -0,0 +1,4 @@ +{ + "extends": "./circular-a.jsonc", + "locale": "fr-FR" +} diff --git a/packages/tempo/test/core/__fixtures__/config/non-json-extends.jsonc b/packages/tempo/test/core/__fixtures__/config/non-json-extends.jsonc new file mode 100644 index 00000000..47aa555f --- /dev/null +++ b/packages/tempo/test/core/__fixtures__/config/non-json-extends.jsonc @@ -0,0 +1,4 @@ +{ + "extends": "./tempo.config.js", + "timeZone": "Asia/Tokyo" +} diff --git a/packages/tempo/test/core/__fixtures__/config/proto-extends.jsonc b/packages/tempo/test/core/__fixtures__/config/proto-extends.jsonc new file mode 100644 index 00000000..e385c095 --- /dev/null +++ b/packages/tempo/test/core/__fixtures__/config/proto-extends.jsonc @@ -0,0 +1,11 @@ +{ + "extends": "./base.jsonc", + "pluginOptions": { + "safeKey": { + "enabled": true + }, + "__proto__": { + "polluted": true + } + } +} diff --git a/packages/tempo/test/core/__fixtures__/config/remote-extends.jsonc b/packages/tempo/test/core/__fixtures__/config/remote-extends.jsonc new file mode 100644 index 00000000..67c4bac7 --- /dev/null +++ b/packages/tempo/test/core/__fixtures__/config/remote-extends.jsonc @@ -0,0 +1,4 @@ +{ + "extends": "https://example.com/remote.jsonc", + "timeZone": "Asia/Tokyo" +} diff --git a/packages/tempo/test/core/config.remote.test.ts b/packages/tempo/test/core/config.remote.test.ts index da9602fa..a3407987 100644 --- a/packages/tempo/test/core/config.remote.test.ts +++ b/packages/tempo/test/core/config.remote.test.ts @@ -1,338 +1,107 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { resolveConfig } from '#tempo/config/config.resolve.js'; -import * as requestLib from '#library/request.library.js'; -describe('Remote and Cascading Config Resolution', () => { +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const fixturesDir = path.resolve(__dirname, '__fixtures__/config'); + +describe('Hardened Local Config Resolution and Extends', () => { beforeEach(() => { vi.restoreAllMocks(); }); - test('should fetch and parse remote JSONC config from https URL', async () => { - const mockJsonc = ` - { - // Corporate base config - "timeZone": "Asia/Tokyo", - "locale": "ja-JP" - } - `; - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - if (String(url) === 'https://config.internal.org/tempo.config.jsonc') { - return mockJsonc; - } - throw new Error('Not found'); - }); - - const config = await resolveConfig({ configFile: 'https://config.internal.org/tempo.config.jsonc' }); - expect(config).toBeDefined(); - expect(config?.timeZone).toBe('Asia/Tokyo'); - expect(config?.locale).toBe('ja-JP'); + test('should reject remote HTTP(S) configFile with warning and return undefined', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); + const config = await resolveConfig({ configFile: 'https://example.com/tempo.config.jsonc' }); + expect(config).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Tempo] Remote HTTP(S) configFile is not supported: https://example.com/tempo.config.jsonc') + ); }); - test('should handle localhost HTTP URLs', async () => { - const mockJson = JSON.stringify({ timeZone: 'Europe/London', debug: 1 }); - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - if (String(url) === 'http://localhost:8080/tempo.config.json') { - return mockJson; - } - throw new Error('Not found'); - }); - + test('should reject localhost HTTP configFile with warning and return undefined', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); const config = await resolveConfig({ configFile: 'http://localhost:8080/tempo.config.json' }); - expect(config).toBeDefined(); - expect(config?.timeZone).toBe('Europe/London'); - expect(config?.debug).toBe(1); + expect(config).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Tempo] Remote HTTP(S) configFile is not supported: http://localhost:8080/tempo.config.json') + ); }); - test('should recursively resolve "extends" from remote base config', async () => { - const baseRemoteConfig = ` - { - "timeZone": "UTC", - "locale": "en-US", - "registry": { - "formats": { "custom": "{yyyy}-{mm}-{dd}" } - } - } - `; - - const childRemoteConfig = ` - { - "extends": "https://company.org/base.jsonc", - "locale": "en-AU" - } - `; + test('should reject file: URLs with non-empty hostname other than localhost with warning and return undefined', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); + const config = await resolveConfig({ configFile: 'file://host/share/tempo.config.js' }); + expect(config).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Tempo] Remote file URL with host is not supported: file://host/share/tempo.config.js') + ); + }); - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - const strUrl = String(url); - if (strUrl === 'https://company.org/child.jsonc') return childRemoteConfig; - if (strUrl === 'https://company.org/base.jsonc') return baseRemoteConfig; - throw new Error(`404: ${url}`); - }); + test('should recursively resolve "extends" from local static JSONC base config', async () => { + const childConfigPath = path.join(fixturesDir, 'child.jsonc'); + const config = await resolveConfig({ configFile: childConfigPath }); - const config = await resolveConfig({ configFile: 'https://company.org/child.jsonc' }); expect(config).toBeDefined(); - // Inherited from base + // Inherited from base.jsonc expect(config?.timeZone).toBe('UTC'); expect(config?.registry?.formats?.custom).toBe('{yyyy}-{mm}-{dd}'); - // Overridden by child + // Overridden by child.jsonc expect(config?.locale).toBe('en-AU'); }); - test('should protect against circular extends loops', async () => { + test('should protect against circular extends loops in local files and emit warning', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - const configA = `{ "extends": "https://company.org/b.jsonc", "timeZone": "UTC" }`; - const configB = `{ "extends": "https://company.org/a.jsonc", "locale": "fr-FR" }`; - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - const strUrl = String(url); - if (strUrl === 'https://company.org/a.jsonc') return configA; - if (strUrl === 'https://company.org/b.jsonc') return configB; - throw new Error(`404: ${url}`); - }); + const circularPath = path.join(fixturesDir, 'circular-a.jsonc'); + const config = await resolveConfig({ configFile: circularPath }); - const config = await resolveConfig({ configFile: 'https://company.org/a.jsonc' }); expect(config).toBeDefined(); expect(config?.timeZone).toBe('UTC'); expect(config?.locale).toBe('fr-FR'); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Circular extends detected')); }); - test('should return undefined and log warning on network error or 404', async () => { + test('should safely ignore remote HTTP extends with a warning without making network calls', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - vi.spyOn(requestLib, 'fetchRequest').mockRejectedValue(new Error('Network error')); - - const config = await resolveConfig({ configFile: 'https://invalid.domain/missing.jsonc' }); - expect(config).toBeUndefined(); - expect(warnSpy).toHaveBeenCalled(); - }); - - test('should parse commented JSONC served with application/json Content-Type', async () => { - const jsoncWithComments = ` - { - // Server returns application/json Content-Type header - "timeZone": "Asia/Tokyo", - "locale": "ja-JP", - } - `; - - const originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn().mockResolvedValue(new Response(jsoncWithComments, { - status: 200, - headers: { 'Content-Type': 'application/json' } - })); - - try { - const config = await resolveConfig({ configFile: 'https://api.internal.org/tempo.config.jsonc' }); - expect(config).toBeDefined(); - expect(config?.timeZone).toBe('Asia/Tokyo'); - expect(config?.locale).toBe('ja-JP'); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test('should allow sibling branches to independently load shared base configs', async () => { - const rootConfig = `{ "extends": ["https://company.org/b.jsonc", "https://company.org/c.jsonc"] }`; - const configB = `{ "extends": "https://company.org/shared-base.jsonc", "timeZone": "Asia/Tokyo" }`; - const configC = `{ "extends": "https://company.org/shared-base.jsonc", "locale": "ja-JP" }`; - const sharedBase = `{ "debug": 2 }`; - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - const strUrl = String(url); - if (strUrl === 'https://company.org/root.jsonc') return rootConfig; - if (strUrl === 'https://company.org/b.jsonc') return configB; - if (strUrl === 'https://company.org/c.jsonc') return configC; - if (strUrl === 'https://company.org/shared-base.jsonc') return sharedBase; - throw new Error(`404: ${url}`); - }); + const remoteExtendsPath = path.join(fixturesDir, 'remote-extends.jsonc'); + const config = await resolveConfig({ configFile: remoteExtendsPath }); - const config = await resolveConfig({ configFile: 'https://company.org/root.jsonc' }); expect(config).toBeDefined(); - expect(config?.debug).toBe(2); expect(config?.timeZone).toBe('Asia/Tokyo'); - expect(config?.locale).toBe('ja-JP'); - }); - - test('should enforce max extends depth limit and emit warning', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - const num = parseInt(String(url).match(/(\d+)/)?.[1] || '0', 10); - return JSON.stringify({ extends: `https://company.org/level-${num + 1}.jsonc`, [`level_${num}`]: true }); - }); - - const config = await resolveConfig({ configFile: 'https://company.org/level-0.jsonc' }); - expect(config).toBeDefined(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Maximum config extends depth limit reached')); - }); - - test('should retain and merge parent plugins when resolving cascading config', async () => { - const parentConfig = { plugins: { ai: { timeout: 3000 } }, timeZone: 'UTC' }; - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - if (String(url) === 'https://company.org/parent.jsonc') return JSON.stringify(parentConfig); - throw new Error('404'); - }); - - const config = await resolveConfig({ configFile: 'https://company.org/parent.jsonc' }); - expect(config).toBeDefined(); - expect(config?.timeZone).toBe('UTC'); - expect((config?.plugins as any)?.ai?.timeout).toBe(3000); - }); - - test('should retain and merge parent pluginOptions when resolving cascading config', async () => { - const parentConfig = { pluginOptions: { ai: { timeout: 3000, model: 'gpt-4' } }, timeZone: 'UTC' }; - const childConfig = { extends: 'https://company.org/parent.jsonc', pluginOptions: { ai: { timeout: 5000 } } }; - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - if (String(url) === 'https://company.org/parent.jsonc') return JSON.stringify(parentConfig); - if (String(url) === 'https://company.org/child.jsonc') return JSON.stringify(childConfig); - throw new Error('404'); - }); - - const config = await resolveConfig({ configFile: 'https://company.org/child.jsonc' }); - expect(config).toBeDefined(); - expect(config?.timeZone).toBe('UTC'); - expect((config?.pluginOptions as any)?.ai?.timeout).toBe(5000); - expect((config?.pluginOptions as any)?.ai?.model).toBe('gpt-4'); - }); - - test('should enforce inherited-target budget across sibling extends branches and emit warning', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - const loadedTargets: string[] = []; - - const siblingUrls = Array.from({ length: 30 }, (_, i) => `https://company.org/sibling-${i}.jsonc`); - const rootConfig = { extends: siblingUrls, timeZone: 'UTC' }; - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - const strUrl = String(url); - if (strUrl === 'https://company.org/root.jsonc') return JSON.stringify(rootConfig); - loadedTargets.push(strUrl); - const idx = siblingUrls.indexOf(strUrl); - return JSON.stringify({ [`sibling_${idx}`]: true }); - }); - - const config = await resolveConfig({ configFile: 'https://company.org/root.jsonc' }); - expect(config).toBeDefined(); - expect(config?.timeZone).toBe('UTC'); - // Initial root is depth 0, 25 inherited targets are loaded, 26th is stopped - expect(loadedTargets.length).toBe(25); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Maximum config extends target budget reached')); - }); - - test('should share inherited-target budget across sibling branches with recursive extends', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - const loadedTargets: string[] = []; - - const branchAExtends = Array.from({ length: 15 }, (_, i) => `https://company.org/a-${i}.jsonc`); - const branchBExtends = Array.from({ length: 15 }, (_, i) => `https://company.org/b-${i}.jsonc`); - - const rootConfig = { - extends: ['https://company.org/branch-a.jsonc', 'https://company.org/branch-b.jsonc'], - locale: 'en-US', - }; - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - const strUrl = String(url); - if (strUrl === 'https://company.org/root.jsonc') return JSON.stringify(rootConfig); - loadedTargets.push(strUrl); - - if (strUrl === 'https://company.org/branch-a.jsonc') { - return JSON.stringify({ extends: branchAExtends, branchA: true }); - } - if (strUrl === 'https://company.org/branch-b.jsonc') { - return JSON.stringify({ extends: branchBExtends, branchB: true }); - } - return JSON.stringify({ item: strUrl }); - }); - - const config = await resolveConfig({ configFile: 'https://company.org/root.jsonc' }); - expect(config).toBeDefined(); - expect(config?.locale).toBe('en-US'); - // branch-a (1) + branchAExtends (15) + branch-b (1) + branchBExtends (8 loaded before budget 25 exhausted) = 25 - expect(loadedTargets.length).toBe(25); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Maximum config extends target budget reached')); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Tempo] Remote HTTP(S) config extends is not supported, skipping: https://example.com/remote.jsonc') + ); }); - test('should reject non-HTTP(S) extends targets such as file:// when base config is remote', async () => { + test('should reject non-JSON extends targets with a warning to prevent arbitrary code execution', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - const remoteConfig = { - extends: 'file:///etc/passwd', - timeZone: 'Asia/Tokyo', - }; - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - if (String(url) === 'https://company.org/malicious.jsonc') { - return JSON.stringify(remoteConfig); - } - throw new Error(`Unexpected request: ${url}`); - }); + const nonJsonExtendsPath = path.join(fixturesDir, 'non-json-extends.jsonc'); + const config = await resolveConfig({ configFile: nonJsonExtendsPath }); - const config = await resolveConfig({ configFile: 'https://company.org/malicious.jsonc' }); expect(config).toBeDefined(); expect(config?.timeZone).toBe('Asia/Tokyo'); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Tempo] Remote configuration cannot extend non-HTTP(S) target: file:///etc/passwd'), + expect.stringContaining('[Tempo] Config extends only supports static .json and .jsonc data files, skipping:') ); }); - test('should reject non-HTTP(S) extends targets in cascading remote configurations', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - const rootRemoteConfig = { - extends: 'https://company.org/child.jsonc', - timeZone: 'UTC', - }; - const childRemoteConfig = { - extends: ['file:///home/user/.ssh/id_rsa', 'https://company.org/safe-base.jsonc'], - locale: 'en-US', - }; - const safeBaseConfig = { - debug: 1, - }; + test('should load top-level local JS config file', async () => { + const jsConfigPath = path.join(fixturesDir, 'tempo.config.js'); + const config = await resolveConfig({ configFile: jsConfigPath }); - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - const str = String(url); - if (str === 'https://company.org/root.jsonc') return JSON.stringify(rootRemoteConfig); - if (str === 'https://company.org/child.jsonc') return JSON.stringify(childRemoteConfig); - if (str === 'https://company.org/safe-base.jsonc') return JSON.stringify(safeBaseConfig); - throw new Error(`404: ${url}`); - }); - - const config = await resolveConfig({ configFile: 'https://company.org/root.jsonc' }); expect(config).toBeDefined(); - expect(config?.timeZone).toBe('UTC'); - expect(config?.locale).toBe('en-US'); - expect(config?.debug).toBe(1); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Tempo] Remote configuration cannot extend non-HTTP(S) target: file:///home/user/.ssh/id_rsa'), - ); + expect(config?.timeZone).toBe('Europe/Paris'); }); - test('should resolve relative HTTP(S) extends within remote configuration without warning', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - const parentConfig = { timeZone: 'UTC', locale: 'en-GB' }; - const childConfig = { extends: './parent.jsonc', locale: 'en-US' }; - - vi.spyOn(requestLib, 'fetchRequest').mockImplementation(async (url) => { - const str = String(url); - if (str === 'https://company.org/configs/child.jsonc') return JSON.stringify(childConfig); - if (str === 'https://company.org/configs/parent.jsonc') return JSON.stringify(parentConfig); - throw new Error(`404: ${url}`); - }); + test('should ignore dangerous prototype keys when merging pluginOptions or plugins during extends', async () => { + const protoExtendsPath = path.join(fixturesDir, 'proto-extends.jsonc'); + const config = await resolveConfig({ configFile: protoExtendsPath }); - const config = await resolveConfig({ configFile: 'https://company.org/configs/child.jsonc' }); expect(config).toBeDefined(); expect(config?.timeZone).toBe('UTC'); - expect(config?.locale).toBe('en-US'); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('should preserve local and file:// inheritance behavior when root configuration is local', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - const fixtureFileUrl = new URL('./__fixtures__/config/tempo.config.js', import.meta.url).href; - const config = await resolveConfig({ configFile: fixtureFileUrl }); - expect(config).toBeDefined(); - expect(config?.timeZone).toBe('Europe/Paris'); - expect(warnSpy).not.toHaveBeenCalled(); + expect(config?.pluginOptions?.safeKey).toEqual({ enabled: true }); + expect(Object.prototype.hasOwnProperty.call(config?.pluginOptions ?? {}, '__proto__')).toBe(false); + expect((config?.pluginOptions as any)?.polluted).toBeUndefined(); + expect(({} as any).polluted).toBeUndefined(); }); }); diff --git a/packages/tempo/test/instance/instance.set.test.ts b/packages/tempo/test/instance/instance.set.test.ts index b018ad51..a8cc3698 100644 --- a/packages/tempo/test/instance/instance.set.test.ts +++ b/packages/tempo/test/instance/instance.set.test.ts @@ -56,7 +56,7 @@ describe(`${label} set method`, () => { test('startOf/midOf/endOf shorthand via set ({ Term: Value })', () => { const t = new Tempo('2024-05-20 12:34:56'); - + // New { Term: Value } syntax (full names) const startDay = t.set({ day: 'start' }); expect(startDay.hh).toBe(0); @@ -282,5 +282,27 @@ describe(`${label} set method`, () => { expect(tSubObj.dd).toBe(8); }); }); + + describe('Boundary Mutation Symmetry (start, mid, end)', () => { + test('Unit-Key and old-style ( deprecated ) Boundary-Key syntaxes produce identical results', () => { + const t = new Tempo('2026-06-15T12:30:45Z'); + + // Year + expect(t.set({ start: 'yy' }).iso).toBe(t.set({ yy: 'start' }).iso); + expect(t.set({ start: 'year' }).iso).toBe(t.set({ year: 'start' }).iso); + + // Month + expect(t.set({ mid: 'mm' }).iso).toBe(t.set({ mm: 'mid' }).iso); + expect(t.set({ mid: 'month' }).iso).toBe(t.set({ month: 'mid' }).iso); + + // Day + expect(t.set({ end: 'dd' }).iso).toBe(t.set({ dd: 'end' }).iso); + expect(t.set({ end: 'day' }).iso).toBe(t.set({ day: 'end' }).iso); + + // Hour + expect(t.set({ start: 'hh' }).iso).toBe(t.set({ hh: 'start' }).iso); + expect(t.set({ start: 'hour' }).iso).toBe(t.set({ hour: 'start' }).iso); + }); + }); }); }); diff --git a/packages/tempo/test/instance/mutate_aliases.test.ts b/packages/tempo/test/instance/mutate_aliases.test.ts index c9b7fd9b..9d15694c 100644 --- a/packages/tempo/test/instance/mutate_aliases.test.ts +++ b/packages/tempo/test/instance/mutate_aliases.test.ts @@ -16,4 +16,35 @@ describe('Mutate Module Aliases (plus & minus)', () => { const t2 = t1.minus({ hours: 5 }); expect(t2.iso).toBe('2026-08-25T05:00:00Z'); }); + + it('should support .sub() and .subtract() identically to negative-add', () => { + const t = new Tempo('2026-08-25T10:00:00Z'); + const tSub = t.subtract({ days: 3 }); + const tSubAlias = t.sub({ days: 3 }); + const tAddNeg = t.add({ days: -3 }); + + expect(tSub.iso).toBe('2026-08-22T10:00:00Z'); + expect(tSubAlias.iso).toBe(tSub.iso); + expect(tAddNeg.iso).toBe(tSub.iso); + }); + + it('should support element abbreviations with subtract', () => { + const t = new Tempo('2026-08-25T10:00:00Z'); + const tSub = t.subtract({ dd: 5, hh: 2 }); + expect(tSub.iso).toBe('2026-08-20T08:00:00Z'); + }); + + it('should handle double negatives correctly (subtract negative offset)', () => { + const t = new Tempo('2026-08-25T10:00:00Z'); + const tDoubleNeg = t.subtract({ days: -2 }); + expect(tDoubleNeg.iso).toBe('2026-08-27T10:00:00Z'); + }); + + it('should support numeric string durations with subtract and minus', () => { + const t = new Tempo('2026-08-25T10:00:00Z'); + const tSubStr = t.subtract({ days: '3' as any }); + const tMinusStr = t.minus({ dd: '3' as any }); + expect(tSubStr.iso).toBe('2026-08-22T10:00:00Z'); + expect(tMinusStr.iso).toBe('2026-08-22T10:00:00Z'); + }); }); diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 26d6e87e..57fa9412 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -101,6 +101,7 @@ export default defineConfig({ { find: /^@magmacomputing\/tempo-fns\/(.*)$/, replacement: resolve(__dirname, '../functions/src/$1.ts') }, { find: /^@magmacomputing\/library$/, replacement: resolve(__dirname, '../library/src/common.index.ts') }, { find: /^@magmacomputing\/library\/(primitives|temporal|security|scheduling|runtime)\/(.*?)(\.js)?$/, replacement: resolve(__dirname, '../library/src/common/$1/$2.ts') }, + { find: /^@magmacomputing\/library\/(browser|server)\/(.*?)(\.js)?$/, replacement: resolve(__dirname, '../library/src/$1/$2.ts') }, { find: /^@magmacomputing\/library\/(.*)$/, replacement: resolve(__dirname, '../library/src/$1.ts') }, { find: /^#library\/(primitives|temporal|security|scheduling|runtime)\/(.*)\.js$/, replacement: resolve(__dirname, '../library/src/common/$1/$2.ts') }, { find: /^#library\/(array|assertion|coercion|number|object|primitive|string|symbol|type)\.library\.js$/, replacement: resolve(__dirname, '../library/src/common/primitives/$1.library.ts') }, diff --git a/vitest.config.ts b/vitest.config.ts index 6fc9be27..6e1b59b5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ { find: /^@magmacomputing\/tempo-fns\/(.*)$/, replacement: path.resolve(__dirname, './packages/functions/src/$1.ts') }, { find: /^@magmacomputing\/library$/, replacement: path.resolve(__dirname, './packages/library/src/common.index.ts') }, { find: /^@magmacomputing\/library\/(primitives|temporal|security|scheduling|runtime)\/(.*)$/, replacement: path.resolve(__dirname, './packages/library/src/common/$1/$2') }, + { find: /^@magmacomputing\/library\/(browser|server)\/(.*)$/, replacement: path.resolve(__dirname, './packages/library/src/$1/$2') }, { find: /^@magmacomputing\/library\/(.*)$/, replacement: path.resolve(__dirname, './packages/library/src/$1.ts') }, { find: /^#library\/(primitives|temporal|security|scheduling|runtime)\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/$1/$2.ts') }, { find: /^#library\/(browser|server)\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/$1/$2.ts') },