@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.109.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 (116) hide show
  1. package/package.json +8 -6
  2. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
  5. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
  6. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  7. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  8. package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
  9. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
  10. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +3 -4
  12. package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
  13. package/src/auth-email-password/web/session.tsx +5 -1
  14. package/src/auth-email-password/web/user-menu.tsx +1 -1
  15. package/src/config/__tests__/deserialize-value.test.ts +3 -6
  16. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
  17. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
  18. package/src/custom-fields/constants.ts +1 -0
  19. package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
  20. package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
  21. package/src/custom-fields/wire-for-entity.ts +104 -77
  22. package/src/data-retention/__tests__/parse-override.test.ts +11 -6
  23. package/src/data-retention/resolve-for-tenant.ts +16 -4
  24. package/src/data-retention/run-retention-cleanup.ts +72 -10
  25. package/src/delivery/attempt-log.ts +22 -10
  26. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
  27. package/src/folders/__tests__/folders.integration.test.ts +26 -0
  28. package/src/folders/feature.ts +4 -2
  29. package/src/folders/handlers/delete-folder.write.ts +40 -0
  30. package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
  31. package/src/folders/web/folder-manager.tsx +18 -13
  32. package/src/folders/web/i18n.ts +0 -2
  33. package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
  34. package/src/folders-user-data/hooks.ts +31 -8
  35. package/src/folders-user-data/index.ts +4 -4
  36. package/src/jobs/feature.ts +23 -21
  37. package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
  38. package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
  39. package/src/ledger/__tests__/reports.test.ts +20 -0
  40. package/src/ledger/entity.ts +1 -1
  41. package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
  42. package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
  43. package/src/ledger/recurring.ts +18 -9
  44. package/src/ledger/schemas.ts +4 -1
  45. package/src/ledger/web/index.ts +3 -2
  46. package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
  47. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +205 -0
  48. package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
  49. package/src/personal-access-tokens/constants.ts +33 -0
  50. package/src/personal-access-tokens/feature.ts +78 -0
  51. package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
  52. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  53. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  54. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  55. package/src/personal-access-tokens/hash.ts +17 -0
  56. package/src/personal-access-tokens/index.ts +45 -0
  57. package/src/personal-access-tokens/resolver.ts +58 -0
  58. package/src/personal-access-tokens/roles.ts +29 -0
  59. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  60. package/src/personal-access-tokens/scopes.ts +43 -0
  61. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  62. package/src/personal-access-tokens/web/i18n.ts +69 -0
  63. package/src/personal-access-tokens/web/index.ts +7 -0
  64. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -0
  65. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  66. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  67. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  68. package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
  69. package/src/sessions/schema/user-session.ts +8 -2
  70. package/src/sessions/session-callbacks.ts +9 -3
  71. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  72. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  73. package/src/tags/handlers/assign-tag.write.ts +13 -3
  74. package/src/tags/schemas.ts +10 -4
  75. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  76. package/src/template-resolver/testing.ts +4 -3
  77. package/src/tier-engine/feature.ts +22 -10
  78. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  79. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  80. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  81. package/src/user/index.ts +4 -0
  82. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  83. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  84. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  85. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  86. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  87. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  88. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  89. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  90. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  91. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  92. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
  93. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  94. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  95. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  96. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  97. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  98. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  99. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  100. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  101. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  102. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  103. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  104. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  105. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  106. package/src/user-data-rights/run-forget-cleanup.ts +8 -1
  107. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  108. package/src/user-data-rights/web/i18n.ts +0 -10
  109. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  110. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  111. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  112. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  113. package/src/user-profile/handlers/change-email.write.ts +11 -8
  114. package/src/user-profile/web/profile-screen.tsx +10 -8
  115. package/src/auth-email-password/stream-tenant.ts +0 -31
  116. package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
@@ -13,7 +13,7 @@ import {
13
13
  transactionExecutor,
14
14
  transactionTable,
15
15
  } from "../executor";
16
- import { scheduleReference } from "../recurring";
16
+ import { findReversedIds, isoMonth, scheduleReference } from "../recurring";
17
17
  import { type ConfirmSchedulePeriodPayload, confirmSchedulePeriodPayloadSchema } from "../schemas";
18
18
 
19
19
  // confirm-schedule-period — turn ONE projected period of a schedule into a posted,
