@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.0.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 (125) hide show
  1. package/package.json +6 -8
  2. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
  3. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
  4. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
  5. package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
  6. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
  7. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  8. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  9. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  11. package/src/auth-email-password/constants.ts +0 -4
  12. package/src/auth-email-password/feature.ts +4 -8
  13. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  14. package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
  15. package/src/auth-email-password/handlers/login.write.ts +1 -4
  16. package/src/auth-email-password/password-hashing.ts +0 -11
  17. package/src/auth-email-password/stream-tenant.ts +31 -0
  18. package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
  19. package/src/auth-email-password/web/auth-gate.tsx +4 -3
  20. package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
  21. package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
  22. package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
  23. package/src/auth-email-password/web/user-menu.tsx +1 -3
  24. package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
  25. package/src/config/__tests__/config.integration.test.ts +8 -7
  26. package/src/config/__tests__/deserialize-value.test.ts +6 -3
  27. package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
  28. package/src/config/feature.ts +8 -14
  29. package/src/config/handlers/set.write.ts +2 -2
  30. package/src/config/index.ts +3 -4
  31. package/src/config/resolver.ts +10 -34
  32. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
  33. package/src/custom-fields/constants.ts +0 -1
  34. package/src/custom-fields/events.ts +0 -6
  35. package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
  36. package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
  37. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  38. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
  39. package/src/custom-fields/wire-for-entity.ts +77 -116
  40. package/src/data-retention/__tests__/parse-override.test.ts +13 -13
  41. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
  42. package/src/folders/__tests__/folders.integration.test.ts +0 -3
  43. package/src/folders/web/__tests__/tree.test.ts +0 -8
  44. package/src/folders/web/folder-manager.tsx +3 -1
  45. package/src/folders/web/i18n.ts +2 -0
  46. package/src/folders/web/tree.ts +2 -13
  47. package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
  48. package/src/folders-user-data/index.ts +4 -4
  49. package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
  50. package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
  51. package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
  52. package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
  53. package/src/ledger/recurring.ts +8 -17
  54. package/src/ledger/web/index.ts +2 -3
  55. package/src/legal-pages/feature.ts +3 -1
  56. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
  57. package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
  58. package/src/managed-pages/feature.ts +3 -1
  59. package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
  60. package/src/secrets/handlers/rotate.job.ts +98 -77
  61. package/src/secrets/secrets-context.ts +38 -7
  62. package/src/secrets/table.ts +7 -6
  63. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
  64. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  65. package/src/sessions/feature.ts +18 -51
  66. package/src/sessions/index.ts +2 -2
  67. package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
  68. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
  69. package/src/tags/__tests__/tags.integration.test.ts +1 -12
  70. package/src/tags/entity.ts +1 -6
  71. package/src/tags/schemas.ts +2 -9
  72. package/src/tags/web/tag-manager.tsx +1 -1
  73. package/src/template-resolver/testing.ts +1 -2
  74. package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
  75. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
  76. package/src/user/__tests__/user.integration.test.ts +0 -26
  77. package/src/user/handlers/find-for-auth.query.ts +5 -7
  78. package/src/user/index.ts +0 -4
  79. package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
  80. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  81. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  82. package/src/user-data-rights/feature.ts +3 -1
  83. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
  84. package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
  85. package/src/user-data-rights/run-forget-cleanup.ts +0 -1
  86. package/src/user-data-rights/run-user-export.ts +4 -43
  87. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
  88. package/src/user-data-rights/web/client-plugin.tsx +1 -6
  89. package/src/user-data-rights/web/i18n.ts +10 -0
  90. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
  91. package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
  92. package/src/user-profile/handlers/change-email.write.ts +8 -11
  93. package/src/user-profile/web/profile-screen.tsx +1 -12
  94. package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
  95. package/src/auth-email-password/password-hashing.test.ts +0 -35
  96. package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
  97. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
  98. package/src/config/handlers/reencrypt.job.ts +0 -197
  99. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
  100. package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
  101. package/src/personal-access-tokens/constants.ts +0 -33
  102. package/src/personal-access-tokens/feature.ts +0 -78
  103. package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
  104. package/src/personal-access-tokens/handlers/create.write.ts +0 -41
  105. package/src/personal-access-tokens/handlers/list.query.ts +0 -47
  106. package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
  107. package/src/personal-access-tokens/hash.ts +0 -17
  108. package/src/personal-access-tokens/index.ts +0 -45
  109. package/src/personal-access-tokens/resolver.ts +0 -58
  110. package/src/personal-access-tokens/roles.ts +0 -29
  111. package/src/personal-access-tokens/schema/api-token.ts +0 -56
  112. package/src/personal-access-tokens/scopes.ts +0 -43
  113. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  114. package/src/personal-access-tokens/web/i18n.ts +0 -69
  115. package/src/personal-access-tokens/web/index.ts +0 -7
  116. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
  117. package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
  118. package/src/shared/chunked-entity-migration.ts +0 -89
  119. package/src/shared/index.ts +0 -7
  120. package/src/tags/__tests__/schemas.test.ts +0 -34
  121. package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
  122. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
  123. package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
  124. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
  125. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
