From 20c9b44cef9c6199148b174fb46164a4ebae22cf Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 17 Jun 2026 08:53:38 +0800 Subject: [PATCH 1/2] feat(runtime): resolve reference timezone onto ExecutionContext (#1978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0053 Phase 2 foundation. Adds `ExecutionContext.timezone` (optional IANA zone) and resolves it once per request in resolveExecutionContext, precedence user preference -> org default -> UTC: - user override: sys_user_preference (user_id, key='timezone') - org default: tenant-scoped sys_setting (namespace='localization', key='timezone', scope='tenant') — one org per physical tenant (ADR-0002), so no tenant_id filter - invalid IANA zone ignored, falls through; every read defensive via tryFind, never blocks auth Pure plumbing, no behavior change: nothing reads ctx.timezone yet and an absent value resolves to UTC (today's behavior). Foundation consumed by the rest of Phase 2 — tz-aware today()/daysFromNow() (#1980), datetime rendering (#1981), analytics bucketing (#1982). A discoverable `localization` settings manifest for the org default is a follow-up; the resolver already reads the row if present. Tests: user pref > org default > UTC; invalid zone falls through; anonymous leaves it unset. Runtime suite green (387), spec kernel green (682). Closes #1978. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/exec-context-timezone-resolver.md | 16 ++++ .../resolve-execution-context.test.ts | 75 +++++++++++++++++++ .../src/security/resolve-execution-context.ts | 52 +++++++++++++ .../spec/src/kernel/execution-context.zod.ts | 9 ++- 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 .changeset/exec-context-timezone-resolver.md diff --git a/.changeset/exec-context-timezone-resolver.md b/.changeset/exec-context-timezone-resolver.md new file mode 100644 index 0000000000..3db40badbf --- /dev/null +++ b/.changeset/exec-context-timezone-resolver.md @@ -0,0 +1,16 @@ +--- +"@objectstack/runtime": minor +"@objectstack/spec": minor +--- + +feat(runtime): resolve a reference timezone onto ExecutionContext (ADR-0053 Phase 2 foundation) + +Adds `ExecutionContext.timezone` (optional IANA zone) and resolves it once per request in `resolveExecutionContext`, with precedence **user preference → org default → `UTC`**: + +- User override: `sys_user_preference` row `(user_id, key='timezone')`. +- Org default: the tenant-scoped `sys_setting` `(namespace='localization', key='timezone', scope='tenant')` — one org per physical tenant (ADR-0002), so no tenant_id filter is needed. +- An invalid IANA zone is ignored and resolution falls through; every read is defensive and never blocks auth. + +This is **pure plumbing with no behavior change**: nothing reads `ctx.timezone` yet, and an absent value resolves to `UTC` (today's behavior). It is the foundation the rest of ADR-0053 Phase 2 consumes — tz-aware `today()`/`daysFromNow()` (#1980), datetime rendering (#1981), and analytics bucketing (#1982). A discoverable `localization` settings manifest for the org default is a follow-up; the resolver already reads the row if present. + +Part of #1978. diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 2370f3ddf3..1d0b303dce 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -141,3 +141,78 @@ describe('resolveExecutionContext — API key verify path', () => { expect(ctx.userId).toBeUndefined(); }); }); + +/** + * Reference-timezone resolution (ADR-0053 Phase 2, #1978): user preference → + * org default → UTC. Authenticate via API key so a userId is present, then + * seed `sys_user_preference` / `sys_setting` and assert `ctx.timezone`. + */ +describe('resolveExecutionContext — reference timezone (#1978)', () => { + const RAW = 'osk_tz'; + const apiKeyRows = [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', expires_at: FUTURE }]; + + function makeTzOpts({ prefs = [], settings = [] }: { prefs?: any[]; settings?: any[] }) { + const tables: Record = { + sys_api_key: apiKeyRows, + sys_user_preference: prefs, + sys_setting: settings, + }; + const ql = { + async find(object: string, opts: any) { + const rows = tables[object] ?? []; + const where = opts?.where ?? {}; + return rows.filter((row) => { + for (const [k, v] of Object.entries(where)) { + if (v !== null && typeof v === 'object') continue; // skip $in/operators + if (row[k] !== v) return false; + } + return true; + }); + }, + }; + return { + getService: async () => undefined, + getQl: async () => ql, + request: { headers: { 'x-api-key': RAW } }, + }; + } + + it('prefers the user preference over the org default', async () => { + const ctx = await resolveExecutionContext(makeTzOpts({ + prefs: [{ user_id: 'u1', key: 'timezone', value: 'America/New_York' }], + settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }], + })); + expect(ctx.userId).toBe('u1'); + expect(ctx.timezone).toBe('America/New_York'); + }); + + it('falls back to the tenant-scoped org default when no user preference', async () => { + const ctx = await resolveExecutionContext(makeTzOpts({ + settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }], + })); + expect(ctx.timezone).toBe('Europe/Paris'); + }); + + it('defaults to UTC when neither is set', async () => { + const ctx = await resolveExecutionContext(makeTzOpts({})); + expect(ctx.timezone).toBe('UTC'); + }); + + it('ignores an invalid zone and continues down the chain', async () => { + const ctx = await resolveExecutionContext(makeTzOpts({ + prefs: [{ user_id: 'u1', key: 'timezone', value: 'Not/AZone' }], + settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }], + })); + expect(ctx.timezone).toBe('Asia/Tokyo'); + }); + + it('leaves timezone unset for anonymous requests', async () => { + const ctx = await resolveExecutionContext({ + getService: async () => undefined, + getQl: async () => ({ async find() { return []; } }), + request: { headers: {} }, + }); + expect(ctx.userId).toBeUndefined(); + expect(ctx.timezone).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 4af9c670ab..5b0f679a03 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -68,6 +68,53 @@ async function tryFind(ql: any, object: string, where: any, limit = 100): Promis } } +/** True for a valid IANA timezone name (e.g. `America/New_York`, `UTC`). */ +function isValidTimeZone(tz: string): boolean { + try { + new Intl.DateTimeFormat('en-US', { timeZone: tz }); + return true; + } catch { + return false; + } +} + +/** Coerce a stored preference/setting value to a valid IANA zone, or undefined. */ +function coerceTimeZone(value: unknown): string | undefined { + const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : ''; + return s && isValidTimeZone(s) ? s : undefined; +} + +/** + * Resolve the active reference timezone for an authenticated context + * (ADR-0053 Phase 2): user preference → org default → `UTC`. + * + * - User override: `sys_user_preference` row `(user_id, key='timezone')`. + * - Org default: the tenant-scoped `sys_setting` `(namespace='localization', + * key='timezone', scope='tenant')` — one org per physical tenant (ADR-0002), + * so the row needs no tenant_id filter. + * + * Pure plumbing: nothing downstream reads `ctx.timezone` yet, so an absent + * value resolves to `UTC` and preserves today's behavior. Every read is + * defensive (via `tryFind`) and an invalid zone falls through — timezone + * resolution never blocks auth. + */ +async function resolveTimezone(ql: any, userId: string): Promise { + const prefRows = await tryFind(ql, 'sys_user_preference', { user_id: userId, key: 'timezone' }, 1); + const userTz = coerceTimeZone(prefRows[0]?.value); + if (userTz) return userTz; + + const settingRows = await tryFind( + ql, + 'sys_setting', + { namespace: 'localization', key: 'timezone', scope: 'tenant' }, + 1, + ); + const orgTz = coerceTimeZone(settingRows[0]?.value); + if (orgTz) return orgTz; + + return 'UTC'; +} + /** * Resolve the {@link ExecutionContext} for an inbound request. * @@ -263,6 +310,11 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise z.object({ /** Current organization/tenant ID (resolved from session.activeOrganizationId) */ tenantId: z.string().optional(), - + + /** + * Active reference timezone (IANA name, e.g. `America/New_York`), resolved + * once per request as user-preference → org default → `UTC` (ADR-0053 + * Phase 2). When unset, consumers treat it as `UTC` — today's behavior. + */ + timezone: z.string().optional(), + /** User role names (resolved from Member + Role) */ roles: z.array(z.string()).default([]), From 8372dd6da1981e9fdc6a1fdc51294aa35ccd1ec3 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 17 Jun 2026 09:44:44 +0800 Subject: [PATCH 2/2] chore(spec): classify 4 liveness properties left unclassified by #1990/#1992 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing red on the spec-liveness gate (not from this branch's timezone change): #1990 added flow.successMessage/errorMessage + action.errorMessage and #1992 added action.undoable, none classified in the ledgers. - flow.successMessage → live (service-automation/src/engine.ts:1292) - flow.errorMessage → live (service-automation/src/engine.ts:1348) - action.errorMessage → live (objectui toast, error counterpart of successMessage) - action.undoable → experimental: declared + demoed in #1992 but no runtime reader yet in framework or objectui (objectui has an UndoManager but does not key off the action's `undoable` flag). Promote to live once a consumer lands. Greens the liveness gate (was red on main too). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/spec/liveness/action.json | 8 ++++++++ packages/spec/liveness/flow.json | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 8f82b13b17..372cf0f59c 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -64,6 +64,14 @@ "status": "live", "note": "objectui toast." }, + "errorMessage": { + "status": "live", + "note": "objectui toast (error counterpart of successMessage; added #1990)." + }, + "undoable": { + "status": "experimental", + "note": "Declared + demoed in #1992 (example-crm reassign) but no runtime reader yet: neither service-automation nor objectui consume the action's `undoable` flag (objectui has an UndoManager but does not key off this field). Promote to live once a consumer is wired." + }, "refreshAfter": { "status": "live", "note": "objectui post-action refresh." diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index 8661f849ed..c39ef14f43 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -6,6 +6,14 @@ "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "successMessage": { + "status": "live", + "evidence": "packages/services/service-automation/src/engine.ts:1292" + }, + "errorMessage": { + "status": "live", + "evidence": "packages/services/service-automation/src/engine.ts:1348" + }, "label": { "status": "live", "note": "display."