Skip to content

Commit 11af299

Browse files
os-zhuangclaude
andauthored
feat(runtime): resolve a reference timezone onto ExecutionContext (#1993)
* feat(runtime): resolve reference timezone onto ExecutionContext (#1978) 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) <noreply@anthropic.com> * chore(spec): classify 4 liveness properties left unclassified by #1990/#1992 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 808487d commit 11af299

6 files changed

Lines changed: 167 additions & 1 deletion

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
feat(runtime): resolve a reference timezone onto ExecutionContext (ADR-0053 Phase 2 foundation)
7+
8+
Adds `ExecutionContext.timezone` (optional IANA zone) and resolves it once per request in `resolveExecutionContext`, with precedence **user preference → org default → `UTC`**:
9+
10+
- User override: `sys_user_preference` row `(user_id, key='timezone')`.
11+
- 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.
12+
- An invalid IANA zone is ignored and resolution falls through; every read is defensive and never blocks auth.
13+
14+
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.
15+
16+
Part of #1978.

packages/runtime/src/security/resolve-execution-context.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,78 @@ describe('resolveExecutionContext — API key verify path', () => {
141141
expect(ctx.userId).toBeUndefined();
142142
});
143143
});
144+
145+
/**
146+
* Reference-timezone resolution (ADR-0053 Phase 2, #1978): user preference →
147+
* org default → UTC. Authenticate via API key so a userId is present, then
148+
* seed `sys_user_preference` / `sys_setting` and assert `ctx.timezone`.
149+
*/
150+
describe('resolveExecutionContext — reference timezone (#1978)', () => {
151+
const RAW = 'osk_tz';
152+
const apiKeyRows = [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', expires_at: FUTURE }];
153+
154+
function makeTzOpts({ prefs = [], settings = [] }: { prefs?: any[]; settings?: any[] }) {
155+
const tables: Record<string, any[]> = {
156+
sys_api_key: apiKeyRows,
157+
sys_user_preference: prefs,
158+
sys_setting: settings,
159+
};
160+
const ql = {
161+
async find(object: string, opts: any) {
162+
const rows = tables[object] ?? [];
163+
const where = opts?.where ?? {};
164+
return rows.filter((row) => {
165+
for (const [k, v] of Object.entries(where)) {
166+
if (v !== null && typeof v === 'object') continue; // skip $in/operators
167+
if (row[k] !== v) return false;
168+
}
169+
return true;
170+
});
171+
},
172+
};
173+
return {
174+
getService: async () => undefined,
175+
getQl: async () => ql,
176+
request: { headers: { 'x-api-key': RAW } },
177+
};
178+
}
179+
180+
it('prefers the user preference over the org default', async () => {
181+
const ctx = await resolveExecutionContext(makeTzOpts({
182+
prefs: [{ user_id: 'u1', key: 'timezone', value: 'America/New_York' }],
183+
settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }],
184+
}));
185+
expect(ctx.userId).toBe('u1');
186+
expect(ctx.timezone).toBe('America/New_York');
187+
});
188+
189+
it('falls back to the tenant-scoped org default when no user preference', async () => {
190+
const ctx = await resolveExecutionContext(makeTzOpts({
191+
settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }],
192+
}));
193+
expect(ctx.timezone).toBe('Europe/Paris');
194+
});
195+
196+
it('defaults to UTC when neither is set', async () => {
197+
const ctx = await resolveExecutionContext(makeTzOpts({}));
198+
expect(ctx.timezone).toBe('UTC');
199+
});
200+
201+
it('ignores an invalid zone and continues down the chain', async () => {
202+
const ctx = await resolveExecutionContext(makeTzOpts({
203+
prefs: [{ user_id: 'u1', key: 'timezone', value: 'Not/AZone' }],
204+
settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }],
205+
}));
206+
expect(ctx.timezone).toBe('Asia/Tokyo');
207+
});
208+
209+
it('leaves timezone unset for anonymous requests', async () => {
210+
const ctx = await resolveExecutionContext({
211+
getService: async () => undefined,
212+
getQl: async () => ({ async find() { return []; } }),
213+
request: { headers: {} },
214+
});
215+
expect(ctx.userId).toBeUndefined();
216+
expect(ctx.timezone).toBeUndefined();
217+
});
218+
});

