Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/exec-context-timezone-resolver.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 75 additions & 0 deletions packages/runtime/src/security/resolve-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any[]> = {
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();
});
});
52 changes: 52 additions & 0 deletions packages/runtime/src/security/resolve-execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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.
*
Expand Down Expand Up @@ -263,6 +310,11 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
}
}

// 4. Reference timezone (ADR-0053 Phase 2) — resolved once per request and
// carried on the context. No consumer reads it yet; absent config → 'UTC'
// keeps current behavior.
ctx.timezone = await resolveTimezone(ql, userId);

return ctx;
}

Expand Down
8 changes: 8 additions & 0 deletions packages/spec/liveness/action.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
8 changes: 8 additions & 0 deletions packages/spec/liveness/flow.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
9 changes: 8 additions & 1 deletion packages/spec/src/kernel/execution-context.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ export const ExecutionContextSchema = lazySchema(() => 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([]),

Expand Down
Loading