@cosmicdrift/kumiko-bundled-features 0.256.0 → 0.258.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +9 -10
  2. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +78 -6
  3. package/src/auth-mfa/__tests__/screen.test.ts +58 -0
  4. package/src/auth-mfa/feature.ts +6 -16
  5. package/src/auth-mfa/handlers/enable-start.write.ts +21 -12
  6. package/src/auth-mfa/i18n.ts +19 -0
  7. package/src/auth-mfa/screens.ts +52 -0
  8. package/src/auth-mfa/web/client-plugin.ts +4 -5
  9. package/src/auth-mfa/web/i18n.ts +0 -7
  10. package/src/auth-mfa/web/index.ts +0 -2
  11. package/src/auth-mfa/web/mfa-disable-dialog.tsx +2 -2
  12. package/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +4 -4
  13. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +5 -5
  14. package/src/auth-mfa/web/qrcode-browser.d.ts +2 -2
  15. package/src/folders/__tests__/folders-read-gate.integration.test.ts +179 -0
  16. package/src/folders/entity.ts +47 -28
  17. package/src/folders/feature.ts +58 -4
  18. package/src/folders/handlers/clear-folder.write.ts +4 -4
  19. package/src/folders/handlers/set-folder.write.ts +4 -4
  20. package/src/ledger/__tests__/ledger.integration.test.ts +111 -4
  21. package/src/ledger/entity.ts +30 -0
  22. package/src/ledger/handlers/confirm-schedule-period.write.ts +7 -0
  23. package/src/ledger/handlers/create-transaction.write.ts +2 -0
  24. package/src/ledger/schemas.ts +3 -0
  25. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +2 -1
  26. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +4 -1
  27. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +3 -1
  28. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  29. package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +243 -0
  30. package/src/notes-history/entity.ts +11 -1
  31. package/src/notes-history/feature.ts +12 -11
  32. package/src/notes-history/handlers/add-note.write.ts +8 -12
  33. package/src/personal-access-tokens/__tests__/expiry.test.ts +19 -0
  34. package/src/personal-access-tokens/__tests__/feature.test.ts +93 -0
  35. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +143 -13
  36. package/src/personal-access-tokens/constants.ts +5 -2
  37. package/src/personal-access-tokens/expiry.ts +7 -0
  38. package/src/personal-access-tokens/feature.ts +8 -17
  39. package/src/personal-access-tokens/handlers/list.query.ts +75 -16
  40. package/src/personal-access-tokens/i18n.ts +54 -0
  41. package/src/personal-access-tokens/resolver.ts +2 -7
  42. package/src/personal-access-tokens/screens.ts +107 -0
  43. package/src/shared/index.ts +1 -1
  44. package/src/shared/parent-visibility.ts +30 -0
  45. package/src/tags/__tests__/tags-read-gate.integration.test.ts +366 -0
  46. package/src/tags/__tests__/tags.integration.test.ts +1 -0
  47. package/src/tags/entity.ts +11 -1
  48. package/src/tags/feature.ts +25 -8
  49. package/src/tags/handlers/assign-tag.write.ts +2 -8
  50. package/src/tags/handlers/remove-tag.write.ts +2 -8
  51. package/src/tags/web/__tests__/entity-tags.test.tsx +13 -4
  52. package/src/tags/web/entity-tags.tsx +9 -2
  53. package/src/tags/web/tag-section.tsx +9 -4
  54. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +0 -182
  55. package/src/auth-mfa/web/mfa-enable-screen.tsx +0 -224
  56. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  57. package/src/personal-access-tokens/web/i18n.ts +0 -40
  58. package/src/personal-access-tokens/web/index.ts +0 -7
  59. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -296
@@ -55,6 +55,10 @@ import type { PatScopeConfig } from "../scopes";
55
55
  let stack: TestStack;
56
56
  let h: ReturnType<typeof makeSessionHelpers>;
57
57
 
58
+ // `personal-access-tokens:query:mine` is now a paged handler — every caller
59
+ // reads `response.rows` instead of a blank array (fw#2548 Teil B).
60
+ type PatRowsEnvelope<T> = { readonly rows: readonly T[]; readonly nextCursor: string | null };
61
+
58
62
  const encryptionKey = randomBytes(32).toString("base64");