packages/runtime/src/security/resolve-execution-context.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,53 @@ async function tryFind(ql: any, object: string, where: any, limit = 100): Promis
6868
}
6969
}
7070

71+
/** True for a valid IANA timezone name (e.g. `America/New_York`, `UTC`). */
72+
function isValidTimeZone(tz: string): boolean {
73+
try {
74+
new Intl.DateTimeFormat('en-US', { timeZone: tz });
75+
return true;
76+
} catch {
77+
return false;
78+
}
79+
}
80+
81+
/** Coerce a stored preference/setting value to a valid IANA zone, or undefined. */
82+
function coerceTimeZone(value: unknown): string | undefined {
83+
const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : '';
84+
return s && isValidTimeZone(s) ? s : undefined;
85+
}
86+
87+
/**
88+
* Resolve the active reference timezone for an authenticated context
89+
* (ADR-0053 Phase 2): user preference → org default → `UTC`.
90+
*
91+
* - User override: `sys_user_preference` row `(user_id, key='timezone')`.
92+
* - Org default: the tenant-scoped `sys_setting` `(namespace='localization',
93+
* key='timezone', scope='tenant')` — one org per physical tenant (ADR-0002),
94+
* so the row needs no tenant_id filter.
95+
*
96+
* Pure plumbing: nothing downstream reads `ctx.timezone` yet, so an absent
97+
* value resolves to `UTC` and preserves today's behavior. Every read is
98+
* defensive (via `tryFind`) and an invalid zone falls through — timezone
99+
* resolution never blocks auth.
100+
*/
101+
async function resolveTimezone(ql: any, userId: string): Promise<string> {
102+
const prefRows = await tryFind(ql, 'sys_user_preference', { user_id: userId, key: 'timezone' }, 1);
103+
const userTz = coerceTimeZone(prefRows[0]?.value);
104+
if (userTz) return userTz;
105+
106+
const settingRows = await tryFind(
107+
ql,
108+
'sys_setting',
109+
{ namespace: 'localization', key: 'timezone', scope: 'tenant' },
110+
1,
111+
);
112+
const orgTz = coerceTimeZone(settingRows[0]?.value);
113+
if (orgTz) return orgTz;
114+
115+
return 'UTC';
116+
}
117+
71118
/**
72119
* Resolve the {@link ExecutionContext} for an inbound request.
73120
*
@@ -263,6 +310,11 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
263310
}
264311
}
265312

313+
// 4. Reference timezone (ADR-0053 Phase 2) — resolved once per request and
314+
// carried on the context. No consumer reads it yet; absent config → 'UTC'
315+
// keeps current behavior.
316+
ctx.timezone = await resolveTimezone(ql, userId);
317+
266318
return ctx;
267319
}
268320

packages/spec/liveness/action.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@
6464
"status": "live",
6565
"note": "objectui toast."
6666
},
67+
"errorMessage": {
68+
"status": "live",
69+
"note": "objectui toast (error counterpart of successMessage; added #1990)."
70+
},
71+
"undoable": {
72+
"status": "experimental",
73+
"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."
74+
},
6775
"refreshAfter": {
6876
"status": "live",
6977
"note": "objectui post-action refresh."

packages/spec/liveness/flow.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
"status": "live",
77
"evidence": "packages/services/service-automation/src/engine.ts"
88
},
9+
"successMessage": {
10+
"status": "live",
11+
"evidence": "packages/services/service-automation/src/engine.ts:1292"
12+
},
13+
"errorMessage": {
14+
"status": "live",
15+
"evidence": "packages/services/service-automation/src/engine.ts:1348"
16+
},
917
"label": {
1018
"status": "live",
1119
"note": "display."

packages/spec/src/kernel/execution-context.zod.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@ export const ExecutionContextSchema = lazySchema(() => z.object({
2424

2525
/** Current organization/tenant ID (resolved from session.activeOrganizationId) */
2626
tenantId: z.string().optional(),
27-
27+
28+
/**
29+
* Active reference timezone (IANA name, e.g. `America/New_York`), resolved
30+
* once per request as user-preference → org default → `UTC` (ADR-0053
31+
* Phase 2). When unset, consumers treat it as `UTC` — today's behavior.
32+
*/
33+
timezone: z.string().optional(),
34+
2835
/** User role names (resolved from Member + Role) */
2936
roles: z.array(z.string()).default([]),
3037

0 commit comments

Comments
 (0)