@@ -42,6 +42,13 @@ export function createConfirmSchedulePeriodHandler(
42
42
  );
43
43
  if (!schedule) return writeFailure(new NotFoundError("schedule", payload.scheduleId));
44
44
 
45
+ if (
46
+ payload.period < isoMonth(String(schedule["startDate"])) ||
47
+ (schedule["endDate"] != null && payload.period > isoMonth(String(schedule["endDate"])))
48
+ ) {
49
+ return writeFailure(new NotFoundError("schedule-period", payload.period));
50
+ }
51
+
45
52
  const debitAccountId = String(schedule["debitAccountId"]);
46
53
  const creditAccountId = String(schedule["creditAccountId"]);
47
54
  for (const accountId of [debitAccountId, creditAccountId]) {
@@ -51,24 +58,33 @@ export function createConfirmSchedulePeriodHandler(
51
58
 
52
59
  const reference = scheduleReference(payload.scheduleId, payload.period);
53
60
 
54
- // Scan this tenant's transactions (same full-tenant read the reports do) to
55
- // find an active booking for this reference. A tx is reversed when another
56
- // tx's reference names its id (the Storno mirror).
61
+ // Two targeted reads instead of a full-tenant transaction scan: (1) any
62
+ // booking that already carries this exact schedule-period reference
63
+ // normally 0 or 1 row, never O(tenant's-full-history); (2) any Storno
64
+ // that mirrors one of those candidates (a tx is reversed when ANOTHER
65
+ // tx's `reference` names its id).
57
66
  // ponytail: read-then-write, so two confirms racing the same period could
58
67
  // double-book; add a unique index on (tenantId, reference) when concurrent
59
68
  // confirms become real.
60
- const txRows = await selectMany(ctx.db.raw, transactionTable, {
69
+ const candidates = await selectMany(ctx.db.raw, transactionTable, {
61
70
  tenantId: event.user.tenantId,
71
+ reference,
62
72
  });
63
- const txIds = new Set(txRows.map((r) => String(r["id"])));
64
- const reversedTxIds = new Set(
65
- txRows
66
- .filter((r) => r["reference"] != null && txIds.has(String(r["reference"])))
67
- .map((r) => String(r["reference"])),
68
- );
69
- const active = txRows.find(
70
- (r) => r["reference"] === reference && !reversedTxIds.has(String(r["id"])),
73
+ const candidateIds = candidates.map((r) => String(r["id"]));
74
+ const stornos =
75
+ candidateIds.length > 0
76
+ ? await selectMany(ctx.db.raw, transactionTable, {
77
+ tenantId: event.user.tenantId,
78
+ reference: { in: candidateIds },
79
+ })
80
+ : [];
81
+ const reversedIds = findReversedIds(
82
+ [...candidates, ...stornos].map((r) => ({
83
+ id: String(r["id"]),
84
+ reference: r["reference"] === null ? null : String(r["reference"]),
85
+ })),
71
86
  );
87
+ const active = candidates.find((r) => !reversedIds.has(String(r["id"])));
72
88
  if (active) {
73
89
  const ok: WriteResult<{ id: string; alreadyBooked: true }> = {
74
90
  isSuccess: true,
@@ -1,9 +1,15 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
2
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import {
4
+ ConflictError,
5
+ NotFoundError,
6
+ UnprocessableError,
7
+ writeFailure,
8
+ } from "@cosmicdrift/kumiko-framework/errors";
3
9
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
4
10
  import { DEFAULT_LEDGER_ACCESS } from "../constants";
5
- import { transactionExecutor } from "../executor";
6
- import type { Posting } from "../schemas";
11
+ import { transactionExecutor, transactionTable } from "../executor";
12
+ import { normalizeLines } from "../reports";
7
13
  import { type ReverseTransactionPayload, reverseTransactionPayloadSchema } from "../schemas";
8
14
 
9
15
  // reverse-transaction (Storno) — the ONLY correction path for a posted entry.
@@ -24,8 +30,41 @@ export function createReverseTransactionHandler(
24
30
  const original = await transactionExecutor.detail({ id: payload.id }, event.user, ctx.db);
25
31
  if (!original) return writeFailure(new NotFoundError("transaction", payload.id));
26
32
 
27
- const originalLines = original["lines"] as readonly Posting[]; // @cast-boundary db-row
28
- const lines = originalLines.map((l) => ({ accountId: l.accountId, amount: -l.amount }));
33
+ // Only a posted entry contributes to the books (rawBalances skips
34
+ // anything else) reversing a draft would book a real, balanced
35
+ // Storno entry against a booking that was never counted, creating a
36
+ // phantom balance with no corresponding original.
37
+ if (original["status"] !== "posted") {
38
+ return writeFailure(
39
+ new UnprocessableError("transaction_not_posted", {
40
+ details: { transactionId: payload.id, status: original["status"] },
41
+ }),
42
+ );
43
+ }
44
+
45
+ // Dedup guard: two reverse() calls on the same original would each book
46
+ // an independently-balanced Storno (the global trial balance stays 0
47
+ // either way, masking the bug), doubling the per-account effect. The
48
+ // reference column ties a Storno back to its original 1:1.
49
+ const alreadyReversed = await selectMany(ctx.db.raw, transactionTable, {
50
+ tenantId: event.user.tenantId,
51
+ reference: payload.id,
52
+ });
53
+ if (alreadyReversed.length > 0) {
54
+ return writeFailure(
55
+ new ConflictError({
56
+ message: "transaction already reversed",
57
+ details: { transactionId: payload.id },
58
+ }),
59
+ );
60
+ }
61
+
62
+ // jsonb `lines` may surface as a parsed array or a JSON string depending
63
+ // on the driver path — normalizeLines handles both (see reports.ts).
64
+ const lines = normalizeLines(original["lines"]).map((l) => ({
65
+ accountId: l.accountId,
66
+ amount: -l.amount,
67
+ }));
29
68
 
30
69
  return transactionExecutor.create(
31
70
  {
@@ -53,10 +53,26 @@ export function scheduleReference(scheduleId: string, period: string): string {
53
53
  return `schedule:${scheduleId}:${period}`;
54
54
  }
55
55
 
56
+ // A Storno mirror carries reference = the reversed tx's id, so any tx whose
57
+ // reference names another tx in the set marks that other tx reversed. Shared
58
+ // by mergeScheduleActuals (below) and confirm-schedule-period.write.ts's
59
+ // idempotency check (684/4) — same detection, kept in one place so a fix
60
+ // can't land in one call-site and drift from the other.
61
+ export function findReversedIds(
62
+ rows: readonly { readonly id: string; readonly reference: string | null }[],
63
+ ): Set<string> {
64
+ const ids = new Set(rows.map((r) => r.id));
65
+ return new Set(
66
+ rows
67
+ .filter((r) => r.reference !== null && ids.has(r.reference))
68
+ .map((r) => r.reference as string),
69
+ );
70
+ }
71
+
56
72
  // "YYYY-MM" ⇄ a month index (year*12 + monthOfYear), so window math is pure
57
73
  // integer arithmetic — no Date object (no-date-api guard) and no DST/timezone
58
74
  // drift. isoMonth tolerates a full ISO date ("2026-01-15" → "2026-01").
59
- function isoMonth(iso: string): string {
75
+ export function isoMonth(iso: string): string {
60
76
  return iso.slice(0, 7);
61
77
  }
62
78
 
@@ -105,14 +121,7 @@ export function mergeScheduleActuals(
105
121
  transactions: readonly LedgerTxRow[],
106
122
  asOf: string,
107
123
  ): ScheduleMonth[] {
108
- const txIds = new Set(transactions.map((t) => t.id));
109
- // A Storno mirror carries reference = the reversed tx's id, so any tx whose
110
- // reference names another tx marks that other tx reversed.
111
- const reversedTxIds = new Set(
112
- transactions
113
- .filter((t) => t.reference != null && txIds.has(t.reference))
114
- .map((t) => t.reference as string),
115
- );
124
+ const reversedTxIds = findReversedIds(transactions);
116
125
  const asOfIndex = monthIndex(isoMonth(asOf));
117
126
 
118
127
  return projection.map((projected) => {
@@ -57,7 +57,10 @@ export const confirmSchedulePeriodPayloadSchema = z.object({
57
57
  scheduleId: z.string().min(1).max(64),
58
58
  period: z.string().regex(/^\d{4}-\d{2}$/, "period must be YYYY-MM"),
59
59
  amount: z.number().int().positive().optional(),
60
- date: z.string().min(1).max(32).optional(),
60
+ date: z
61
+ .string()
62
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD")
63
+ .optional(),
61
64
  });
62
65
  export type ConfirmSchedulePeriodPayload = z.infer<typeof confirmSchedulePeriodPayloadSchema>;
63
66
 
@@ -1,6 +1,7 @@
1
1
  // @runtime client
2
- // Client-safe ledger surface: QN constants + the pure recurring helpers
3
- // (projection + Soll/Ist merge), nothing else. The full `../ledger` entry
2
+ // Client-safe ledger surface: QN constants, the pure recurring helpers
3
+ // (projection + Soll/Ist merge), and the Zod payload schemas (for client-side
4
+ // pre-validation before dispatch) — nothing else. The full `../ledger` entry
4
5
  // re-exports the feature/handlers/executor, which pull bun-db/postgres — a
5
6
  // browser bundle that imports from there fails on Node builtins. A client screen
6
7
  // (e.g. a rent-cashflow view) imports the dispatch QNs + the pure forecast/merge
@@ -2,12 +2,8 @@ import { describe, expect, test } from "bun:test";
2
2
  import { type BrandingTokens, EMPTY_BRANDING } from "../../page-render";
3
3
  import { coerceBranding } from "../branding";
4
4
 
5
- // coerceBranding is the IO boundary for the branding query's wire response:
6
- // untrusted `unknown` BrandingTokens with no `as` cast. Every missing or
7
- // non-string field must collapse to "" so a malformed/empty response renders
8
- // the unbranded default rather than throwing — and an attacker-controlled
9
- // non-string (e.g. a logoUrl object) can never leak through as a live render
10
- // token. Exercised only indirectly by the integration path before this.
5
+ // IO-boundary coercion: untrusted `unknown` BrandingTokens, no `as` cast
6
+ // every missing/non-string field collapses to "" instead of throwing.
11
7
 
12
8
  describe("coerceBranding", () => {
13
9
  const FULL: BrandingTokens = {
@@ -0,0 +1,205 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import {
4
+ createInMemoryLoginRateLimiter,
5
+ type PatResolver,
6
+ } from "@cosmicdrift/kumiko-framework/api";
7
+ import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
9
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
10
+ import {
11
+ setupTestStack,
12
+ type TestStack,
13
+ testTenantId,
14
+ unsafeCreateEntityTable,
15
+ unsafePushTables,
16
+ } from "@cosmicdrift/kumiko-framework/stack";
17
+ import { deleteRows } from "@cosmicdrift/kumiko-framework/testing";
18
+ import { Temporal } from "temporal-polyfill";
19
+ import { AuthHandlers } from "../../auth-email-password/constants";
20
+ import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
21
+ import { createConfigFeature } from "../../config";
22
+ import { createConfigResolver } from "../../config/resolver";
23
+ import { configValuesTable } from "../../config/table";
24
+ import { makeSessionHelpers } from "../../sessions/__tests__/test-helpers";
25
+ import { SessionQueries } from "../../sessions/constants";
26
+ import { createSessionsFeature } from "../../sessions/feature";
27
+ import { userSessionEntity } from "../../sessions/schema/user-session";
28
+ import { createTenantFeature } from "../../tenant";
29
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
30
+ import { tenantEntity } from "../../tenant/schema/tenant";
31
+ import { createUserFeature } from "../../user/feature";
32
+ import { userEntity } from "../../user/schema/user";
33
+ import { PatHandlers, PatQueries } from "../constants";
34
+ import { createPersonalAccessTokensFeature } from "../feature";
35
+ import { createPatResolver } from "../resolver";
36
+ import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
37
+ import type { PatScopeConfig } from "../scopes";
38
+
39
+ // Full loop, no mocks: mint a PAT via the create handler → use it as a bearer
40
+ // token over real HTTP. The resolver hashes it, resolves live roles, and the
41
+ // API boundary enforces the granted scopes. Proves the security matrix:
42
+ // allowed → 200, out-of-scope → 403 (while a JWT user could call it),
43
+ // revoked/expired/unknown/membership-removed → 401.
44
+
45
+ let stack: TestStack;
46
+ let h: ReturnType<typeof makeSessionHelpers>;
47
+ let patResolver: PatResolver | undefined;
48
+
49
+ const encryptionKey = randomBytes(32).toString("base64");
50
+ const TENANT: TenantId = testTenantId(1);
51
+
52
+ // One domain "tokens" whose read set is exactly the two PAT queries —
53
+ // deliberately NOT sessions:query:user-session:mine, so that QN is the
54
+ // out-of-scope probe. Granted as "tokens:read".
55
+ const SCOPES: PatScopeConfig = {
56
+ tokens: { label: "Tokens", read: [PatQueries.mine, PatQueries.availableScopes] },
57
+ };
58
+
59
+ async function mintToken(
60
+ actor: SessionUser,
61
+ opts?: { scopes?: string[]; expiresInDays?: number },
62
+ ): Promise<string> {
63
+ const res = await stack.http.writeOk<{ id: string; token: string }>(
64
+ PatHandlers.create,
65
+ {
66
+ name: "test",
67
+ scopes: opts?.scopes ?? ["tokens:read"],
68
+ ...(opts?.expiresInDays ? { expiresInDays: opts.expiresInDays } : {}),
69
+ },
70
+ actor,
71
+ );
72
+ return res.token;
73
+ }
74
+
75
+ beforeAll(async () => {
76
+ const encryption = createEncryptionProvider(encryptionKey);
77
+ const resolver = createConfigResolver({ encryption });
78
+
79
+ stack = await setupTestStack({
80
+ features: [
81
+ createConfigFeature(),
82
+ createUserFeature(),
83
+ createTenantFeature(),
84
+ createAuthEmailPasswordFeature(),
85
+ createSessionsFeature(),
86
+ createPersonalAccessTokensFeature({ scopes: SCOPES }),
87
+ ],
88
+ extraContext: { configResolver: resolver, configEncryption: encryption },
89
+ authConfig: {
90
+ membershipQuery: "tenant:query:memberships",
91
+ loginHandler: AuthHandlers.login,
92
+ patResolver: (raw: string) => {
93
+ if (!patResolver) throw new Error("resolver not set");
94
+ return patResolver(raw);
95
+ },
96
+ // Low per-token cap so the rate-limit test can exhaust it. Other tests do
97
+ // ≤2 requests per (distinct) token, so this ceiling never trips them.
98
+ patRateLimiter: createInMemoryLoginRateLimiter(3, 60_000),
99
+ },
100
+ });
101
+ patResolver = createPatResolver({ db: stack.db, scopes: SCOPES });
102
+ h = makeSessionHelpers(stack, TENANT);
103
+
104
+ await unsafeCreateEntityTable(stack.db, userEntity);
105
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
106
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
107
+ await unsafeCreateEntityTable(stack.db, apiTokenEntity);
108
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
109
+ });
110
+
111
+ afterAll(async () => {
112
+ await stack.cleanup();
113
+ });
114
+
115
+ async function actorFor(email: string): Promise<SessionUser> {
116
+ const { userId } = await h.seedUser(email, "pw");
117
+ return { id: userId, tenantId: TENANT, roles: ["User"] };
118
+ }
119
+
120
+ // Raw login → JWT (no sid: session callbacks aren't wired here, and the PAT
121
+ // path doesn't need them). Used only to prove a non-PAT bearer skips the gate.
122
+ async function loginToken(email: string): Promise<string> {
123
+ const res = await stack.http.raw("POST", "/api/auth/login", { email, password: "pw" });
124
+ expect(res.status).toBe(200);
125
+ return ((await res.json()) as { token: string }).token;
126
+ }
127
+
128
+ describe("PAT auth", () => {
129
+ test("granted scope → 200", async () => {
130
+ const actor = await actorFor("granted@example.com");
131
+ const token = await mintToken(actor);
132
+ const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
133
+ expect(res.status).toBe(200);
134
+ });
135
+
136
+ test("out-of-scope QN → 403 (but a JWT user may call it)", async () => {
137
+ const actor = await actorFor("scope@example.com");
138
+ const token = await mintToken(actor);
139
+ const denied = await h.authedPost("/api/query", token, {
140
+ type: SessionQueries.mine,
141
+ payload: {},
142
+ });
143
+ expect(denied.status).toBe(403);
144
+
145
+ const jwt = await loginToken("scope@example.com");
146
+ const allowed = await h.authedPost("/api/query", jwt, {
147
+ type: SessionQueries.mine,
148
+ payload: {},
149
+ });
150
+ expect(allowed.status).toBe(200);
151
+ });
152
+
153
+ test("unknown token → 401", async () => {
154
+ const res = await h.authedPost("/api/query", "kpat_deadbeef", {
155
+ type: PatQueries.mine,
156
+ payload: {},
157
+ });
158
+ expect(res.status).toBe(401);
159
+ });
160
+
161
+ test("revoked token → 401", async () => {
162
+ const actor = await actorFor("revoked@example.com");
163
+ const token = await mintToken(actor);
164
+ const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
165
+ const id = rows[0]?.id;
166
+ expect(id).toBeDefined();
167
+ await stack.http.writeOk(PatHandlers.revoke, { id }, actor);
168
+ const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
169
+ expect(res.status).toBe(401);
170
+ });
171
+
172
+ test("expired token → 401", async () => {
173
+ const actor = await actorFor("expired@example.com");
174
+ const token = await mintToken(actor);
175
+ const past = Temporal.Now.instant().subtract({ hours: 1 });
176
+ const rows =
177
+ (await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor)) ?? [];
178
+ await updateMany(stack.db, apiTokenTable, { expiresAt: past }, { id: rows[0]?.id });
179
+ const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
180
+ expect(res.status).toBe(401);
181
+ });
182
+
183
+ test("per-token rate limit → 429 once the cap is exceeded", async () => {
184
+ const actor = await actorFor("ratelimit@example.com");
185
+ const token = await mintToken(actor);
186
+ for (let i = 0; i < 3; i++) {
187
+ const ok = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
188
+ expect(ok.status).toBe(200);
189
+ }
190
+ const limited = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
191
+ expect(limited.status).toBe(429);
192
+ });
193
+
194
+ test("membership removed → 401 (live roles, no snapshot)", async () => {
195
+ const actor = await actorFor("removed@example.com");
196
+ const token = await mintToken(actor);
197
+ const ok = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
198
+ expect(ok.status).toBe(200);
199
+ // tenantMembershipsTable is ES-managed (executor-only branded); deleteRows
200
+ // is the test-side escape for out-of-band row removal.
201
+ await deleteRows(stack.db, tenantMembershipsTable, { userId: actor.id });
202
+ const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
203
+ expect(res.status).toBe(401);
204
+ });
205
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { expandScopes, type PatScopeConfig, parseGrant } from "../scopes";
3
+
4
+ const CONFIG: PatScopeConfig = {
5
+ credit: { label: "Kredite", read: ["credit:query:*"], write: ["credit:write:*"] },
6
+ miete: { label: "Mieten", read: ["ledger:query:*"] }, // read-only domain
7
+ };
8
+
9
+ describe("parseGrant", () => {
10
+ it("splits domain:level on the last colon", () => {
11
+ expect(parseGrant("credit:write")).toEqual({ domain: "credit", level: "write" });
12
+ });
13
+ it("rejects a bare token", () => {
14
+ expect(parseGrant("credit")).toBeNull();
15
+ });
16
+ });
17
+
18
+ describe("expandScopes", () => {
19
+ it("read grants only the read QNs", () => {
20
+ expect(expandScopes(CONFIG, ["credit:read"])).toEqual(["credit:query:*"]);
21
+ });
22
+
23
+ it("write grants read + write QNs", () => {
24
+ expect(expandScopes(CONFIG, ["credit:write"]).sort()).toEqual(
25
+ ["credit:query:*", "credit:write:*"].sort(),
26
+ );
27
+ });
28
+
29
+ it("read-only domain ignores a write grant's missing write set", () => {
30
+ expect(expandScopes(CONFIG, ["miete:write"])).toEqual(["ledger:query:*"]);
31
+ });
32
+
33
+ it("unknown domain contributes nothing (fail-closed)", () => {
34
+ expect(expandScopes(CONFIG, ["ghost:write"])).toEqual([]);
35
+ });
36
+
37
+ it("unions across multiple grants without duplicates", () => {
38
+ expect(expandScopes(CONFIG, ["credit:read", "miete:read"]).sort()).toEqual(
39
+ ["credit:query:*", "ledger:query:*"].sort(),
40
+ );
41
+ });
42
+ });
@@ -0,0 +1,33 @@
1
+ // @runtime client
2
+ // Pure constants — client-marked so the web screen (web/) may import screen-id
3
+ // and QN names without pulling the feature's server runtime barrel.
4
+ export const PAT_FEATURE = "personal-access-tokens";
5
+
6
+ // Snake_case reason strings (Error-Reasons guard: no colons/dashes).
7
+ export const PatErrors = {
8
+ ownershipDenied: "ownership_denied",
9
+ } as const;
10
+
11
+ // Dormant custom-screen id (r.screen) — the app places it via r.nav. The client
12
+ // maps it to the PatTokensScreen component.
13
+ export const PAT_SCREEN_ID = "api-tokens";
14
+
15
+ export const PatHandlers = {
16
+ create: "personal-access-tokens:write:create",
17
+ revoke: "personal-access-tokens:write:revoke",
18
+ } as const;
19
+
20
+ export const PatQueries = {
21
+ mine: "personal-access-tokens:query:mine",
22
+ availableScopes: "personal-access-tokens:query:available-scopes",
23
+ } as const;
24
+
25
+ // Only the first chars of a minted token are stored (alongside the hash) so the
26
+ // UI can show "kpat_ab12…" for recognition without ever holding the secret.
27
+ export const PAT_PREFIX_DISPLAY_LENGTH = 12;
28
+
29
+ export type PatRateLimit = { readonly maxRequests: number; readonly windowMs: number };
30
+
31
+ // Default per-token request rate for PAT-authenticated calls. Generous enough
32
+ // for normal automation, low enough to blunt a leaked-token abuse spike.
33
+ export const PAT_DEFAULT_RATE_LIMIT: PatRateLimit = { maxRequests: 120, windowMs: 60_000 };
@@ -0,0 +1,78 @@
1
+ import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PAT_SCREEN_ID, type PatRateLimit } from "./constants";
4
+ import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
5
+ import { createPatWrite } from "./handlers/create.write";
6
+ import { listPatQuery } from "./handlers/list.query";
7
+ import { revokePatWrite } from "./handlers/revoke.write";
8
+ import { apiTokenEntity } from "./schema/api-token";
9
+ import type { PatScopeConfig } from "./scopes";
10
+
11
+ export type PersonalAccessTokensOptions = {
12
+ // The scopes this deployment offers. Each is a named bundle of QN globs a PAT
13
+ // may be granted (a scope can span features). Closed over by available-scopes
14
+ // (UI list) and exported so run-prod-app can build the resolver from the same
15
+ // single source.
16
+ readonly scopes: PatScopeConfig;
17
+ // Per-token request rate limit for PAT-authenticated calls. Defaults to
18
+ // PAT_DEFAULT_RATE_LIMIT (120/60s). run-prod-app builds the limiter from this.
19
+ readonly rateLimit?: PatRateLimit;
20
+ };
21
+
22
+ export type PatFeatureExports = {
23
+ readonly scopes: PatScopeConfig;
24
+ readonly rateLimit: PatRateLimit;
25
+ };
26
+
27
+ // Personal Access Tokens — long-lived, revocable bearer credentials for the
28
+ // HTTP API. Like `sessions`, the hot-path resolver is NOT a handler: it runs on
29
+ // every PAT-authenticated request and does a direct-DB point-read (see
30
+ // createPatResolver / run-prod-app wiring). The dispatcher-side handlers here
31
+ // only mint/list/revoke tokens and expose the scope catalog.
32
+ export function createPersonalAccessTokensFeature(
33
+ options: PersonalAccessTokensOptions,
34
+ ): FeatureDefinition {
35
+ const { scopes } = options;
36
+ return defineFeature(PAT_FEATURE, (r) => {
37
+ r.describe(
38
+ "Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `read_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — the resolver is wired via run-prod-app, not the dispatcher.",
39
+ );
40
+ r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
41
+ // Resolver reads memberships + users on every PAT request to build live
42
+ // roles — make both boot-time deps so a mis-wiring fails validateBoot.
43
+ r.requires("user", "tenant");
44
+ // Direct-write store like read_user_sessions: create/revoke write it, the
45
+ // resolver point-reads it. r.entity would make it a rebuildable projection
46
+ // whose replay (no token events) would wipe every live token (#498/#494).
47
+ r.unmanagedTable(buildEntityTableMeta("api-token", apiTokenEntity), {
48
+ reason: "read_side.api_tokens_direct_write",
49
+ });
50
+
51
+ const handlers = {
52
+ create: r.writeHandler(createPatWrite),
53
+ revoke: r.writeHandler(revokePatWrite),
54
+ };
55
+ const queries = {
56
+ mine: r.queryHandler(listPatQuery),
57
+ availableScopes: r.queryHandler(buildAvailableScopesQuery(scopes)),
58
+ };
59
+
60
+ // Dormant custom-screen — the client maps PAT_SCREEN_ID to PatTokensScreen;
61
+ // the app places it via r.nav in its logged-in settings area.
62
+ r.screen({
63
+ id: PAT_SCREEN_ID,
64
+ type: "custom",
65
+ renderer: { react: { __component: "PatTokensScreen" } },
66
+ access: { openToAll: true },
67
+ });
68
+
69
+ // scopes + rateLimit flow into feature.exports so run-prod-app builds the
70
+ // resolver + limiter from the same declaration — single source of truth.
71
+ return {
72
+ handlers,
73
+ queries,
74
+ scopes,
75
+ rateLimit: options.rateLimit ?? PAT_DEFAULT_RATE_LIMIT,
76
+ } satisfies { handlers: unknown; queries: unknown } & PatFeatureExports;
77
+ });
78
+ }
@@ -0,0 +1,21 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { z } from "zod";
3
+ import type { PatScopeConfig } from "../scopes";
4
+
5
+ // Returns the app-declared scope domains ({name, label, canWrite}) so the mint
6
+ // UI can render a per-domain level picker (no access / read / read & write).
7
+ // canWrite is false for read-only domains → the UI hides the write option.
8
+ // Static per deployment, not per user.
9
+ export function buildAvailableScopesQuery(scopes: PatScopeConfig) {
10
+ return defineQueryHandler({
11
+ name: "available-scopes",
12
+ schema: z.object({}),
13
+ access: { openToAll: true },
14
+ handler: async () =>
15
+ Object.entries(scopes).map(([name, def]) => ({
16
+ name,
17
+ label: def.label,
18
+ canWrite: (def.write?.length ?? 0) > 0,
19
+ })),
20
+ });
21
+ }
@@ -0,0 +1,41 @@
1
+ import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
4
+ import { Temporal } from "temporal-polyfill";
5
+ import { z } from "zod";
6
+ import { mintPatToken } from "../hash";
7
+ import { apiTokenTable } from "../schema/api-token";
8
+
9
+ // Mint a PAT for the calling user in their active tenant. The plaintext token
10
+ // is returned ONCE (data.token) and never again — only the hash is stored.
11
+ // `scopes` are granted scope names; unknown names simply grant nothing at
12
+ // resolve time (fail-closed), so no cross-check against the app config here.
13
+ export const createPatWrite = defineWriteHandler({
14
+ name: "create",
15
+ schema: z.object({
16
+ name: z.string().min(1).max(120),
17
+ scopes: z.array(z.string().min(1)).min(1),
18
+ expiresInDays: z.number().int().positive().max(3650).optional(),
19
+ }),
20
+ access: { openToAll: true },
21
+ handler: async (event, ctx) => {
22
+ const { raw, hash, prefix } = mintPatToken();
23
+ const now = Temporal.Now.instant();
24
+ const id = generateId();
25
+ await insertOne(ctx.db, apiTokenTable, {
26
+ id,
27
+ userId: event.user.id,
28
+ tenantId: event.user.tenantId,
29
+ name: event.payload.name,
30
+ tokenHash: hash,
31
+ prefix,
32
+ scopes: JSON.stringify(event.payload.scopes),
33
+ createdAt: now,
34
+ expiresAt: event.payload.expiresInDays
35
+ ? now.add({ hours: 24 * event.payload.expiresInDays })
36
+ : null,
37
+ revokedAt: null,
38
+ });
39
+ return { isSuccess: true, data: { id, token: raw, prefix } };
40
+ },
41
+ });