@@ -1,197 +0,0 @@
1
- // Re-encrypt job for `encrypted: true` config values. Two jobs in one,
2
- // because format detection makes them the same loop:
3
- // - MIGRATION: legacy CONFIG_ENCRYPTION_KEY values (base64 blob, no key
4
- // id) → envelope format under the current master key. After a clean
5
- // run the legacy key can be dropped from the environment.
6
- // - KEK-ROTATION: envelope values wrapped under an older kekVersion →
7
- // re-encrypted under provider.currentVersion(). Config has no
8
- // kek_version column (values live in a TEXT column), so unlike the
9
- // secrets rotate job the version check parses the stored JSON.
10
- //
11
- // Idempotent: a re-run skips rows already on the current version. Every
12
- // write goes through the event-store executor (config values are
13
- // entity-backed — raw UPDATEs would be wiped by a projection rebuild),
14
- // so each migration appends a normal `.updated` event whose payload
15
- // carries the NEW envelope: after a full run even a from-scratch rebuild
16
- // no longer needs the legacy key for the final state.
17
-
18
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
19
- import {
20
- createEventStoreExecutor,
21
- createTenantDb,
22
- type DbConnection,
23
- type TenantDb,
24
- } from "@cosmicdrift/kumiko-framework/db";
25
- import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
26
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
27
- import { type EnvelopeCipher, isStoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
28
- import { type ChunkedMigrationStopReason, runChunkedMigration } from "../../shared";
29
- import { configValueEntity, configValuesTable } from "../table";
30
-
31
- const DEFAULT_MAX_FAILURES = 10;
32
- const SCAN_SLICE_SIZE = 100;
33
- const SYSTEM_ROLES = ["system"] as const;
34
-
35
- const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
36
- entityName: "config-value",
37
- });
38
-
39
- export type ReencryptJobPayload = {
40
- readonly maxDurationMs?: number;
41
- readonly maxFailures?: number;
42
- };
43
-
44
- export type ReencryptJobResult = {
45
- readonly migrated: number;
46
- readonly failed: number;
47
- readonly alreadyCurrent: number;
48
- readonly stoppedReason: ChunkedMigrationStopReason;
49
- };
50
-
51
- function needsReencrypt(value: string, targetVersion: number): boolean {
52
- // legacy single-key format (base64 — can never start with "{")
53
- if (!value.startsWith("{")) return true;
54
- try {
55
- const parsed: unknown = JSON.parse(value);
56
- if (!isStoredEnvelope(parsed)) return true;
57
- return parsed.kekVersion !== targetVersion;
58
- } catch {
59
- // malformed JSON — let the decrypt attempt surface the real error
60
- return true;
61
- }
62
- }
63
-
64
- export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
65
- const payload = rawPayload as ReencryptJobPayload; // @cast-boundary engine-payload
66
- const maybeCipher = ctx.configEncryption;
67
- if (!maybeCipher) {
68
- throw new InternalError({
69
- message:
70
- "[config:reencrypt] ctx.configEncryption missing — provide a master key " +
71
- "(KUMIKO_SECRETS_MASTER_KEY_V<n>) so the boot wires the envelope cipher.",
72
- });
73
- }
74
- // hoisted function declarations below capture these — pin the narrowed
75
- // type explicitly so TS keeps it inside the closures
76
- const cipher: EnvelopeCipher = maybeCipher;
77
- const provider = ctx.masterKeyProvider;
78
- if (!provider) {
79
- throw new InternalError({
80
- message:
81
- "[config:reencrypt] ctx.masterKeyProvider missing — wire it via extraContext.masterKeyProvider at boot.",
82
- });
83
- }
84
- if (!ctx.db) {
85
- throw new InternalError({
86
- message: "[config:reencrypt] ctx.db missing — job context requires a database connection.",
87
- });
88
- }
89
- if (!ctx.registry) {
90
- throw new InternalError({
91
- message: "[config:reencrypt] ctx.registry missing — job context requires the registry.",
92
- });
93
- }
94
- const db = ctx.db as DbConnection; // @cast-boundary db-operator
95
-
96
- const encryptedKeys = [...ctx.registry.getAllConfigKeys()]
97
- .filter(([, def]) => def.encrypted === true)
98
- .map(([key]) => key);
99
-
100
- const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
101
- const deadline = payload.maxDurationMs
102
- ? Date.now() + payload.maxDurationMs
103
- : Number.POSITIVE_INFINITY;
104
-
105
- const tdbCache = new Map<TenantId, TenantDb>();
106
- function tdbFor(tenantId: TenantId): TenantDb {
107
- let existing = tdbCache.get(tenantId);
108
- if (!existing) {
109
- existing = createTenantDb(db, tenantId, "system");
110
- tdbCache.set(tenantId, existing);
111
- }
112
- return existing;
113
- }
114
-
115
- type ConfigRow = {
116
- id: string;
117
- key: string;
118
- value: string | null;
119
- tenantId: string;
120
- version: number;
121
- };
122
-
123
- let alreadyCurrent = 0;
124
- const targetVersion = provider.currentVersion();
125
-
126
- // ponytail: one full candidate scan — config rows are operator-scale
127
- // (tenants × encrypted keys), cursor pagination when that ever changes.
128
- // Served to the shared loop in slices so its deadline/signal/failure
129
- // checks run between chunks, not only once.
130
- let pending: ConfigRow[] | undefined;
131
- async function nextBatch(): Promise<readonly ConfigRow[]> {
132
- if (pending === undefined) {
133
- pending =
134
- encryptedKeys.length === 0
135
- ? []
136
- : [
137
- ...(await selectMany<ConfigRow>(db, configValuesTable, {
138
- key: { in: encryptedKeys },
139
- })),
140
- ];
141
- }
142
- const slice = pending;
143
- return slice.splice(0, SCAN_SLICE_SIZE);
144
- }
145
-
146
- async function migrateRow(row: ConfigRow): Promise<"migrated" | "skipped" | "failed"> {
147
- if (row.value === null || row.value === undefined) return "skipped";
148
- if (!needsReencrypt(row.value, targetVersion)) {
149
- alreadyCurrent++;
150
- return "skipped";
151
- }
152
-
153
- // decrypt failure (missing legacy key, unknown kekVersion, tamper)
154
- // throws → counted as failed via onRowError; the row stays untouched —
155
- // never write anything we couldn't read.
156
- const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
157
- const plaintext = await cipher.decrypt(row.value, { tenantId });
158
- const reencrypted = await cipher.encrypt(plaintext, { tenantId });
159
-
160
- const actor: SessionUser = { id: "system", tenantId, roles: SYSTEM_ROLES };
161
- const result = await executor.update(
162
- { id: row.id, version: row.version, changes: { value: reencrypted } },
163
- actor,
164
- tdbFor(tenantId),
165
- );
166
-
167
- // version_conflict == a concurrent config:set beat us; the row now
168
- // holds a fresh envelope written by the set handler — already fine.
169
- if (!result.isSuccess) {
170
- if (result.error.code === "version_conflict") return "skipped";
171
- ctx.log?.warn?.(`[config:reencrypt] executor rejected row ${row.id}`, {
172
- code: result.error.code,
173
- });
174
- return "failed";
175
- }
176
- return "migrated";
177
- }
178
-
179
- const outcome = await runChunkedMigration<ConfigRow>({
180
- nextBatch,
181
- migrateRow,
182
- maxFailures,
183
- deadlineAt: deadline,
184
- signal: ctx.signal,
185
- onRowError: (row, err) => {
186
- ctx.log?.warn?.(`[config:reencrypt] failed to re-encrypt row ${row.id}`, { err });
187
- },
188
- });
189
-
190
- const result: ReencryptJobResult = {
191
- migrated: outcome.migrated,
192
- failed: outcome.failed,
193
- alreadyCurrent,
194
- stoppedReason: outcome.stoppedReason,
195
- };
196
- ctx.log?.info?.(`[config:reencrypt] complete: ${JSON.stringify(result)}`);
197
- };
@@ -1,204 +0,0 @@
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 type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
- import {
10
- setupTestStack,
11
- type TestStack,
12
- testTenantId,
13
- unsafeCreateEntityTable,
14
- unsafePushTables,
15
- } from "@cosmicdrift/kumiko-framework/stack";
16
- import { createTestEnvelopeCipher, deleteRows } from "@cosmicdrift/kumiko-framework/testing";
17
- import { Temporal } from "temporal-polyfill";
18
- import { AuthHandlers } from "../../auth-email-password/constants";
19
- import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
20
- import { createConfigFeature } from "../../config";
21
- import { createConfigResolver } from "../../config/resolver";
22
- import { configValuesTable } from "../../config/table";
23
- import { makeSessionHelpers } from "../../sessions/__tests__/test-helpers";
24
- import { SessionQueries } from "../../sessions/constants";
25
- import { createSessionsFeature } from "../../sessions/feature";
26
- import { userSessionEntity } from "../../sessions/schema/user-session";
27
- import { createTenantFeature } from "../../tenant";
28
- import { tenantMembershipsTable } from "../../tenant/membership-table";
29
- import { tenantEntity } from "../../tenant/schema/tenant";
30
- import { createUserFeature } from "../../user/feature";
31
- import { userEntity } from "../../user/schema/user";
32
- import { PatHandlers, PatQueries } from "../constants";
33
- import { createPersonalAccessTokensFeature } from "../feature";
34
- import { createPatResolver } from "../resolver";
35
- import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
36
- import type { PatScopeConfig } from "../scopes";
37
-
38
- // Full loop, no mocks: mint a PAT via the create handler → use it as a bearer
39
- // token over real HTTP. The resolver hashes it, resolves live roles, and the
40
- // API boundary enforces the granted scopes. Proves the security matrix:
41
- // allowed → 200, out-of-scope → 403 (while a JWT user could call it),
42
- // revoked/expired/unknown/membership-removed → 401.
43
-
44
- let stack: TestStack;
45
- let h: ReturnType<typeof makeSessionHelpers>;
46
- let patResolver: PatResolver | undefined;
47
-
48
- const encryptionKey = randomBytes(32).toString("base64");
49
- const TENANT: TenantId = testTenantId(1);
50
-
51
- // One domain "tokens" whose read set is exactly the two PAT queries —
52
- // deliberately NOT sessions:query:user-session:mine, so that QN is the
53
- // out-of-scope probe. Granted as "tokens:read".
54
- const SCOPES: PatScopeConfig = {
55
- tokens: { label: "Tokens", read: [PatQueries.mine, PatQueries.availableScopes] },
56
- };
57
-
58
- async function mintToken(
59
- actor: SessionUser,
60
- opts?: { scopes?: string[]; expiresInDays?: number },
61
- ): Promise<string> {
62
- const res = await stack.http.writeOk<{ id: string; token: string }>(
63
- PatHandlers.create,
64
- {
65
- name: "test",
66
- scopes: opts?.scopes ?? ["tokens:read"],
67
- ...(opts?.expiresInDays ? { expiresInDays: opts.expiresInDays } : {}),
68
- },
69
- actor,
70
- );
71
- return res.token;
72
- }
73
-
74
- beforeAll(async () => {
75
- const encryption = createTestEnvelopeCipher(encryptionKey);
76
- const resolver = createConfigResolver({ cipher: encryption });
77
-
78
- stack = await setupTestStack({
79
- features: [
80
- createConfigFeature(),
81
- createUserFeature(),
82
- createTenantFeature(),
83
- createAuthEmailPasswordFeature(),
84
- createSessionsFeature(),
85
- createPersonalAccessTokensFeature({ scopes: SCOPES }),
86
- ],
87
- extraContext: { configResolver: resolver, configEncryption: encryption },
88
- authConfig: {
89
- membershipQuery: "tenant:query:memberships",
90
- loginHandler: AuthHandlers.login,
91
- patResolver: (raw: string) => {
92
- if (!patResolver) throw new Error("resolver not set");
93
- return patResolver(raw);
94
- },
95
- // Low per-token cap so the rate-limit test can exhaust it. Other tests do
96
- // ≤2 requests per (distinct) token, so this ceiling never trips them.
97
- patRateLimiter: createInMemoryLoginRateLimiter(3, 60_000),
98
- },
99
- });
100
- patResolver = createPatResolver({ db: stack.db, scopes: SCOPES });
101
- h = makeSessionHelpers(stack, TENANT);
102
-
103
- await unsafeCreateEntityTable(stack.db, userEntity);
104
- await unsafeCreateEntityTable(stack.db, tenantEntity);
105
- await unsafeCreateEntityTable(stack.db, userSessionEntity);
106
- await unsafeCreateEntityTable(stack.db, apiTokenEntity);
107
- await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
108
- });
109
-
110
- afterAll(async () => {
111
- await stack.cleanup();
112
- });
113
-
114
- async function actorFor(email: string): Promise<SessionUser> {
115
- const { userId } = await h.seedUser(email, "pw");
116
- return { id: userId, tenantId: TENANT, roles: ["User"] };
117
- }
118
-
119
- // Raw login → JWT (no sid: session callbacks aren't wired here, and the PAT
120
- // path doesn't need them). Used only to prove a non-PAT bearer skips the gate.
121
- async function loginToken(email: string): Promise<string> {
122
- const res = await stack.http.raw("POST", "/api/auth/login", { email, password: "pw" });
123
- expect(res.status).toBe(200);
124
- return ((await res.json()) as { token: string }).token;
125
- }
126
-
127
- describe("PAT auth", () => {
128
- test("granted scope → 200", async () => {
129
- const actor = await actorFor("granted@example.com");
130
- const token = await mintToken(actor);
131
- const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
132
- expect(res.status).toBe(200);
133
- });
134
-
135
- test("out-of-scope QN → 403 (but a JWT user may call it)", async () => {
136
- const actor = await actorFor("scope@example.com");
137
- const token = await mintToken(actor);
138
- const denied = await h.authedPost("/api/query", token, {
139
- type: SessionQueries.mine,
140
- payload: {},
141
- });
142
- expect(denied.status).toBe(403);
143
-
144
- const jwt = await loginToken("scope@example.com");
145
- const allowed = await h.authedPost("/api/query", jwt, {
146
- type: SessionQueries.mine,
147
- payload: {},
148
- });
149
- expect(allowed.status).toBe(200);
150
- });
151
-
152
- test("unknown token → 401", async () => {
153
- const res = await h.authedPost("/api/query", "kpat_deadbeef", {
154
- type: PatQueries.mine,
155
- payload: {},
156
- });
157
- expect(res.status).toBe(401);
158
- });
159
-
160
- test("revoked token → 401", async () => {
161
- const actor = await actorFor("revoked@example.com");
162
- const token = await mintToken(actor);
163
- const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
164
- const id = rows[0]?.id;
165
- expect(id).toBeDefined();
166
- await stack.http.writeOk(PatHandlers.revoke, { id }, actor);
167
- const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
168
- expect(res.status).toBe(401);
169
- });
170
-
171
- test("expired token → 401", async () => {
172
- const actor = await actorFor("expired@example.com");
173
- const token = await mintToken(actor);
174
- const past = Temporal.Now.instant().subtract({ hours: 1 });
175
- const rows =
176
- (await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor)) ?? [];
177
- await updateMany(stack.db, apiTokenTable, { expiresAt: past }, { id: rows[0]?.id });
178
- const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
179
- expect(res.status).toBe(401);
180
- });
181
-
182
- test("per-token rate limit → 429 once the cap is exceeded", async () => {
183
- const actor = await actorFor("ratelimit@example.com");
184
- const token = await mintToken(actor);
185
- for (let i = 0; i < 3; i++) {
186
- const ok = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
187
- expect(ok.status).toBe(200);
188
- }
189
- const limited = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
190
- expect(limited.status).toBe(429);
191
- });
192
-
193
- test("membership removed → 401 (live roles, no snapshot)", async () => {
194
- const actor = await actorFor("removed@example.com");
195
- const token = await mintToken(actor);
196
- const ok = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
197
- expect(ok.status).toBe(200);
198
- // tenantMembershipsTable is ES-managed (executor-only branded); deleteRows
199
- // is the test-side escape for out-of-band row removal.
200
- await deleteRows(stack.db, tenantMembershipsTable, { userId: actor.id });
201
- const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
202
- expect(res.status).toBe(401);
203
- });
204
- });
@@ -1,42 +0,0 @@
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
- });
@@ -1,33 +0,0 @@
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 };
@@ -1,78 +0,0 @@
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
- }
@@ -1,21 +0,0 @@
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
- }
@@ -1,41 +0,0 @@
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
- });
@@ -1,47 +0,0 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
- import { z } from "zod";
4
- import { apiTokenTable } from "../schema/api-token";
5
-
6
- // The caller's own tokens — metadata only, never the hash or plaintext.
7
- // Includes revoked/expired rows so the UI can show history; `prefix` is the
8
- // only fragment of the secret ever exposed.
9
- export const listPatQuery = defineQueryHandler({
10
- name: "mine",
11
- schema: z.object({}),
12
- access: { openToAll: true },
13
- handler: async (query, ctx) => {
14
- const rows = await selectMany<{
15
- id: string;
16
- name: string;
17
- prefix: string;
18
- scopes: string;
19
- createdAt: unknown;
20
- expiresAt: unknown;
21
- revokedAt: unknown;
22
- }>(
23
- ctx.db,
24
- apiTokenTable,
25
- { userId: query.user.id },
26
- { orderBy: { col: "createdAt", direction: "desc" } },
27
- );
28
- return rows.map((r) => ({
29
- id: r.id,
30
- name: r.name,
31
- prefix: r.prefix,
32
- scopes: parseScopeNames(r.scopes),
33
- createdAt: r.createdAt,
34
- expiresAt: r.expiresAt,
35
- revokedAt: r.revokedAt,
36
- }));
37
- },
38
- });
39
-
40
- function parseScopeNames(raw: string): string[] {
41
- try {
42
- const parsed: unknown = JSON.parse(raw);
43
- return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
44
- } catch {
45
- return [];
46
- }
47
- }