Skip to content

Commit c898bd8

Browse files
feat(platform-objects): Create Key offers the key's expiry and names the Authentication policy that bounds it (#25371)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao61ZBid2jvapvCexubDuEuE)_ Fixes #25339 Clause-②: yes Metadata-protocol: none ## What changed Setup → API Keys → Create Key asked only for a name, so every key silently got the default life the Authentication API key policy sets. Ruling carried: objectstack-ai/hotcrm#2194 record `6354968262`, 「25251 B hotcrm B」 (adopts T2-U21). - **`sys_api_key` `create_api_key`** (`packages/platform-objects/src/identity/sys-api-key.object.ts`) now declares an optional, field-backed `expires_at` param (the column is `datetime`). Its help text names the policy: left empty, the key gets the *Default key lifetime* under Setup → Authentication → API keys (90 days unless changed); a date later than the *Maximum key lifetime* there is refused. The param has **no `defaultValue` on purpose**: an action param default is a literal submitted verbatim (`ActionParamSchema.defaultValue`, and objectui `ActionParamDialog` seeds it untouched), so any fixed date there would override the deployment's policy instead of following it. - **The reveal dialog** shows the expiry the key actually got (`expires_at`, plain text) beside the masked key. The route already echoes it in its answer. - **The route's ceiling refusal** (`packages/runtime/src/domains/keys.ts`) now names the setting that decided it: "the Authentication API key policy's Maximum key lifetime is N days", next to the latest date allowed. Code and status unchanged: `400 API_KEY_EXPIRY_TOO_LONG`, never a clamp. - Translation bundles regenerated (`node scripts/check-i18n-bundles.mjs --write`); the zh-CN / ja-JP / es-ES values are translated by hand, reusing each locale's existing labels for the column and the two policy settings. ## Where it landed, and why not plugin-auth The order expected the refusal in `packages/plugins/plugin-auth`. Measured on `origin/main` `303aef069`: the mint route is `@objectstack/runtime` `domains/keys.ts` (the one mint path, `POST /api/v1/keys`), and it already reads the policy through the auth service's `resolveApiKeyLifecyclePolicy` and refuses past the ceiling since #23367 (pinned in `packages/runtime/src/api-key-lifecycle.pin.test.ts`). plugin-auth is only the policy's source and is untouched here. The one runtime change is the refusal text. ## Pins - New `packages/platform-objects/src/identity/sys-api-key-create-expiry.test.ts`: params are exactly `name` (required) and `expires_at` (optional); no literal default on `expires_at`; its help text names both policy settings; the param resolves to a `datetime` column; the reveal lists `key` then `expires_at`. - `setup-list-empty-states.test.ts`: the reveal pin now expects the masked key plus the plain-text expiry. - `api-key-lifecycle.pin.test.ts`: the refusal (status + `error.code` already asserted) also names the policy setting. The empty-field default and the "a changed setting changes the answer" pins already exist there and are unchanged. ## Verification (head `9bfcaebce8`) - `pnpm --filter @objectstack/platform-objects test`: 89 files, 1322/1322 passed; `typecheck` green. - `pnpm --filter @objectstack/runtime exec vitest run src/api-key-lifecycle.pin.test.ts src/domains`: 138 files, 3807/3807 passed; `typecheck` green. - Ablation (route): restoring `domains/keys.ts` from `303aef069` turns `a day past the ceiling is refused …` red (1 failed / 13 passed); restored with `git checkout HEAD --`, `git diff HEAD` empty and the blob hash equal to HEAD's. - `dispatch-gates --commands`: 69 derived; 68 run, exit 0; 1 NOT-MEASURED: `pnpm check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET, it reads every package's `dist/` and this worktree built only the diff's closure). CI's Lint & Repo Gates runs it on a full build. `--ran` reconciliation: 0 UNRUN. ## Acceptance notes - **The dialog states the default; it does not prefill a policy-derived date.** The card asks for the expiry "defaulted from the policy". An action param can only carry a literal default, and the policy is per-deployment configuration, so a prefilled date would need a dynamic default surface on `ActionParamSchema` (or a renderer that reads the policy). That is a spec/renderer contract change outside this card's S size. What ships: the field says what empty means and names the setting, and the reveal shows the exact date the key got. If a prefilled date is still wanted, it is a separate decision. Carrier: the engine seat. - Security floor holds: nothing here lengthens a lifetime. The route still refuses (never clamps) a date past the maximum, and an empty field still gets the policy default, never a permanent key. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012ZpkdTkteFWsMN7p2QMzLq --- _Generated by [Claude Code](https://claude.ai/code/session_012ZpkdTkteFWsMN7p2QMzLq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 97aa34e commit c898bd8