59
63
  const TENANT: TenantId = testTenantId(1);
60
64
 
@@ -183,7 +187,11 @@ describe("PAT auth", () => {
183
187
  test("revoked token → 401", async () => {
184
188
  const actor = await actorFor("revoked@example.com");
185
189
  const token = await mintToken(actor);
186
- const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
190
+ const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
191
+ PatQueries.mine,
192
+ {},
193
+ actor,
194
+ );
187
195
  const id = rows[0]?.id;
188
196
  expect(id).toBeDefined();
189
197
  await stack.http.writeOk(PatHandlers.revoke, { id }, actor);
@@ -195,8 +203,11 @@ describe("PAT auth", () => {
195
203
  const actor = await actorFor("expired@example.com");
196
204
  const token = await mintToken(actor);
197
205
  const past = Temporal.Now.instant().subtract({ hours: 1 });
198
- const rows =
199
- (await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor)) ?? [];
206
+ const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
207
+ PatQueries.mine,
208
+ {},
209
+ actor,
210
+ );
200
211
  await updateMany(stack.db, apiTokenTable, { expiresAt: past }, { id: rows[0]?.id });
201
212
  const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
202
213
  expect(res.status).toBe(401);
@@ -245,7 +256,7 @@ describe("PAT with active KMS (#820): token name is userOwned PII", () => {
245
256
  const actor = await actorFor("kms-pat@example.com");
246
257
  const token = await mintToken(actor);
247
258
 
248
- const rows = await stack.http.queryOk<Array<{ id: string; name: string }>>(
259
+ const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string; name: string }>>(
249
260
  PatQueries.mine,
250
261
  {},
251
262
  actor,
@@ -276,7 +287,11 @@ describe("PAT create: re-auth (#security)", () => {
276
287
  actor,
277
288
  );
278
289
  expect(err.httpStatus).toBeGreaterThanOrEqual(400);
279
- const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
290
+ const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
291
+ PatQueries.mine,
292
+ {},
293
+ actor,
294
+ );
280
295
  expect(rows).toHaveLength(0);
281
296
  });
282
297
 
@@ -288,18 +303,20 @@ describe("PAT create: re-auth (#security)", () => {
288
303
  actor,
289
304
  );
290
305
  expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
291
- const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
306
+ const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
307
+ PatQueries.mine,
308
+ {},
309
+ actor,
310
+ );
292
311
  expect(rows).toHaveLength(0);
293
312
  });
294
313
 
295
314
  test("expiresInDays omitted → defaults to ~90 days, not never-expiring", async () => {
296
315
  const actor = await actorFor("reauth-expiry@example.com");
297
316
  await mintToken(actor);
298
- const rows = await stack.http.queryOk<Array<{ id: string; expiresAt: string | null }>>(
299
- PatQueries.mine,
300
- {},
301
- actor,
302
- );
317
+ const { rows } = await stack.http.queryOk<
318
+ PatRowsEnvelope<{ id: string; expiresAt: string | null }>
319
+ >(PatQueries.mine, {}, actor);
303
320
  expect(rows[0]?.expiresAt).not.toBeNull();
304
321
  const expiresAt = Temporal.Instant.from(rows[0]?.expiresAt as string);
305
322
  const expected = Temporal.Now.instant().add({ hours: 24 * PAT_DEFAULT_EXPIRES_IN_DAYS });
@@ -361,7 +378,11 @@ describe("PAT create: MFA re-auth gate", () => {
361
378
  actor,
362
379
  );
363
380
  expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
364
- const rows = await mfaStack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
381
+ const { rows } = await mfaStack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
382
+ PatQueries.mine,
383
+ {},
384
+ actor,
385
+ );
365
386
  expect(rows).toHaveLength(0);
366
387
  });
367
388
 
@@ -374,7 +395,11 @@ describe("PAT create: MFA re-auth gate", () => {
374
395
  actor,
375
396
  );
376
397
  expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
377
- const rows = await mfaStack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
398
+ const { rows } = await mfaStack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
399
+ PatQueries.mine,
400
+ {},
401
+ actor,
402
+ );
378
403
  expect(rows).toHaveLength(0);
379
404
  });
380
405
  });
