@cosmicdrift/kumiko-bundled-features 0.312.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 (63) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/extension-points-typed.test.ts +4 -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/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
  29. package/src/file-derivatives/changes.json +6 -0
  30. package/src/file-derivatives/feature.ts +5 -0
  31. package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
  32. package/src/shared/index.ts +10 -1
  33. package/src/shared/run-in-sub-transaction.ts +35 -0
  34. package/src/shared/signup-handover.ts +67 -0
  35. package/src/shared/single-use-token-store.ts +25 -5
  36. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
  37. package/src/subscription-stripe/changes.json +9 -1
  38. package/src/subscription-stripe/feature.ts +13 -1
  39. package/src/subscription-stripe/plugin-methods.ts +17 -2
  40. package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
  41. package/src/tenant/changes.json +6 -0
  42. package/src/tenant/index.ts +1 -0
  43. package/src/tenant/is-tenant-serving-public-content.ts +13 -0
  44. package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
  45. package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
  46. package/src/tenant/web/member-roles-cell.tsx +11 -2
  47. package/src/tenant/web/member-status-cell.tsx +6 -2
  48. package/src/tenant/web/translate-or-raw.ts +13 -0
  49. package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
  50. package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
  51. package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
  52. package/src/tenant-handover/changes.json +12 -0
  53. package/src/tenant-handover/events.ts +6 -0
  54. package/src/tenant-handover/feature.ts +11 -0
  55. package/src/tenant-handover/handlers/claim.write.ts +8 -25
  56. package/src/tenant-handover/move-entity-graph.ts +30 -1
  57. package/src/tenant-handover/root-anchor.ts +50 -0
  58. package/src/tenant-handover/signup-handover-provider.ts +62 -0
  59. package/src/tenant-handover/transfer-graph.ts +6 -2
  60. package/src/user-data-rights/changes.json +6 -0
  61. package/src/user-data-rights/feature.ts +1 -2
  62. package/src/user-data-rights/i18n.ts +3 -0
  63. package/src/user-data-rights/run-forget-cleanup.ts +1 -38
@@ -14,9 +14,11 @@ import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipelin
14
14
  import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
15
15
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
16
16
  import type { Redis } from "ioredis";
17
+ import { hashUnsubscribeAddress } from "./address-opt-out";
17
18
  import { appendAttemptEvent, logAttempt } from "./attempt-log";
18
19
  import { buildChannelContext } from "./channel-context";
19
20
  import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants";
21
+ import { isAddressOptedOut } from "./db/queries/address-opt-outs";
20
22
  import { selectNotificationPreferences } from "./db/queries/preferences";