10 files changed

Lines changed: 127 additions & 7 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
feat: Setup → API Keys → Create Key offers the key's expiry, and a date past the policy's maximum is refused with the policy named
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
Create Key asked only for a name, so every key silently got the default life the Authentication API key policy sets, and the list then showed an expiry the admin never chose. The `sys_api_key` action `create_api_key` now declares an optional `expires_at` param, the second body key `POST /api/v1/keys` already reads. Its help text says what an empty field means (the policy's Default key lifetime, 90 days unless changed) and that a date later than the policy's Maximum key lifetime is refused. The param has no literal default on purpose: an action param default is submitted verbatim, so a fixed date there would override the deployment's policy instead of following it. The reveal dialog now shows the expiry the key actually got, beside the key.
12+
13+
The route behaviour is unchanged: an empty field gets the policy's default life, and a date past the maximum is refused `400 API_KEY_EXPIRY_TOO_LONG`, never clamped. That refusal's message now names the setting that decided it ("the Authentication API key policy's Maximum key lifetime is N days") next to the latest date allowed.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1123,12 +1123,18 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
11231123
create_api_key: {
11241124
label: "Create Key",
11251125
description: "Create a key for a script or integration. The key acts as you, with your access, in the organization you are working in now.",
1126+
params: {
1127+
expires_at: {
1128+
helpText: "Leave empty for the default lifetime set under Setup → Authentication → API keys (Default key lifetime, 90 days unless changed). A date later than the Maximum key lifetime there is refused."
1129+
}
1130+
},
11261131
resultDialog: {
11271132
title: "API key created",
11281133
description: "Copy the key now — it is shown only once and cannot be recovered. If it leaks, revoke it and create a new one.",
11291134
acknowledge: "I have saved the key",
11301135
fields: {
1131-
key: "API Key"
1136+
key: "API Key",
1137+
expires_at: "Expires At"
11321138
}
11331139
}
11341140
},

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1123,12 +1123,18 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
11231123
create_api_key: {
11241124
label: "Crear clave",
11251125
description: "Crea una clave para un script o una integración. La clave actúa como tú, con tu acceso, en la organización en la que trabajas ahora.",
1126+
params: {
1127+
expires_at: {
1128+
helpText: "Déjelo vacío para usar la duración predeterminada de la clave definida en Configuración → Autenticación → Claves de API (90 días si no se ha cambiado). Una fecha posterior a la duración máxima de la clave definida allí se rechaza."
1129+
}
1130+
},
11261131
resultDialog: {
11271132
title: "Clave de API creada",
11281133
description: "Copia la clave ahora: solo se muestra una vez y no se puede recuperar. Si se filtra, revócala y crea una nueva.",
11291134
acknowledge: "He guardado la clave",
11301135
fields: {
1131-
key: "Clave de API"
1136+
key: "Clave de API",
1137+
expires_at: "Caduca el"
11321138
}
11331139
}
11341140
},

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1123,12 +1123,18 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
11231123
create_api_key: {
11241124
label: "キーを作成",
11251125
description: "スクリプトや連携用のキーを作成します。キーはあなたとして、あなたのアクセス権で、現在作業中の組織で動作します。",
1126+
params: {
1127+
expires_at: {
1128+
helpText: "空欄のままにすると、「設定 → 認証 → API キー」のキーの既定の有効期間(変更していなければ 90 日)が使われます。そこにあるキーの最大有効期間より後の日付は拒否されます。"
1129+
}
1130+
},
11261131
resultDialog: {
11271132
title: "API キーを作成しました",
11281133
description: "今すぐキーをコピーしてください。キーは一度しか表示されず、後から取得することはできません。漏えいした場合は失効させて新しいキーを作成してください。",
11291134
acknowledge: "キーを保存しました",
11301135
fields: {
1131-
key: "API キー"
1136+
key: "API キー",
1137+
expires_at: "有効期限"
11321138
}
11331139
}
11341140
},

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1123,12 +1123,18 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
11231123
create_api_key: {
11241124
label: "创建密钥",
11251125
description: "为脚本或集成创建密钥。该密钥以你的身份、按你的访问权限,在你当前所在的组织中生效。",
1126+
params: {
1127+
expires_at: {
1128+
helpText: "留空则使用「设置 → 身份验证 → API 密钥」中的默认密钥有效期(未修改时为 90 天)。晚于该处密钥最长有效期的日期会被拒绝。"
1129+
}
1130+
},
11261131
resultDialog: {
11271132
title: "API 密钥已创建",
11281133
description: "请立即复制密钥——它只显示这一次,之后无法找回。如果泄露,请撤销它并创建新的密钥。",
11291134
acknowledge: "我已保存密钥",
11301135
fields: {
1131-
key: "API 密钥"
1136+
key: "API 密钥",
1137+
expires_at: "过期时间"
11321138
}
11331139
}
11341140
},

‎packages/platform-objects/src/identity/setup-list-empty-states.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,11 @@ describe('API Keys: Create Key through the existing mint route', () => {
145145
it('reveals the raw key in a result dialog, masked', () => {
146146
// The route answers `{ success, data: { key, … } }`; the console unwraps
147147
// `data` before resolving these paths, so the path is `key`, not `data.key`.
148+
// Beside it, in plain text, the expiry the key actually got
149+
// (`sys-api-key-create-expiry.test.ts`).
148150
expect(create?.resultDialog?.fields).toEqual([
149151
expect.objectContaining({ path: 'key', format: 'secret' }),
152+
expect.objectContaining({ path: 'expires_at', format: 'text' }),
150153
]);
151154
});
152155

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// sys_api_key's Create Key door offers the key's expiry, so the date in the
4+
// list is one the admin saw and could choose — not one that silently appeared.
5+
//
6+
// What this file pins, from source:
7+
// - `create_api_key` declares exactly `name` (required) and `expires_at`
8+
// (optional): the two body keys `POST /api/v1/keys` reads, and nothing the
9+
// route ignores;
10+
// - `expires_at` carries NO `defaultValue`: a param default is a literal
11+
// submitted verbatim, so any date written here would override the
12+
// deployment's Authentication API key policy instead of following it. Left
13+
// empty, the route applies the policy's default life;
14+
// - its help text names that policy, so the dialog says what an empty field
15+
// means and that a later date than the maximum is refused;
16+
// - the param resolves to a real datetime column on this object;
17+
// - the reveal shows the expiry the key actually got, beside the key.
18+
//
19+
// The route half — the default applied when the field is empty, and a date
20+
// past the maximum refused `400 API_KEY_EXPIRY_TOO_LONG` with the policy
21+
// named, never clamped — is `packages/runtime/src/api-key-lifecycle.pin.test.ts`.
22+
import { describe, expect, it } from 'vitest';
23+
import { SysApiKey } from './sys-api-key.object.js';
24+
25+
interface DeclaredParam {
26+
field?: string;
27+
name?: string;
28+
required?: boolean;
29+
defaultValue?: unknown;
30+
helpText?: string;
31+
}
32+
33+
const createKey = (SysApiKey.actions ?? []).find((a) => a.name === 'create_api_key') as
34+
| { params?: DeclaredParam[]; resultDialog?: { fields?: Array<{ path: string }> } }
35+
| undefined;
36+
37+
describe('sys_api_key create_api_key — the expiry is declared on the dialog', () => {
38+
it('declares exactly the two body keys the mint route reads', () => {
39+
expect(createKey).toBeDefined();
40+
const params = createKey!.params ?? [];
41+
expect(params.map((p) => p.field ?? p.name)).toEqual(['name', 'expires_at']);
42+
expect(params.find((p) => p.field === 'name')?.required).toBe(true);
43+
expect(params.find((p) => p.field === 'expires_at')?.required ?? false).toBe(false);
44+
});
45+
46+
it('leaves the default to the policy: no literal defaultValue on expires_at', () => {
47+
const expiry = createKey!.params!.find((p) => p.field === 'expires_at')!;
48+
expect(expiry.defaultValue).toBeUndefined();
49+
expect(expiry.helpText).toContain('Default key lifetime');
50+
expect(expiry.helpText).toContain('Maximum key lifetime');
51+
});
52+
53+
it('resolves to a datetime column on this object', () => {
54+
expect((SysApiKey.fields as Record<string, { type: string }>).expires_at.type).toBe('datetime');
55+
});
56+
57+
it('reveals the expiry the key got, beside the key', () => {
58+
expect(createKey!.resultDialog?.fields?.map((f) => f.path)).toEqual(['key', 'expires_at']);
59+
});
60+
});

‎packages/platform-objects/src/identity/sys-api-key.object.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,30 @@ export const SysApiKey = ObjectSchema.create({
112112
// The route reads `name` (and an optional `expires_at`) and nothing else;
113113
// a key named nothing is stored as "API Key", so the name is required
114114
// here to keep the list readable.
115+
//
116+
// `expires_at` is offered so the expiry is a choice the admin sees, not
117+
// a date that silently appears in the list. It stays optional and has
118+
// NO `defaultValue`: a param default is a literal submitted verbatim,
119+
// while the default life is the deployment's Authentication API key
120+
// policy, which the route applies when the field is left empty. The
121+
// help text names that policy, the route refuses (never clamps) a date
122+
// past the policy's maximum (`API_KEY_EXPIRY_TOO_LONG`), and the reveal
123+
// below shows the expiry the key actually got. The column's `readonly`
124+
// is a form flag; an action param does not inherit it.
115125
params: [
116126
{ field: 'name', required: true },
127+
{
128+
field: 'expires_at',
129+
helpText: 'Leave empty for the default lifetime set under Setup → Authentication → API keys (Default key lifetime, 90 days unless changed). A date later than the Maximum key lifetime there is refused.',
130+
},
117131
],
118132
resultDialog: {
119133
title: 'API key created',
120134
description: 'Copy the key now — it is shown only once and cannot be recovered. If it leaks, revoke it and create a new one.',
121135
acknowledge: 'I have saved the key',
122136
fields: [
123137
{ path: 'key', label: 'API Key', format: 'secret' },
138+
{ path: 'expires_at', label: 'Expires At', format: 'text' },
124139
],
125140
},
126141
},

‎packages/runtime/src/api-key-lifecycle.pin.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ describe('[#23367] POST /keys — the default life and the ceiling', () => {
112112
expect(res.body.error.code).toBe('API_KEY_EXPIRY_TOO_LONG');
113113
// Located: the refusal names the latest date this signing may ask for.
114114
expect(res.body.error.message).toContain(new Date(NOW + 365 * DAY).toISOString());
115+
// And the policy that refused it, by the setting's own label, so the
116+
// admin knows which knob decided (the Create Key dialog's expiry field
117+
// points at the same setting).
118+
expect(res.body.error.message).toContain('Maximum key lifetime is 365 days');
115119
expect(rows).toHaveLength(0);
116120
});
117121

‎packages/runtime/src/domains/keys.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,10 @@ export async function handleKeysRequest(
240240
return {
241241
handled: true,
242242
response: deps.error(
243-
`expires_at ${new Date(requestedMs).toISOString()} is further away than this deployment allows: an `
244-
+ `API key lives at most ${policy.maxExpiryDays} days, so the latest expiry a key signed now may `
245-
+ `have is ${latest}. Ask for that date or an earlier one, and rotate the key before it expires.`,
243+
`expires_at ${new Date(requestedMs).toISOString()} is further away than this deployment allows: the `
244+
+ `Authentication API key policy's Maximum key lifetime is ${policy.maxExpiryDays} days, so the `
245+
+ `latest expiry a key signed now may have is ${latest}. Ask for that date or an earlier one, and `
246+
+ 'rotate the key before it expires.',
246247
API_KEY_EXPIRY_TOO_LONG.status,
247248
{ code: API_KEY_EXPIRY_TOO_LONG.code },
248249
),

0 commit comments

Comments
 (0)