@@ -420,3 +445,108 @@ describe("PAT revoke on password change (#security)", () => {
420
445
  expect(res.status).toBe(200);
421
446
  });
422
447
  });
448
+
449
+ describe("PAT list projection (fw#2548 Teil B)", () => {
450
+ test("the minted plaintext token is returned exactly once and never by query:mine", async () => {
451
+ const actor = await actorFor("secret-once@example.com");
452
+ const token = await mintToken(actor);
453
+
454
+ const first = await stack.http.queryOk<PatRowsEnvelope<{ prefix: string }>>(
455
+ PatQueries.mine,
456
+ {},
457
+ actor,
458
+ );
459
+ expect(JSON.stringify(first)).not.toContain(token);
460
+ expect(first.rows[0]?.prefix).toBeDefined();
461
+
462
+ // A second read confirms this isn't a one-request fluke — the plaintext
463
+ // never lands in the projection at all, so a later read can't leak it
464
+ // either.
465
+ const second = await stack.http.queryOk<PatRowsEnvelope<{ prefix: string }>>(
466
+ PatQueries.mine,
467
+ {},
468
+ actor,
469
+ );
470
+ expect(JSON.stringify(second)).not.toContain(token);
471
+ });
472
+
473
+ test("another user's tokens never appear in query:mine", async () => {
474
+ const actorA = await actorFor("owner-a@example.com");
475
+ const actorB = await actorFor("owner-b@example.com");
476
+ await mintToken(actorA);
477
+ await mintToken(actorB);
478
+
479
+ const { rows: rowsA } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
480
+ PatQueries.mine,
481
+ {},
482
+ actorA,
483
+ );
484
+ const { rows: rowsB } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
485
+ PatQueries.mine,
486
+ {},
487
+ actorB,
488
+ );
489
+ expect(rowsA).toHaveLength(1);
490
+ expect(rowsB).toHaveLength(1);
491
+ expect(rowsA[0]?.id).not.toBe(rowsB[0]?.id);
492
+ });
493
+
494
+ test("revoke of another user's token fails and leaves it usable", async () => {
495
+ const actorA = await actorFor("revoke-owner-a@example.com");
496
+ const actorB = await actorFor("revoke-owner-b@example.com");
497
+ const tokenA = await mintToken(actorA);
498
+ const { rows } = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
499
+ PatQueries.mine,
500
+ {},
501
+ actorA,
502
+ );
503
+ const tokenAId = rows[0]?.id;
504
+ expect(tokenAId).toBeDefined();
505
+
506
+ const err = await stack.http.writeErr(PatHandlers.revoke, { id: tokenAId }, actorB);
507
+ expect(err.details).toMatchObject({ reason: PatErrors.ownershipDenied });
508
+
509
+ const res = await h.authedPost("/api/query", tokenA, { type: PatQueries.mine, payload: {} });
510
+ expect(res.status).toBe(200);
511
+ });
512
+
513
+ test("status column reflects revoked/expired/active", async () => {
514
+ const actor = await actorFor("status-column@example.com");
515
+ await mintToken(actor);
516
+ const active = await stack.http.queryOk<PatRowsEnvelope<{ id: string; status: string }>>(
517
+ PatQueries.mine,
518
+ {},
519
+ actor,
520
+ );
521
+ expect(active.rows[0]?.status).toBe("active");
522
+
523
+ await stack.http.writeOk(PatHandlers.revoke, { id: active.rows[0]?.id }, actor);
524
+ const revoked = await stack.http.queryOk<PatRowsEnvelope<{ id: string; status: string }>>(
525
+ PatQueries.mine,
526
+ {},
527
+ actor,
528
+ );
529
+ expect(revoked.rows[0]?.status).toBe("revoked");
530
+
531
+ const expiredActor = await actorFor("status-column-expired@example.com");
532
+ await mintToken(expiredActor);
533
+ const beforeExpiry = await stack.http.queryOk<PatRowsEnvelope<{ id: string }>>(
534
+ PatQueries.mine,
535
+ {},
536
+ expiredActor,
537
+ );
538
+ const past = Temporal.Now.instant().subtract({ hours: 1 });
539
+ await updateMany(
540
+ stack.db,
541
+ apiTokenTable,
542
+ { expiresAt: past },
543
+ { id: beforeExpiry.rows[0]?.id },
544
+ );
545
+ const expired = await stack.http.queryOk<PatRowsEnvelope<{ id: string; status: string }>>(
546
+ PatQueries.mine,
547
+ {},
548
+ expiredActor,
549
+ );
550
+ expect(expired.rows[0]?.status).toBe("expired");
551
+ });
552
+ });
@@ -16,10 +16,13 @@ export const PatErrors = {
16
16
  // lets callers who genuinely want a long-lived token pass it explicitly.
17
17
  export const PAT_DEFAULT_EXPIRES_IN_DAYS = 90;
18
18
 
19
- // Dormant custom-screen id (r.screen) — the app places it via r.nav. The client
20
- // maps it to the PatTokensScreen component.
19
+ // Declarative projectionList screen id — the caller's own tokens, revoke as a
20
+ // row action. The app places it via r.nav.
21
21
  export const PAT_SCREEN_ID = "api-tokens";
22
22
 
23
+ // Declarative secretMint screen id — mints a new token, reveals it once.
24
+ export const PAT_MINT_SCREEN_ID = "api-token-create";
25
+
23
26
  export const PatHandlers = {
24
27
  create: "personal-access-tokens:write:create",
25
28
  revoke: "personal-access-tokens:write:revoke",
@@ -0,0 +1,7 @@
1
+ import { Temporal } from "temporal-polyfill";
2
+
3
+ export function isExpiredAt(expiresAt: { epochMilliseconds: number } | null): boolean {
4
+ return (
5
+ expiresAt !== null && expiresAt.epochMilliseconds <= Temporal.Now.instant().epochMilliseconds
6
+ );
7
+ }
@@ -5,15 +5,16 @@ import {
5
5
  import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
6
6
  import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
7
7
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
8
- import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PAT_SCREEN_ID, type PatRateLimit } from "./constants";
8
+ import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, type PatRateLimit } from "./constants";
9
9
  import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
10
10
  import { type CreatePatOptions, createPatCreateHandler } from "./handlers/create.write";
11
11
  import { listPatQuery } from "./handlers/list.query";
12
12
  import { revokePatWrite } from "./handlers/revoke.write";
13
- import { PAT_FEATURE_I18N } from "./i18n";
13
+ import { PAT_FEATURE_I18N, patScopeOptionTranslations } from "./i18n";
14
14
  import { createPatResolver } from "./resolver";
15
15
  import { apiTokenEntity } from "./schema/api-token";
16
16
  import type { PatScopeConfig } from "./scopes";
17
+ import { createPatMintScreen, patListScreen } from "./screens";
17
18
 
18
19
  // Password-change is the only field-level trigger — see the postSave hook
19
20
  // below. MFA-enable/disable is wired separately (auth-mfa's
@@ -145,21 +146,11 @@ export function createPersonalAccessTokensFeature(
145
146
  availableScopes: r.queryHandler(buildAvailableScopesQuery(scopes)),
146
147
  };
147
148
 
148
- // Dormant custom-screen — the client maps PAT_SCREEN_ID to PatTokensScreen;
149
- // the app places it via r.nav in its logged-in settings area. dormant:
150
- // true skips createKumikoApp's missing-client-plugin boot diagnostic
151
- // (#2025) for apps that don't nav this screen (#2034).
152
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
153
- r.screen({
154
- id: PAT_SCREEN_ID,
155
- type: "custom",
156
- renderer: { react: { __component: "PatTokensScreen" } },
157
- access: { openToAll: true },
158
- dormant: true,
159
- description:
160
- "Self-service screen where a signed-in user reviews their personal access tokens, mints a new one by picking a permission level per scope domain and an expiry, and revokes tokens they no longer need.",
161
- });
162
- r.translations({ keys: PAT_FEATURE_I18N });
149
+ // Declarative screens — list-with-revoke + mint-with-reveal. The app
150
+ // places `patListScreen` via r.nav in its logged-in settings area.
151
+ r.screen(patListScreen);
152
+ r.screen(createPatMintScreen(scopes));
153
+ r.translations({ keys: { ...PAT_FEATURE_I18N, ...patScopeOptionTranslations(scopes) } });
163
154
 
164
155
  // rateLimit flows into feature.exports so run-prod-app builds the
165
156
  // limiter from the same declaration — single source of truth. `scopes`
@@ -1,52 +1,111 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { definePagedQueryHandler, MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
4
  import { decryptStoredPii } from "../../shared";
5
+ import { isExpiredAt } from "../expiry";
5
6
  import { apiTokenTable } from "../schema/api-token";
6
7
 
8
+ // `sort` arrives raw from the client's query string. selectMany's orderBy has
9
+ // no column-existence check — an unrecognised field just gets snake_cased and
10
+ // quoted as-is (bun-db/query.ts columnOf) — so this allowlist is what stops a
11
+ // client sorting by tokenHash or any other non-exposed column.
12
+ const SORTABLE_COLUMNS = ["id", "name", "createdAt", "expiresAt", "revokedAt"] as const;
13
+ type SortableColumn = (typeof SORTABLE_COLUMNS)[number];
14
+
15
+ function isSortableColumn(value: string): value is SortableColumn {
16
+ return (SORTABLE_COLUMNS as readonly string[]).includes(value);
17
+ }
18
+
19
+ type PatStatus = "active" | "revoked" | "expired";
20
+
21
+ function tokenStatus(row: {
22
+ revokedAt: unknown;
23
+ expiresAt: { epochMilliseconds: number } | null;
24
+ }): PatStatus {
25
+ if (row.revokedAt !== null) return "revoked";
26
+ if (isExpiredAt(row.expiresAt)) return "expired";
27
+ return "active";
28
+ }
29
+
30
+ function parseScopeNames(raw: string): string[] {
31
+ try {
32
+ const parsed: unknown = JSON.parse(raw);
33
+ return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
7
39
  // The caller's own tokens — metadata only, never the hash or plaintext.
8
40
  // Includes revoked/expired rows so the UI can show history; `prefix` is the
9
41
  // only fragment of the secret ever exposed.
10
- export const listPatQuery = defineQueryHandler({
42
+ export const listPatQuery = definePagedQueryHandler({
11
43
  name: "mine",
12
- schema: z.object({}),
44
+ schema: z.object({
45
+ limit: z.number().int().nonnegative().max(MAX_LIST_LIMIT).optional(),
46
+ sort: z.string().optional(),
47
+ sortDirection: z.enum(["asc", "desc"]).optional(),
48
+ }),
13
49
  access: { openToAll: true },
14
50
  description:
15
- "Lists the calling user's personal access tokens with metadata only (name, key prefix, scopes, created/expiry/revoked timestamps, including revoked ones) and never the token secret.",
51
+ "Lists the calling user's personal access tokens with metadata only (name, key prefix, scopes, computed status, created/expiry/revoked timestamps, including revoked ones) and never the token secret.",
52
+ outputSchema: z.object({
53
+ rows: z.array(
54
+ z.object({
55
+ id: z.string(),
56
+ name: z.string(),
57
+ prefix: z.string(),
58
+ scopes: z.array(z.string()),
59
+ status: z.enum(["active", "revoked", "expired"]),
60
+ createdAt: z.unknown(),
61
+ expiresAt: z.unknown(),
62
+ revokedAt: z.unknown(),
63
+ }),
64
+ ),
65
+ nextCursor: z.string().nullable(),
66
+ }),
16
67
  handler: async (query, ctx) => {
68
+ const requestedSort = query.payload.sort;
69
+ const sortColumn: SortableColumn =
70
+ requestedSort !== undefined && isSortableColumn(requestedSort) ? requestedSort : "createdAt";
17
71
  const rows = await selectMany<{
18
72
  id: string;
19
73
  name: string;
20
74
  prefix: string;
21
75
  scopes: string;
22
76
  createdAt: unknown;
23
- expiresAt: unknown;
77
+ expiresAt: { epochMilliseconds: number } | null;
24
78
  revokedAt: unknown;
25
79
  }>(
26
80
  ctx.db,
27
81
  apiTokenTable,
28
82
  { userId: query.user.id },
29
- { orderBy: { col: "createdAt", direction: "desc" } },
83
+ {
84
+ // `id` as a tie-breaker keeps row order (and, with `limit` set, row
85
+ // selection) deterministic across identical requests — sortColumn
86
+ // alone isn't unique (e.g. many NULL revokedAt, or equal timestamps).
87
+ orderBy:
88
+ sortColumn === "id"
89
+ ? { col: "id", direction: query.payload.sortDirection ?? "desc" }
90
+ : [
91
+ { col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
92
+ { col: "id", direction: "asc" },
93
+ ],
94
+ ...(query.payload.limit !== undefined && { limit: query.payload.limit }),
95
+ },
30
96
  );
31
- return Promise.all(
97
+ const decryptedRows = await Promise.all(
32
98
  rows.map(async (r) => ({
33
99
  id: r.id,
34
100
  name: await decryptStoredPii(r.name, "name", "pat:list"),
35
101
  prefix: r.prefix,
36
102
  scopes: parseScopeNames(r.scopes),
103
+ status: tokenStatus(r),
37
104
  createdAt: r.createdAt,
38
105
  expiresAt: r.expiresAt,
39
106
  revokedAt: r.revokedAt,
40
107
  })),
41
108
  );
109
+ return { rows: decryptedRows, nextCursor: null };
42
110
  },
43
111
  });
44
-
45
- function parseScopeNames(raw: string): string[] {
46
- try {
47
- const parsed: unknown = JSON.parse(raw);
48
- return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
49
- } catch {
50
- return [];
51
- }
52
- }
@@ -1,5 +1,59 @@
1
+ import type { PatScopeConfig } from "./scopes";
2
+
1
3
  type LocalizedString = { readonly en: string };
2
4
 
3
5
  export const PAT_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
4
6
  "screen:api-tokens.title": { en: "Personal Access Tokens" },
7
+ "screen:api-token-create.title": { en: "Create a new token" },
8
+
9
+ "pat.list.col.name": { en: "Name" },
10
+ "pat.list.col.prefix": { en: "Token" },
11
+ "pat.list.col.scopes": { en: "Scopes" },
12
+ "pat.list.col.status": { en: "Status" },
13
+ "pat.list.col.created": { en: "Created" },
14
+ "pat.list.col.expires": { en: "Expires" },
15
+ "pat.list.revoke": { en: "Revoke" },
16
+ "pat.list.revoke.confirm": { en: "Revoke this token? Anything using it will stop working." },
17
+
18
+ "pat.create.title": { en: "Create a new token" },
19
+ "pat.create.submit": { en: "Create token" },
20
+ "pat.create.reauth": { en: "Confirm your identity" },
21
+
22
+ "pat.created.title": { en: "Token created" },
23
+ "pat.created.token": { en: "Your new token" },
24
+ "pat.created.hint": { en: "Copy this token now — for security it is shown only this once." },
25
+ "pat.created.dismiss": { en: "Done" },
26
+
27
+ // Field labels for the secretMint's synthesized pseudo-entity
28
+ // (`__action-form__`, see action-form-shim.ts) — required by the i18n
29
+ // boot-validator (requiredKeysFromScreen); the declarative renderer
30
+ // resolves these through the schema payload, not a client component's t().
31
+ "personal-access-tokens:entity:__action-form__:field:name": { en: "Name" },
32
+ "personal-access-tokens:entity:__action-form__:field:scopes": { en: "Per-API access" },
33
+ "personal-access-tokens:entity:__action-form__:field:expiresInDays": { en: "Expires in (days)" },
34
+ "personal-access-tokens:entity:__action-form__:field:currentPassword": {
35
+ en: "Password (to confirm)",
36
+ },
37
+ "personal-access-tokens:entity:__action-form__:field:mfaCode": { en: "2FA code (if enabled)" },
5
38
  };
39
+
40
+ // Per-grant-option labels for the mint form's `scopes` multiSelect —
41
+ // "<domain> (read)" / "<domain> (read & write)", matching the wording the
42
+ // former custom screen showed. `def.label` is an app-authored raw display
43
+ // string (not itself an i18n key).
44
+ export function patScopeOptionTranslations(
45
+ scopes: PatScopeConfig,
46
+ ): Readonly<Record<string, LocalizedString>> {
47
+ const out: Record<string, LocalizedString> = {};
48
+ for (const [domain, def] of Object.entries(scopes)) {
49
+ out[`personal-access-tokens:entity:__action-form__:field:scopes:option:${domain}:read`] = {
50
+ en: `${def.label} (read)`,
51
+ };
52
+ if (def.write && def.write.length > 0) {
53
+ out[`personal-access-tokens:entity:__action-form__:field:scopes:option:${domain}:write`] = {
54
+ en: `${def.label} (read & write)`,
55
+ };
56
+ }
57
+ }
58
+ return out;
59
+ }
@@ -2,7 +2,7 @@ import type { TokenVerifier } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
- import { Temporal } from "temporal-polyfill";
5
+ import { isExpiredAt } from "./expiry";
6
6
  import { hashPatToken } from "./hash";
7
7
  import { resolvePatRoles } from "./roles";
8
8
  import { apiTokenTable } from "./schema/api-token";
@@ -29,12 +29,7 @@ export function createPatResolver(opts: {
29
29
  }>(db, apiTokenTable, { tokenHash: hashPatToken(rawToken) });
30
30
  if (!row) return null;
31
31
  if (row.revokedAt !== null) return null;
32
- if (
33
- row.expiresAt &&
34
- row.expiresAt.epochMilliseconds <= Temporal.Now.instant().epochMilliseconds
35
- ) {
36
- return null;
37
- }
32
+ if (isExpiredAt(row.expiresAt)) return null;
38
33
  const roles = await resolvePatRoles(db, row.userId, row.tenantId);
39
34
  if (!roles) return null;
40
35
  const granted = parseScopeNames(row.scopes);
@@ -0,0 +1,107 @@
1
+ import {
2
+ i18nKey,
3
+ type ProjectionListScreenDefinition,
4
+ type SecretMintScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "./constants";
7
+ import type { PatScopeConfig } from "./scopes";
8
+
9
+ export const patListScreen: ProjectionListScreenDefinition = {
10
+ id: PAT_SCREEN_ID,
11
+ type: "projectionList",
12
+ query: PatQueries.mine,
13
+ defaultSort: { field: "createdAt", dir: "desc" },
14
+ columns: [
15
+ { field: "name", label: i18nKey("pat.list.col.name") },
16
+ { field: "prefix", label: i18nKey("pat.list.col.prefix") },
17
+ { field: "scopes", label: i18nKey("pat.list.col.scopes") },
18
+ { field: "status", label: i18nKey("pat.list.col.status") },
19
+ {
20
+ field: "createdAt",
21
+ label: i18nKey("pat.list.col.created"),
22
+ renderer: { format: "timestamp" },
23
+ },
24
+ {
25
+ field: "expiresAt",
26
+ label: i18nKey("pat.list.col.expires"),
27
+ renderer: { format: "timestamp" },
28
+ },
29
+ ],
30
+ rowActions: [
31
+ {
32
+ kind: "writeHandler",
33
+ id: "revoke",
34
+ label: i18nKey("pat.list.revoke"),
35
+ handler: PatHandlers.revoke,
36
+ style: "danger",
37
+ confirm: i18nKey("pat.list.revoke.confirm"),
38
+ visible: { field: "status", ne: "revoked" },
39
+ },
40
+ ],
41
+ toolbarActions: [
42
+ {
43
+ kind: "navigate",
44
+ id: "create",
45
+ label: i18nKey("pat.create.title"),
46
+ screen: PAT_MINT_SCREEN_ID,
47
+ style: "primary",
48
+ },
49
+ ],
50
+ access: { openToAll: true },
51
+ };
52
+
53
+ // Grant-string vocabulary for a scope config's multiSelect field — a
54
+ // read-only domain offers only "<domain>:read", one with a non-empty
55
+ // `write` set additionally offers "<domain>:write" (matches expandScopes/
56
+ // parseGrant in scopes.ts).
57
+ export function patGrantOptions(scopes: PatScopeConfig): string[] {
58
+ const out: string[] = [];
59
+ for (const [domain, def] of Object.entries(scopes)) {
60
+ out.push(`${domain}:read`);
61
+ if (def.write && def.write.length > 0) out.push(`${domain}:write`);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ export function createPatMintScreen(scopes: PatScopeConfig): SecretMintScreenDefinition {
67
+ return {
68
+ id: PAT_MINT_SCREEN_ID,
69
+ type: "secretMint",
70
+ handler: PatHandlers.create,
71
+ fields: {
72
+ name: { type: "text", required: true, maxLength: 120 },
73
+ scopes: {
74
+ type: "multiSelect",
75
+ required: true,
76
+ display: "checkboxes",
77
+ options: patGrantOptions(scopes),
78
+ },
79
+ expiresInDays: { type: "number", integer: true, min: 1, max: 3650, default: 90 },
80
+ currentPassword: { type: "text", required: true, format: "password", sensitive: true },
81
+ mfaCode: { type: "text" },
82
+ },
83
+ layout: {
84
+ sections: [
85
+ { fields: ["name", "scopes", "expiresInDays"] },
86
+ // Separate section: create.write requires password (+ MFA code, if
87
+ // enrolled) even though the request is already session-authed.
88
+ {
89
+ title: i18nKey("pat.create.reauth"),
90
+ fields: ["currentPassword", "mfaCode"],
91
+ },
92
+ ],
93
+ },
94
+ submitLabel: i18nKey("pat.create.submit"),
95
+ reveal: {
96
+ title: i18nKey("pat.created.title"),
97
+ warning: i18nKey("pat.created.hint"),
98
+ confirmLabel: i18nKey("pat.created.dismiss"),
99
+ fields: [
100
+ { field: "token", label: i18nKey("pat.created.token"), display: "code", copyable: true },
101
+ ],
102
+ },
103
+ redirect: PAT_SCREEN_ID,
104
+ cancelTarget: PAT_SCREEN_ID,
105
+ access: { openToAll: true },
106
+ };
107
+ }
@@ -17,7 +17,7 @@ export { isWithinGracePeriod } from "./grace-period";
17
17
  export { hasWhereRule } from "./has-where-rule";
18
18
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
19
19
  export { mapWithConcurrency } from "./map-with-concurrency";
20
- export { parentRowIsVisible } from "./parent-visibility";
20
+ export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility";
21
21
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
22
22
  export { sessionField } from "./session-field";
23
23
  export { sessionLocaleField } from "./session-locale-field";
@@ -33,6 +33,10 @@ export async function parentRowIsVisible(
33
33
  const entity = registry.getEntity(entityType);
34
34
  if (!entity) return false;
35
35
 
36
+ // Mirrors the SQL gate, which drops join-row candidates for the same reason:
37
+ // resolving a host that is itself a join row would need recursive gating.
38
+ if (entity.parentRef !== undefined) return false;
39
+
36
40
  // A malformed id would abort the write transaction on a Postgres cast
37
41
  // error (22P02) instead of cleanly denying — reject it before it reaches
38
42
  // the executor.
@@ -46,3 +50,29 @@ export async function parentRowIsVisible(
46
50
 
47
51
  return (await executor.detail({ id: entityId }, user, db)) !== null;
48
52
  }
53
+
54
+ // Write-path half of the parentRef gate (fw#2766). Resolves the join entity's
55
+ // own `parentRef` declaration from the registry — the same object the read
56
+ // path's SQL gate reads — so the field names and the allowedTypes allowlist
57
+ // exist in exactly one place instead of once per path.
58
+ export async function joinRowParentIsVisible(
59
+ registry: Registry,
60
+ joinEntityName: string,
61
+ payload: Readonly<Record<string, unknown>>,
62
+ user: SessionUser,
63
+ db: TenantDb,
64
+ ): Promise<boolean> {
65
+ const parentRef = registry.getEntity(joinEntityName)?.parentRef;
66
+ // A mount whose join entity lost its declaration can't derive a gate —
67
+ // deny rather than silently fall back to the pre-#2766 open behaviour.
68
+ if (parentRef === undefined) return false;
69
+
70
+ const entityType = payload[parentRef.entityTypeField];
71
+ const entityId = payload[parentRef.entityIdField];
72
+ if (typeof entityType !== "string" || typeof entityId !== "string") return false;
73
+ if (parentRef.allowedTypes !== undefined && !parentRef.allowedTypes.includes(entityType)) {
74
+ return false;
75
+ }
76
+
77
+ return parentRowIsVisible(registry, entityType, entityId, user, db);
78
+ }