21
23
  import {
22
24
  type ChannelContext,
@@ -479,6 +481,36 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
479
481
  const message = buildMessage(notificationType, data, channel.name);
480
482
  if (!address) continue;
481
483
 
484
+ // Address opt-out (critical priority skips it, same rule as user
485
+ // preferences). No blind-index key configured → no hash → nothing to
486
+ // look up, skip the check instead of failing the send.
487
+ if (priority !== "critical") {
488
+ const addressHash = hashUnsubscribeAddress(address);
489
+ if (addressHash !== undefined) {
490
+ const optedOut = await isAddressOptedOut(
491
+ db,
492
+ tenantId,
493
+ addressHash,
494
+ notificationType,
495
+ channel.name,
496
+ );
497
+ if (optedOut) {
498
+ await logDelivery({
499
+ tenantId,
500
+ notificationType,
501
+ channel: channel.name,
502
+ recipientId,
503
+ // The recipient withdrew — suppressed attempts must not keep recording the address.
504
+ recipientAddress: null,
505
+ status: "skipped",
506
+ error: "unsubscribed",
507
+ priority,
508
+ });
509
+ continue;
510
+ }
511
+ }
512
+ }
513
+
482
514
  if (rateLimit) {
483
515
  const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
484
516
  if (!allowed) {
@@ -23,6 +23,8 @@ import { deliveryRenderJob, deliverySendJob } from "./jobs";
23
23
  import {
24
24
  deliveryAttemptsTable,
25
25
  deliveryAttemptsTableMeta,
26
+ notificationAddressOptOutEntity,
27
+ notificationAddressOptOutsTable,
26
28
  notificationPreferenceEntity,
27
29
  notificationPreferencesTable,
28
30
  } from "./tables";
@@ -40,7 +42,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
40
42
  const resolvedAccess = options?.access ?? access.admin;
41
43
  return defineFeature("delivery", (r) => {
42
44
  r.describe(
43
- "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
45
+ "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
44
46
  );
45
47
  r.uiHints({
46
48
  displayLabel: "Notifications \u00b7 Dispatch Core",
@@ -56,6 +58,12 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
56
58
  r.entity("notification-preference", notificationPreferenceEntity, {
57
59
  table: notificationPreferencesTable,
58
60
  });
61
+ // Same ride-along reason as notification-preference above: the
62
+ // (tenant,addressHash,type,channel) uniqueIndex lives only on the
63
+ // physical table.
64
+ r.entity("notification-address-opt-out", notificationAddressOptOutEntity, {
65
+ table: notificationAddressOptOutsTable,
66
+ });
59
67
  r.storeTable(deliveryAttemptsTableMeta, {
60
68
  reason: "read_side.delivery_attempt_log",
61
69
  });
@@ -1,3 +1,4 @@
1
+ export { hashUnsubscribeAddress } from "./address-opt-out";
1
2
  export type { DeliveryStatusValue } from "./constants";
2
3
  export {
3
4
  DELIVERY_CHANNEL_EXTENSION,
@@ -19,6 +20,8 @@ export {
19
20
  export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature";
20
21
  export {
21
22
  deliveryAttemptsTable,
23
+ notificationAddressOptOutEntity,
24
+ notificationAddressOptOutsTable,
22
25
  notificationPreferenceEntity,
23
26
  notificationPreferencesTable,
24
27
  } from "./tables";
@@ -38,7 +41,9 @@ export {
38
41
  type RendererInput,
39
42
  } from "./types";
40
43
  export {
44
+ type AddressUnsubscribeTokenPayload,
41
45
  createUnsubscribeRoute,
46
+ signAddressUnsubscribeToken,
42
47
  signUnsubscribeToken,
43
48
  type UnsubscribeRouteOptions,
44
49
  type UnsubscribeTokenPayload,
@@ -105,3 +105,40 @@ export const notificationPreferencesTable = pgTable(
105
105
  ),
106
106
  ],
107
107
  );
108
+
109
+ // Opt-out for a recipient ADDRESS that has no user account (direct sends via
110
+ // `ctx.notify(type, { route: { email } })`). addressHash is a keyed HMAC
111
+ // (computeBlindIndex over the normalized address) — the plaintext address
112
+ // never reaches this entity or the token that unsubscribes it.
113
+ // A row's mere existence is the opt-out; there is no `enabled` toggle to flip
114
+ // back (re-subscribing a no-account address has no signed-in flow to do it from).
115
+ export const notificationAddressOptOutEntity = createEntity({
116
+ table: "read_notification_address_opt_outs",
117
+ fields: {
118
+ addressHash: createTextField({ required: true, personal: false, reason: "keyed_hash" }),
119
+ notificationType: createTextField({
120
+ required: true,
121
+ personal: false,
122
+ reason: "technical_reference",
123
+ }),
124
+ channel: createTextField({ required: true, personal: false, reason: "technical_reference" }),
125
+ },
126
+ });
127
+
128
+ export const notificationAddressOptOutsTable = pgTable(
129
+ "read_notification_address_opt_outs",
130
+ {
131
+ ...buildBaseColumns(false, "uuid"),
132
+ addressHash: text("address_hash").notNull(),
133
+ notificationType: text("notification_type").notNull(),
134
+ channel: text("channel").notNull(),
135
+ },
136
+ (table) => [
137
+ uniqueIndex("read_notification_address_opt_outs_unique").on(
138
+ table.tenantId,
139
+ table.addressHash,
140
+ table.notificationType,
141
+ table.channel,
142
+ ),
143
+ ],
144
+ );
@@ -3,17 +3,38 @@ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { Hono } from "hono";
4
4
  import * as jose from "jose";
5
5
  import * as z from "zod";
6
+ import { hashUnsubscribeAddress, upsertAddressOptOut } from "./address-opt-out";
6
7
  import { upsertPreference } from "./upsert-preference";
7
8
 
8
9
  // Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
9
10
  // wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
11
+ //
12
+ // `kind` is explicitly typed as "absent or undefined, nothing else" here
13
+ // (not bare `.optional()`, which would also accept any other value on an
14
+ // unrelated key) so an address-token payload — which always carries
15
+ // `kind: "address"` — fails this schema instead of silently parsing with
16
+ // `addressHash` mistaken for a userId.
10
17
  const unsubscribeJwtPayloadSchema = z.object({
18
+ kind: z.undefined().optional(),
11
19
  sub: z.string().min(1),
12
20
  tenantId: z.string().min(1),
13
21
  notificationType: z.string().min(1),
14
22
  channel: z.string().min(1),
15
23
  });
16
24
 
25
+ // Address-token payload. `sub` and `addressHash` are the same value (the
26
+ // subject claim IS the blind-index hash) — kept as two fields because the
27
+ // subject claim is jose's own mechanism while `addressHash` is what the
28
+ // route actually writes; the handler cross-checks them below.
29
+ const addressUnsubscribeJwtPayloadSchema = z.object({
30
+ kind: z.literal("address"),
31
+ sub: z.string().min(1),
32
+ tenantId: z.string().min(1),
33
+ notificationType: z.string().min(1),
34
+ channel: z.string().min(1),
35
+ addressHash: z.string().min(1),
36
+ });
37
+
17
38
  export type UnsubscribeTokenPayload = {
18
39
  readonly userId: string;
19
40
  readonly tenantId: TenantId;
@@ -21,6 +42,13 @@ export type UnsubscribeTokenPayload = {
21
42
  readonly channel: string;
22
43
  };
23
44
 
45
+ export type AddressUnsubscribeTokenPayload = {
46
+ readonly tenantId: TenantId;
47
+ readonly address: string;
48
+ readonly notificationType: string;
49
+ readonly channel: string;
50
+ };
51
+
24
52
  export type UnsubscribeRouteOptions = {
25
53
  readonly db: DbConnection;
26
54
  readonly jwtSecret: string;
@@ -51,6 +79,45 @@ export async function signUnsubscribeToken(
51
79
  .sign(encodedSecret);
52
80
  }
53
81
 
82
+ /**
83
+ * Signs an unsubscribe link for a recipient ADDRESS with no user account
84
+ * (direct sends via `ctx.notify(type, { route: { email } })`). Throws when
85
+ * no blind-index key is configured — fail closed, since without a key the
86
+ * opt-out row this token later writes could never be matched against a
87
+ * future send (the hash wouldn't be reproducible).
88
+ */
89
+ export async function signAddressUnsubscribeToken(
90
+ payload: AddressUnsubscribeTokenPayload,
91
+ secret: string,
92
+ ): Promise<string> {
93
+ const addressHash = hashUnsubscribeAddress(payload.address);
94
+ if (addressHash === undefined) {
95
+ throw new Error(
96
+ "signAddressUnsubscribeToken requires a configured blind-index key (configureBlindIndexKey)",
97
+ );
98
+ }
99
+ const encodedSecret = new TextEncoder().encode(secret);
100
+ return (
101
+ new jose.SignJWT({
102
+ tenantId: payload.tenantId,
103
+ notificationType: payload.notificationType,
104
+ channel: payload.channel,
105
+ addressHash,
106
+ kind: "address",
107
+ })
108
+ .setProtectedHeader({ alg: "HS256" })
109
+ .setSubject(addressHash)
110
+ .setIssuer("kumiko:unsubscribe")
111
+ .setIssuedAt()
112
+ // No expiry, unlike the user token above: a leaked address token can
113
+ // only opt that one address out of one notificationType/channel, and
114
+ // an unsubscribe link mailed out today must keep working indefinitely
115
+ // — there is no signed-in flow for a no-account address to re-subscribe
116
+ // or request a fresh link from.
117
+ .sign(encodedSecret)
118
+ );
119
+ }
120
+
54
121
  export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): Hono {
55
122
  const { db, jwtSecret } = options;
56
123
  const encodedSecret = new TextEncoder().encode(jwtSecret);
@@ -62,34 +129,52 @@ export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): Hono {
62
129
  return c.text("Missing token", 400);
63
130
  }
64
131
 
65
- let userId: string;
66
- let tenantId: TenantId;
67
- let notificationType: string;
68
- let channel: string;
132
+ let verifiedPayload: jose.JWTPayload;
69
133
  try {
70
- const { payload } = await jose.jwtVerify(token, encodedSecret, {
134
+ ({ payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
71
135
  issuer: "kumiko:unsubscribe",
72
- });
73
- const parsed = unsubscribeJwtPayloadSchema.parse(payload);
74
- userId = parsed.sub;
75
- // @cast-boundary engine-bridge — string post-zod → branded TenantId
76
- tenantId = parsed.tenantId as TenantId;
77
- notificationType = parsed.notificationType;
78
- channel = parsed.channel;
136
+ }));
79
137
  } catch {
80
138
  return c.text("Invalid or expired token", 400);
81
139
  }
82
140
 
141
+ const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
142
+ if (addressAttempt.success) {
143
+ const parsed = addressAttempt.data;
144
+ if (parsed.sub !== parsed.addressHash) {
145
+ return c.text("Invalid or expired token", 400);
146
+ }
147
+ // @cast-boundary engine-bridge — string post-zod → branded TenantId
148
+ const tenantId = parsed.tenantId as TenantId;
149
+ const actor = { id: parsed.addressHash, tenantId, roles: SYSTEM_ROLES };
150
+ const tdb = createTenantDb(db, tenantId, "system");
151
+ await upsertAddressOptOut(tdb, actor, {
152
+ tenantId,
153
+ addressHash: parsed.addressHash,
154
+ notificationType: parsed.notificationType,
155
+ channel: parsed.channel,
156
+ });
157
+ return c.text("You have been unsubscribed.", 200);
158
+ }
159
+
160
+ const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
161
+ if (!userAttempt.success) {
162
+ return c.text("Invalid or expired token", 400);
163
+ }
164
+ const parsed = userAttempt.data;
165
+ // @cast-boundary engine-bridge — string post-zod → branded TenantId
166
+ const tenantId = parsed.tenantId as TenantId;
167
+
83
168
  // Token-verify passed — everything below is a legitimate write. Don't
84
169
  // swallow write-errors as "invalid token", that would mask real bugs
85
170
  // (e.g. events-table missing, DB down) behind a misleading 400.
86
- const actor = { id: userId, tenantId, roles: SYSTEM_ROLES };
171
+ const actor = { id: parsed.sub, tenantId, roles: SYSTEM_ROLES };
87
172
  const tdb = createTenantDb(db, tenantId, "system");
88
173
  await upsertPreference(tdb, actor, {
89
174
  tenantId,
90
- userId,
91
- notificationType,
92
- channel,
175
+ userId: parsed.sub,
176
+ notificationType: parsed.notificationType,
177
+ channel: parsed.channel,
93
178
  enabled: false,
94
179
  });
95
180
  return c.text("You have been unsubscribed.", 200);
@@ -130,7 +130,7 @@ export async function upsertPreference(
130
130
  // (SQLSTATE 23505). Drivers wrap the DB error in varying envelopes; we
131
131
  // check the `code` field plus a string-match fallback so the match survives
132
132
  // minor driver-version shifts without drifting wide.
133
- function isUniqueViolation(err: unknown): boolean {
133
+ export function isUniqueViolation(err: unknown): boolean {
134
134
  if (typeof err !== "object" || err === null) return false;
135
135
  const e = err as { code?: unknown; cause?: { code?: unknown }; message?: unknown }; // @cast-boundary error-details
136
136
  if (e.code === "23505") return true;
@@ -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
  }