@cosmicdrift/kumiko-bundled-features 0.311.0 → 0.313.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 (77) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/extension-points-typed.test.ts +8 -0
  3. package/src/agent-tools/__tests__/agent-manifest.test.ts +30 -0
  4. package/src/agent-tools/agent-manifest.ts +7 -1
  5. package/src/agent-tools/changes.json +8 -1
  6. package/src/audit/changes.json +6 -0
  7. package/src/audit/feature.ts +1 -2
  8. package/src/audit/i18n.ts +3 -0
  9. package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +424 -0
  10. package/src/auth-email-password/changes.json +6 -0
  11. package/src/auth-email-password/handlers/signup-confirm.write.ts +75 -5
  12. package/src/auth-email-password/handlers/signup-request.write.ts +60 -0
  13. package/src/auth-email-password/signup-token-store.ts +87 -1
  14. package/src/auth-email-password/web/auth-client.ts +10 -1
  15. package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +160 -0
  16. package/src/data-retention/changes.json +6 -0
  17. package/src/data-retention/run-retention-cleanup.ts +160 -18
  18. package/src/delivery/__tests__/delivery.integration.test.ts +206 -3
  19. package/src/delivery/address-opt-out.ts +87 -0
  20. package/src/delivery/changes.json +7 -0
  21. package/src/delivery/db/queries/address-opt-outs.ts +23 -0
  22. package/src/delivery/delivery-service.ts +32 -0
  23. package/src/delivery/feature.ts +9 -1
  24. package/src/delivery/index.ts +5 -0
  25. package/src/delivery/tables.ts +37 -0
  26. package/src/delivery/unsubscribe.ts +101 -16
  27. package/src/delivery/upsert-preference.ts +1 -1
  28. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +621 -43
  29. package/src/document-ingest-foundation/__tests__/feature.test.ts +20 -3
  30. package/src/document-ingest-foundation/__tests__/providers.test.ts +204 -0
  31. package/src/document-ingest-foundation/boot-checks.ts +58 -0
  32. package/src/document-ingest-foundation/changes.json +18 -0
  33. package/src/document-ingest-foundation/events.ts +3 -0
  34. package/src/document-ingest-foundation/executor.ts +12 -0
  35. package/src/document-ingest-foundation/feature.ts +125 -72
  36. package/src/document-ingest-foundation/file-ref-liveness.ts +10 -0
  37. package/src/document-ingest-foundation/forget-extract-with-file-ref.ts +84 -0
  38. package/src/document-ingest-foundation/index.ts +17 -0
  39. package/src/document-ingest-foundation/providers.ts +78 -0
  40. package/src/document-ingest-foundation/tenant-destroy-hook.ts +3 -7
  41. package/src/document-ingest-foundation/write-document-extract.ts +48 -0
  42. package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
  43. package/src/file-derivatives/changes.json +6 -0
  44. package/src/file-derivatives/feature.ts +5 -0
  45. package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
  46. package/src/shared/index.ts +10 -1
  47. package/src/shared/run-in-sub-transaction.ts +35 -0
  48. package/src/shared/signup-handover.ts +67 -0
  49. package/src/shared/single-use-token-store.ts +25 -5
  50. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
  51. package/src/subscription-stripe/changes.json +9 -1
  52. package/src/subscription-stripe/feature.ts +13 -1
  53. package/src/subscription-stripe/plugin-methods.ts +17 -2
  54. package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
  55. package/src/tenant/changes.json +6 -0
  56. package/src/tenant/index.ts +1 -0
  57. package/src/tenant/is-tenant-serving-public-content.ts +13 -0
  58. package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
  59. package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
  60. package/src/tenant/web/member-roles-cell.tsx +11 -2
  61. package/src/tenant/web/member-status-cell.tsx +6 -2
  62. package/src/tenant/web/translate-or-raw.ts +13 -0
  63. package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
  64. package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
  65. package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
  66. package/src/tenant-handover/changes.json +12 -0
  67. package/src/tenant-handover/events.ts +6 -0
  68. package/src/tenant-handover/feature.ts +11 -0
  69. package/src/tenant-handover/handlers/claim.write.ts +8 -25
  70. package/src/tenant-handover/move-entity-graph.ts +30 -1
  71. package/src/tenant-handover/root-anchor.ts +50 -0
  72. package/src/tenant-handover/signup-handover-provider.ts +62 -0
  73. package/src/tenant-handover/transfer-graph.ts +6 -2
  74. package/src/user-data-rights/changes.json +6 -0
  75. package/src/user-data-rights/feature.ts +1 -2
  76. package/src/user-data-rights/i18n.ts +3 -0
  77. package/src/user-data-rights/run-forget-cleanup.ts +1 -38
@@ -0,0 +1,84 @@
1
+ // documentExtract is derived data — once its source fileRef is gone (soft
2
+ // delete or forget), the extract has no reason to survive it. A restored
3
+ // fileRef is re-ingested by feature.ts's request-ingest MSP
4
+ // (fileRef.restored), so a fresh extract replaces the forgotten one.
5
+ //
6
+ // forget(), not deleteMany: documentExtract is an ES-managed implicit
7
+ // projection, so forget() replays correctly on rebuild. This MSP itself has
8
+ // no `table`, so rebuildMultiStreamProjection never targets it directly.
9
+ //
10
+ // Also reacts to documentExtract.created: providers write extracts
11
+ // themselves (own executor call), racing this consumer's fileRef.deleted/
12
+ // forgotten handling. Each handler re-checks current fileRef liveness at
13
+ // processing time, so no extract survives regardless of write/delete order —
14
+ // this covers providers that bypass write-document-extract.ts's helper too.
15
+
16
+ import { createTenantDb, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
17
+ import type { MultiStreamApplyFn } from "@cosmicdrift/kumiko-framework/engine";
18
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
19
+ import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
20
+ import * as z from "zod";
21
+ import { documentExtractsTable } from "./entity";
22
+ import { documentExtractExecutor } from "./executor";
23
+ import { isFileRefLive } from "./file-ref-liveness";
24
+
25
+ // WriteResult.error is always a plain WriteErrorInfo object, never a
26
+ // KumikoError instance (JSON-serializable for the dispatcher's idempotency-key
27
+ // storage) — compare on .code, not instanceof.
28
+ function isNotFoundWriteError(error: WriteErrorInfo): boolean {
29
+ return error.code === "not_found";
30
+ }
31
+
32
+ async function forgetExtractsForFileRef(
33
+ event: Parameters<MultiStreamApplyFn>[0],
34
+ tenantDb: TenantDb,
35
+ ): Promise<void> {
36
+ const rows = await tenantDb.selectMany<{ id: string }>(documentExtractsTable, {
37
+ fileRefId: event.aggregateId,
38
+ });
39
+ const user = createSystemUser(event.tenantId);
40
+ for (const row of rows) {
41
+ const result = await documentExtractExecutor.forget({ id: row.id }, user, tenantDb);
42
+ if (result.isSuccess) continue;
43
+ // Idempotent: a concurrent/redelivered apply that already forgot this
44
+ // row is not an error — anything else (version conflict, ownership
45
+ // denial) must surface so the dispatcher retries/dead-letters instead of
46
+ // silently leaving the extract behind.
47
+ if (isNotFoundWriteError(result.error)) continue;
48
+ throw new Error(
49
+ `document-ingest-foundation: failed to forget documentExtract ${row.id} for fileRef ${event.aggregateId}: ${result.error.message}`,
50
+ );
51
+ }
52
+ }
53
+
54
+ const documentExtractCreatedPayloadSchema = z.object({ fileRefId: z.string().min(1) });
55
+
56
+ export const forgetOrphanedDocumentExtractHook: MultiStreamApplyFn = async (event, tx) => {
57
+ const parsed = documentExtractCreatedPayloadSchema.safeParse(event.payload);
58
+ // skip: malformed payload — don't poison the consumer
59
+ if (!parsed.success) return;
60
+ const tenantDb = createTenantDb(tx, event.tenantId);
61
+ // skip: fileRef still live — the extract is legitimate
62
+ if (await isFileRefLive(tenantDb, parsed.data.fileRefId)) return;
63
+ const user = createSystemUser(event.tenantId);
64
+ const result = await documentExtractExecutor.forget({ id: event.aggregateId }, user, tenantDb);
65
+ // skip: forgotten now, or already gone via a concurrent/redelivered apply
66
+ if (result.isSuccess || isNotFoundWriteError(result.error)) return;
67
+ throw new Error(
68
+ `document-ingest-foundation: failed to forget orphaned documentExtract ${event.aggregateId}: ${result.error.message}`,
69
+ );
70
+ };
71
+
72
+ export const forgetExtractOnFileRefDeletedHook: MultiStreamApplyFn = async (event, tx) => {
73
+ const tenantDb = createTenantDb(tx, event.tenantId);
74
+ // skip: fileRef is live again — request-ingest and this consumer poll via
75
+ // separate cursors, so a delete→restore round-trip can finish before this
76
+ // consumer sees fileRef.deleted; forgetting would drop the live file's extract.
77
+ if (await isFileRefLive(tenantDb, event.aggregateId)) return;
78
+ await forgetExtractsForFileRef(event, tenantDb);
79
+ };
80
+
81
+ export const forgetExtractOnFileRefForgottenHook: MultiStreamApplyFn = async (event, tx) => {
82
+ const tenantDb = createTenantDb(tx, event.tenantId);
83
+ await forgetExtractsForFileRef(event, tenantDb);
84
+ };
@@ -10,8 +10,25 @@ export {
10
10
  DOCUMENT_INGEST_AGGREGATE_TYPE,
11
11
  DOCUMENT_INGEST_REQUESTED_EVENT_QN,
12
12
  DOCUMENT_INGEST_REQUESTED_EVENT_SHORT,
13
+ DOCUMENT_INGEST_SKIPPED_EVENT_QN,
14
+ DOCUMENT_INGEST_SKIPPED_EVENT_SHORT,
13
15
  type DocumentIngestRequestedPayload,
16
+ type DocumentIngestSkippedPayload,
14
17
  documentIngestRequestedPayloadSchema,
18
+ documentIngestSkippedPayloadSchema,
15
19
  } from "./events";
16
20
  export { documentIngestFoundationFeature } from "./feature";
17
21
  export { readIngestPages, writeIngestPages } from "./pages";
22
+ export {
23
+ type DocumentIngestProviderOptions,
24
+ documentIngestProviderOptionsSchema,
25
+ documentIngestProviderTrigger,
26
+ EXT_DOCUMENT_INGEST_PROVIDER,
27
+ listIngestibleMimeTypes,
28
+ type ResolvedDocumentIngestProvider,
29
+ resolveDocumentIngestProviders,
30
+ } from "./providers";
31
+ export {
32
+ type DocumentExtractWriteResult,
33
+ writeDocumentExtractForLiveFileRef,
34
+ } from "./write-document-extract";
@@ -0,0 +1,78 @@
1
+ // document-ingest-foundation — provider extension-point. One shared
2
+ // documentIngest.requested QN fans out to every mounted provider's job,
3
+ // partitioned by documentIngestProviderTrigger's where-filter.
4
+
5
+ import type { FeatureDefinition, Registry } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { normalizeMimeType } from "@cosmicdrift/kumiko-framework/files";
7
+ import * as z from "zod";
8
+ import { DOCUMENT_INGEST_REQUESTED_EVENT_QN } from "./events";
9
+
10
+ export const EXT_DOCUMENT_INGEST_PROVIDER = "documentIngestProvider" as const;
11
+
12
+ export const documentIngestProviderOptionsSchema = z.object({
13
+ mimeTypes: z.array(z.string().min(1)).min(1),
14
+ maxFileBytes: z.number().int().positive(),
15
+ });
16
+ export type DocumentIngestProviderOptions = z.infer<typeof documentIngestProviderOptionsSchema>;
17
+
18
+ // r.useExtension options-shape, co-located since the framework never imports upward.
19
+ declare module "@cosmicdrift/kumiko-framework/engine" {
20
+ interface KumikoExtensionOptionsMap {
21
+ [EXT_DOCUMENT_INGEST_PROVIDER]: DocumentIngestProviderOptions;
22
+ }
23
+ }
24
+
25
+ export type ResolvedDocumentIngestProvider = {
26
+ readonly name: string;
27
+ readonly maxFileBytes: number;
28
+ };
29
+
30
+ type ExtensionUsages = FeatureDefinition["extensionUsages"];
31
+
32
+ // Throws on an invalid options shape or two providers claiming the same
33
+ // normalized mimeType — callers decide whether that's boot-fatal.
34
+ export function resolveDocumentIngestProviders(
35
+ usages: ExtensionUsages,
36
+ ): ReadonlyMap<string, ResolvedDocumentIngestProvider> {
37
+ const byMime = new Map<string, ResolvedDocumentIngestProvider>();
38
+ for (const usage of usages) {
39
+ if (usage.extensionName !== EXT_DOCUMENT_INGEST_PROVIDER) continue;
40
+ const parsed = documentIngestProviderOptionsSchema.safeParse(usage.options);
41
+ if (!parsed.success) {
42
+ throw new Error(
43
+ `document-ingest-foundation: provider "${usage.entityName}" registered invalid options via ` +
44
+ `r.useExtension(EXT_DOCUMENT_INGEST_PROVIDER, "${usage.entityName}", ...) — ${parsed.error.message}`,
45
+ );
46
+ }
47
+ for (const mimeType of parsed.data.mimeTypes) {
48
+ const normalized = normalizeMimeType(mimeType);
49
+ const existing = byMime.get(normalized);
50
+ if (existing && existing.name !== usage.entityName) {
51
+ throw new Error(
52
+ `document-ingest-foundation: providers "${existing.name}" and "${usage.entityName}" both claim mimeType ` +
53
+ `"${normalized}" — each mimeType may have exactly one registered provider.`,
54
+ );
55
+ }
56
+ byMime.set(normalized, { name: usage.entityName, maxFileBytes: parsed.data.maxFileBytes });
57
+ }
58
+ }
59
+ return byMime;
60
+ }
61
+
62
+ export function listIngestibleMimeTypes(registry: Registry): readonly string[] {
63
+ const usages = registry.getExtensionUsages(EXT_DOCUMENT_INGEST_PROVIDER);
64
+ return [...resolveDocumentIngestProviders(usages).keys()].sort();
65
+ }
66
+
67
+ export type DocumentIngestProviderJobTrigger = {
68
+ readonly on: typeof DOCUMENT_INGEST_REQUESTED_EVENT_QN;
69
+ readonly where: { readonly provider: string };
70
+ };
71
+
72
+ // The trigger every provider's job must use — filters the shared
73
+ // documentIngest.requested QN down to just this provider's requests.
74
+ export function documentIngestProviderTrigger(
75
+ providerName: string,
76
+ ): DocumentIngestProviderJobTrigger {
77
+ return { on: DOCUMENT_INGEST_REQUESTED_EVENT_QN, where: { provider: providerName } };
78
+ }
@@ -10,13 +10,9 @@
10
10
  // (kumiko-framework#1495). The `pages` ciphertext dies separately when the
11
11
  // pipeline's later `subject-keys` stage erases the tenant subject key.
12
12
 
13
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
14
13
  import { createSystemUser, type TenantDataDestroyHook } from "@cosmicdrift/kumiko-framework/engine";
15
- import { documentExtractEntity, documentExtractsTable } from "./entity";
16
-
17
- const executor = createEventStoreExecutor(documentExtractsTable, documentExtractEntity, {
18
- entityName: "document-extract",
19
- });
14
+ import { documentExtractsTable } from "./entity";
15
+ import { documentExtractExecutor } from "./executor";
20
16
 
21
17
  export const documentExtractTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
22
18
  const rows = await ctx.db.selectMany<{ id: string }>(documentExtractsTable, {
@@ -24,7 +20,7 @@ export const documentExtractTenantDestroyHook: TenantDataDestroyHook = async (ct
24
20
  });
25
21
  const user = createSystemUser(ctx.tenantId);
26
22
  for (const row of rows) {
27
- const result = await executor.forget({ id: row.id }, user, ctx.db);
23
+ const result = await documentExtractExecutor.forget({ id: row.id }, user, ctx.db);
28
24
  // Executor writes return {isSuccess:false} instead of throwing — a
29
25
  // discarded result would report this destroy stage "succeeded" while the
30
26
  // extracted document text survives. Throw so the pipeline's retry/abandon
@@ -0,0 +1,48 @@
1
+ // Providers write documentExtract rows themselves (their own executor
2
+ // call on ctx.db) rather than through a request/response API, so this is
3
+ // the one place that write must go through to stay race-safe against a
4
+ // concurrent fileRef delete/forget — see forget-extract-with-file-ref.ts.
5
+
6
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
7
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
9
+ import type { DocumentExtractMeta, IngestPage } from "./entity";
10
+ import { documentExtractExecutor } from "./executor";
11
+ import { isFileRefLive } from "./file-ref-liveness";
12
+ import { writeIngestPages } from "./pages";
13
+
14
+ export type DocumentExtractWriteResult =
15
+ | { readonly kind: "written"; readonly documentExtractId: string }
16
+ | { readonly kind: "skipped"; readonly reason: "file_ref_deleted" };
17
+
18
+ export async function writeDocumentExtractForLiveFileRef(input: {
19
+ readonly tenantDb: TenantDb;
20
+ readonly actor: SessionUser;
21
+ readonly fileRefId: string;
22
+ readonly storageKey: string;
23
+ readonly pages: readonly IngestPage[];
24
+ readonly meta: DocumentExtractMeta & Readonly<Record<string, unknown>>;
25
+ }): Promise<DocumentExtractWriteResult> {
26
+ // Early-skip optimization only — a fileRef deleted/forgotten right after
27
+ // this check still gets caught by forget-extract-with-file-ref.ts's
28
+ // documentExtract.created handler, which is the actual race-safety guarantee.
29
+ if (!(await isFileRefLive(input.tenantDb, input.fileRefId))) {
30
+ return { kind: "skipped", reason: "file_ref_deleted" };
31
+ }
32
+ const result = await documentExtractExecutor.create(
33
+ {
34
+ fileRefId: input.fileRefId,
35
+ storageKey: input.storageKey,
36
+ pages: writeIngestPages(input.pages),
37
+ meta: input.meta,
38
+ },
39
+ input.actor,
40
+ input.tenantDb,
41
+ );
42
+ if (!result.isSuccess) {
43
+ throw new InternalError({
44
+ message: `document-ingest-foundation: failed to write documentExtract for fileRef ${input.fileRefId}: ${result.error.message}`,
45
+ });
46
+ }
47
+ return { kind: "written", documentExtractId: String(result.data.id) };
48
+ }
@@ -22,6 +22,7 @@ import {
22
22
  createTestUser,
23
23
  setupTestStack,
24
24
  type TestStack,
25
+ TestUsers,
25
26
  testTenantId,
26
27
  } from "@cosmicdrift/kumiko-framework/stack";
27
28
  import {
@@ -31,6 +32,7 @@ import {
31
32
  import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
32
33
  import { createConfigFeature } from "../../config";
33
34
  import { fileFoundationFeature } from "../../file-foundation";
35
+ import { createTenantFeature, TenantHandlers } from "../../tenant";
34
36
  import { createFileDerivativesFeature } from "../feature";
35
37
  import type { DerivativePublicPredicateArgs } from "../handlers/public-variant.query";
36
38
  import { PUBLIC_VARIANT_BY_FILE_REF_QN } from "../handlers/public-variant-by-file-ref.query";
@@ -94,6 +96,7 @@ describe("file-derivatives :: publicTenantResolution 'fileRef' — cross-tenant
94
96
  stack = await setupTestStack({
95
97
  features: [
96
98
  createConfigFeature(),
99
+ createTenantFeature(),
97
100
  fileFoundationFeature,
98
101
  createFilesFeature(),
99
102
  createFileDerivativesFeature({
@@ -104,6 +107,16 @@ describe("file-derivatives :: publicTenantResolution 'fileRef' — cross-tenant
104
107
  ],
105
108
  files: { storageProvider: createInMemoryFileProvider() },
106
109
  });
110
+ await stack.http.writeOk(
111
+ TenantHandlers.create,
112
+ { id: TENANT_A, key: "cross-tenant-a", name: "Cross Tenant A" },
113
+ TestUsers.systemAdmin,
114
+ );
115
+ await stack.http.writeOk(
116
+ TenantHandlers.create,
117
+ { id: TENANT_B, key: "cross-tenant-b", name: "Cross Tenant B" },
118
+ TestUsers.systemAdmin,
119
+ );
107
120
  });
108
121
 
109
122
  afterAll(async () => {
@@ -169,6 +182,33 @@ describe("file-derivatives :: publicTenantResolution 'fileRef' — cross-tenant
169
182
  test("PUBLIC_VARIANT_BY_FILE_REF_QN is registered on the stack in 'fileRef' mode", () => {
170
183
  expect(stack.registry.getQueryHandler(PUBLIC_VARIANT_BY_FILE_REF_QN)).toBeDefined();
171
184
  });
185
+
186
+ // Runs last and deliberately leaves tenant B disabled — mirrors the
187
+ // "disabled tenant" ordering convention in
188
+ // tenant/__tests__/multi-tenant.integration.test.ts.
189
+ describe("a disabled tenant answers like an unknown fileRef", () => {
190
+ test("tenant B's public variant 200s before disable, then 404s identically to an unknown fileRef after disable", async () => {
191
+ const fileId = await uploadImage(stack, userB, "public-1");
192
+ const beforeRes = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
193
+ expect(beforeRes.status).toBe(200);
194
+
195
+ const disableResult = await stack.http.writeOk(
196
+ TenantHandlers.disable,
197
+ { id: TENANT_B },
198
+ TestUsers.systemAdmin,
199
+ );
200
+ expect(disableResult).toBeTruthy();
201
+
202
+ const afterRes = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
203
+ const unknownFileRefRes = await stack.app.request(
204
+ `http://${HOST_A}/media/00000000-0000-4000-8000-000000000000/thumb`,
205
+ );
206
+
207
+ expect(afterRes.status).toBe(404);
208
+ expect(unknownFileRefRes.status).toBe(404);
209
+ expect(await afterRes.text()).toBe(await unknownFileRefRes.text());
210
+ });
211
+ });
172
212
  });
173
213
 
174
214
  describe("file-derivatives :: default ('host') mode is unchanged", () => {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.313.0",
4
+ "type": "fix",
5
+ "title": "file-derivatives fileRef mode now 404s a disabled/destroy-lifecycle tenant like an unknown fileRef",
6
+ "detail": "publicTenantResolution:\"fileRef\" resolved a variant's tenant straight off\nthe FileRef row, without checking whether that tenant was still enabled or\npast `active` in its destroy lifecycle — a disabled or destroy-requested\ntenant's public variants stayed reachable through the shared host. The\nby-fileRef handler now also loads the FileRef-tenant's row and, via the\nnew `isTenantServingPublicContent` predicate (isEnabled && status ===\n\"active\"), 404s identically to an unknown fileRef when it isn't serving.\n`publicTenantResolution: \"fileRef\"` now requires the `tenant` feature to\nbe mounted (fails boot otherwise) — no migration needed for apps that\nalready mount `tenant`, which every fileRef-mode consumer does today."
7
+ },
2
8
  {
3
9
  "version": "0.310.0",
4
10
  "type": "improvement",
@@ -160,6 +160,11 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
160
160
  // would expose its unsafeRaw, cross-tenant FileRef lookup via the
161
161
  // generic `/api` query dispatch even in default "host" mode.
162
162
  if (byFileRef) {
163
+ // The handler reads the FileRef-tenant's row to keep a blocked
164
+ // tenant's public variants from staying reachable — require the
165
+ // dependency so a missing mount fails boot, not the tenant check
166
+ // silently no-op-ing at request time.
167
+ r.requires("tenant");
163
168
  r.queryHandler(publicVariantByFileRefQuery);
164
169
  }
165
170
 
@@ -6,14 +6,24 @@ import {
6
6
  type TenantId,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
9
+ import {
10
+ isTenantServingPublicContent,
11
+ type TenantLifecycleStatus,
12
+ tenantTable,
13
+ } from "../../tenant";
9
14
  import { PUBLIC_VARIANT_QN, publicVariantPayloadSchema } from "./public-variant.query";
10
15
 
11
16
  type FileRefTenantRow = {
12
17
  readonly tenantId: TenantId;
13
18
  };
14
19
 
20
+ type TenantGateRow = {
21
+ readonly isEnabled: boolean;
22
+ readonly status: TenantLifecycleStatus;
23
+ };
24
+
15
25
  const BY_FILE_REF_ESCAPE_HATCH_REASON =
16
- "shared public host serves every tenant's public variants; the FileRef row names the tenant, and publicVariantQuery's isPublic gate still runs inside that tenant via queryAs";
26
+ "shared public host serves every tenant's public variants; the FileRef row names the tenant, and publicVariantQuery's isPublic gate still runs inside that tenant via queryAs — reading the FileRef's tenant row (isEnabled/status) first so a blocked tenant 404s identically to an unknown fileRef";
17
27
 
18
28
  // Full QN this handler is registered under, mirrors PUBLIC_VARIANT_QN.
19
29
  export const PUBLIC_VARIANT_BY_FILE_REF_QN = "file-derivatives:query:public-variant-by-file-ref";
@@ -28,13 +38,20 @@ export const publicVariantByFileRefQuery = defineQueryHandler({
28
38
  reason: BY_FILE_REF_ESCAPE_HATCH_REASON,
29
39
  },
30
40
  handler: async (query, ctx) => {
31
- const row = await fetchOne<FileRefTenantRow>(
32
- ctx.db.unsafeRaw(BY_FILE_REF_ESCAPE_HATCH_REASON),
33
- fileRefsTable,
34
- { id: query.payload.fileRefId, isDeleted: false },
35
- );
41
+ const db = ctx.db.unsafeRaw(BY_FILE_REF_ESCAPE_HATCH_REASON);
42
+ const row = await fetchOne<FileRefTenantRow>(db, fileRefsTable, {
43
+ id: query.payload.fileRefId,
44
+ isDeleted: false,
45
+ });
36
46
  if (!row || !isUuid(row.tenantId)) return null;
37
47
 
48
+ // A blocked tenant (disabled, or past active in its destroy lifecycle)
49
+ // must 404 identically to an unknown fileRef — otherwise this shared
50
+ // public host would keep serving a tenant's public variants after it
51
+ // was disabled or asked to be destroyed.
52
+ const tenant = await fetchOne<TenantGateRow>(db, tenantTable, { id: row.tenantId });
53
+ if (!tenant || !isTenantServingPublicContent(tenant)) return null;
54
+
38
55
  return ctx.queryAs(createAnonymousUser(row.tenantId), PUBLIC_VARIANT_QN, query.payload);
39
56
  },
40
57
  });
@@ -25,6 +25,7 @@ export {
25
25
  redeemRowBoundGrant,
26
26
  signRowBoundGrant,
27
27
  } from "./row-bound-grant";
28
+ export { runInSubTransaction } from "./run-in-sub-transaction";
28
29
  export { sessionField } from "./session-field";
29
30
  export { sessionLocaleField } from "./session-locale-field";
30
31
  export { sessionTimezoneField } from "./session-timezone-field";
@@ -35,6 +36,14 @@ export {
35
36
  type VerifyResult,
36
37
  verifyToken,
37
38
  } from "./signed-token";
38
- export { createSingleUseTokenStore } from "./single-use-token-store";
39
+ export {
40
+ EXT_SIGNUP_HANDOVER,
41
+ findSignupHandoverProvider,
42
+ isSignupHandoverProvider,
43
+ SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE,
44
+ type SignupHandoverBinding,
45
+ type SignupHandoverProvider,
46
+ } from "./signup-handover";
47
+ export { createSingleUseTokenStore, hashSingleUseToken } from "./single-use-token-store";
39
48
  export type { SystemQueryFn } from "./system-query";
40
49
  export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
@@ -0,0 +1,35 @@
1
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+
3
+ // Per-unit sub-transaction (one user, one purged row), nesting-aware across both db shapes:
4
+ // - top-level connection (Bun.SQL / postgres-js Sql) → `.begin` (BEGIN)
5
+ // - TransactionSql (inside the dispatcher's outer tx, where every
6
+ // writeHandler already runs) → `.savepoint` (SAVEPOINT)
7
+ // A TransactionSql has no `.begin`, so the previous unconditional `.begin`
8
+ // threw "is not a function" on every user when invoked through the dispatcher
9
+ // (the cron path) → zero deletions in production, while direct-connection tests
10
+ // stayed green. Selecting the available method makes the sub-tx work in both
11
+ // contexts; on throw the savepoint rolls back just this user (others survive).
12
+ export async function runInSubTransaction<T>(
13
+ db: DbRunner,
14
+ fn: (tx: DbRunner) => Promise<T>,
15
+ ): Promise<T> {
16
+ // `db` is already the raw runner (the handler passes ctx.db.unsafeRaw(...),
17
+ // the tests a top-level connection) — cast to read the transaction surface directly,
18
+ // without asRawClient (a test-only escape hatch).
19
+ const runner = db as {
20
+ begin?: (f: (tx: DbRunner) => Promise<T>) => Promise<T>;
21
+ savepoint?: (f: (tx: DbRunner) => Promise<T>) => Promise<T>;
22
+ };
23
+ // savepoint-first: empirically (Bun 1.3.14, not re-checked on 1.4.0) the
24
+ // surfaces are NOT mutually exclusive — a TransactionSql exposes begin AND
25
+ // savepoint, only the top-level connection has begin alone. begin-first
26
+ // picked the nested BEGIN inside a tx (the incident class above);
27
+ // savepoint-first hits the savepoint there and falls back to begin top-level.
28
+ const open = runner.savepoint ?? runner.begin;
29
+ if (!open) {
30
+ throw new Error(
31
+ "runInSubTransaction: db exposes neither .begin nor .savepoint — cannot open a sub-transaction",
32
+ );
33
+ }
34
+ return open.call(runner, fn);
35
+ }
@@ -0,0 +1,67 @@
1
+ // Cross-device try-first handover (kumiko-framework#3035 follow-up,
2
+ // offlot-app#454): signup-request runs in whichever browser the activation
3
+ // link ends up opened in, which may not be the one holding the anonymous
4
+ // visitor's tenant-handover grant. This extension point lets
5
+ // auth-email-password bind a verified grant to the signup token server-side
6
+ // — without knowing anything about tenant-handover's row-bound-grant
7
+ // mechanics — so signup-confirm can claim the data into the freshly
8
+ // provisioned tenant regardless of which browser it runs in.
9
+ //
10
+ // The grant itself never crosses this boundary: verifyGrant returns only
11
+ // the { entityType, rowId, sourceTenantId } binding, and mintClaim returns a
12
+ // freshly signed short-lived token rather than handing back the original.
13
+
14
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
15
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
16
+
17
+ export const EXT_SIGNUP_HANDOVER = "signupHandover" as const;
18
+
19
+ // Part of the contract: a provider's minted claim write may reject two
20
+ // different ways. Benign — this code, nothing was written (forged,
21
+ // expired, or already spent) — a caller redeeming the claim nested in its
22
+ // own write can log and carry on. Anything else means the provider's write
23
+ // already applied a PARTIAL change before failing (e.g. a root row moved
24
+ // but a declared child couldn't) — the caller must roll its own work back
25
+ // together with it, never treat that as a skippable no-op.
26
+ export const SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE = "invalid_or_expired_grant" as const;
27
+
28
+ export type SignupHandoverBinding = {
29
+ readonly entityType: string;
30
+ readonly rowId: string;
31
+ readonly sourceTenantId: string;
32
+ };
33
+
34
+ export type SignupHandoverProvider = {
35
+ verifyGrant(args: {
36
+ readonly db: DbRunner;
37
+ readonly registry: Registry;
38
+ readonly entityType: string;
39
+ readonly token: string;
40
+ }): Promise<SignupHandoverBinding | null>;
41
+ mintClaim(binding: SignupHandoverBinding): {
42
+ readonly qualifiedName: string;
43
+ readonly payload: { readonly token: string; readonly entityType: string };
44
+ };
45
+ };
46
+
47
+ export function isSignupHandoverProvider(options: unknown): options is SignupHandoverProvider {
48
+ return (
49
+ typeof options === "object" &&
50
+ options !== null &&
51
+ typeof (options as Record<string, unknown>)["verifyGrant"] === "function" &&
52
+ typeof (options as Record<string, unknown>)["mintClaim"] === "function"
53
+ );
54
+ }
55
+
56
+ export function findSignupHandoverProvider(registry: Registry): SignupHandoverProvider | undefined {
57
+ for (const usage of registry.getExtensionUsages(EXT_SIGNUP_HANDOVER)) {
58
+ if (isSignupHandoverProvider(usage.options)) return usage.options;
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ declare module "@cosmicdrift/kumiko-framework/engine" {
64
+ interface KumikoExtensionOptionsMap {
65
+ [EXT_SIGNUP_HANDOVER]: SignupHandoverProvider;
66
+ }
67
+ }
@@ -49,7 +49,10 @@
49
49
  import { createHash } from "node:crypto";
50
50
  import type Redis from "ioredis";
51
51
 
52
- function hashToken(token: string): string {
52
+ // Exported so a caller that needs a byte-compatible key in a sibling Redis
53
+ // namespace (e.g. a token↔binding side-mapping) can derive it without
54
+ // re-implementing sha256-of-token — see auth-email-password/signup-token-store.ts.
55
+ export function hashSingleUseToken(token: string): string {
53
56
  return createHash("sha256").update(token).digest("hex");
54
57
  }
55
58
 
@@ -59,7 +62,7 @@ export function createSingleUseTokenStore(prefixes: {
59
62
  readonly burnPrefix: string;
60
63
  }) {
61
64
  function tokenKey(token: string): string {
62
- return `${prefixes.tokenPrefix}${hashToken(token)}`;
65
+ return `${prefixes.tokenPrefix}${hashSingleUseToken(token)}`;
63
66
  }
64
67
  // Builds the forward key from an already-hashed value (e.g. read back
65
68
  // from the by-subject entry) — does NOT hash again. Kept separate from
@@ -72,7 +75,7 @@ export function createSingleUseTokenStore(prefixes: {
72
75
  return `${prefixes.subjectPrefix}${subjectId}`;
73
76
  }
74
77
  function burnKey(token: string): string {
75
- return `${prefixes.burnPrefix}${hashToken(token)}`;
78
+ return `${prefixes.burnPrefix}${hashSingleUseToken(token)}`;
76
79
  }
77
80
 
78
81
  // Stores the pair bidirectionally and sets TTL on both keys. Idempotent —
@@ -84,7 +87,7 @@ export function createSingleUseTokenStore(prefixes: {
84
87
  ): Promise<void> {
85
88
  await Promise.all([
86
89
  redis.set(tokenKey(args.token), args.subjectId, "EX", args.ttlSeconds),
87
- redis.set(subjectKey(args.subjectId), hashToken(args.token), "EX", args.ttlSeconds),
90
+ redis.set(subjectKey(args.subjectId), hashSingleUseToken(args.token), "EX", args.ttlSeconds),
88
91
  ]);
89
92
  }
90
93
 
@@ -132,5 +135,22 @@ export function createSingleUseTokenStore(prefixes: {
132
135
  await redis.del(burnKey(token));
133
136
  }
134
137
 
135
- return { store, getSubjectForToken, invalidateExistingBySubject, burn, deleteBoth, unburn };
138
+ // The by-subject entry's raw value — a token hash, not a token (see file
139
+ // header) — for a caller that needs to key a SIBLING Redis namespace off
140
+ // the same live token without recovering it (e.g. a resend that must
141
+ // carry a side-binding over to the fresh token before the old one is
142
+ // invalidated).
143
+ async function getTokenHashForSubject(redis: Redis, subjectId: string): Promise<string | null> {
144
+ return redis.get(subjectKey(subjectId));
145
+ }
146
+
147
+ return {
148
+ store,
149
+ getSubjectForToken,
150
+ invalidateExistingBySubject,
151
+ burn,
152
+ deleteBoth,
153
+ unburn,
154
+ getTokenHashForSubject,
155
+ };
136
156
  }
@@ -143,9 +143,53 @@ describe("createStripeCheckoutSession", () => {
143
143
  payment_intent_data: {
144
144
  metadata: { tenantId: "tenant-003" },
145
145
  },
146
+ // Default-on: without the runtime option, mode:"payment" gets a
147
+ // Stripe invoice.
148
+ invoice_creation: { enabled: true },
146
149
  });
147
150
  });
148
151
 
152
+ test("mode='payment' + paymentInvoiceCreation:false: invoice_creation.enabled is false", async () => {
153
+ const stripe = buildStripe();
154
+ const createMock = spyOn(stripe.checkout.sessions, "create")
155
+ // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
156
+ .mockResolvedValue({ url: "https://checkout.stripe.com/c/pay/topup" } as any);
157
+
158
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe), {
159
+ paymentInvoiceCreation: false,
160
+ });
161
+ await checkout(stubCtx, {
162
+ priceId: "price_credits_topup",
163
+ tenantId: "tenant-003",
164
+ successUrl: "https://example.com/success",
165
+ cancelUrl: "https://example.com/cancel",
166
+ mode: "payment",
167
+ });
168
+
169
+ expect(createMock).toHaveBeenCalledWith(
170
+ expect.objectContaining({ invoice_creation: { enabled: false } }),
171
+ );
172
+ });
173
+
174
+ test("mode='subscription': NIE invoice_creation — Stripe rejects es außerhalb payment-mode", async () => {
175
+ const stripe = buildStripe();
176
+ const createMock = spyOn(stripe.checkout.sessions, "create")
177
+ // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
178
+ .mockResolvedValue({ url: "https://x" } as any);
179
+
180
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
181
+ await checkout(stubCtx, {
182
+ priceId: "price_x",
183
+ tenantId: "tenant-005",
184
+ successUrl: "https://x/s",
185
+ cancelUrl: "https://x/c",
186
+ mode: "subscription",
187
+ });
188
+
189
+ const callArgs = createMock.mock.calls[0]?.[0] as Record<string, unknown>;
190
+ expect(callArgs).not.toHaveProperty("invoice_creation");
191
+ });
192
+
149
193
  test("mode omitted defaults to 'subscription' (Regression-Pin für bestehende Aufrufer)", async () => {
150
194
  const stripe = buildStripe();
151
195
  const createMock = spyOn(stripe.checkout.sessions, "create")