@cosmicdrift/kumiko-bundled-features 0.313.0 → 0.315.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 (38) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/changes.json +6 -0
  3. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +66 -0
  4. package/src/auth-email-password/web/signup-complete-screen.tsx +10 -4
  5. package/src/compliance-profiles/resolve-for-tenant.ts +15 -5
  6. package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +6 -6
  7. package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +1 -1
  8. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +10 -10
  9. package/src/data-retention/changes.json +6 -0
  10. package/src/data-retention/feature.ts +1 -1
  11. package/src/data-retention/handlers/policy-for.query.ts +14 -31
  12. package/src/data-retention/index.ts +2 -0
  13. package/src/data-retention/resolve-for-tenant.ts +25 -17
  14. package/src/data-retention/resolve-tenant-preset.ts +5 -2
  15. package/src/data-retention/run-retention-cleanup.ts +5 -4
  16. package/src/delivery/__tests__/delivery.integration.test.ts +189 -19
  17. package/src/delivery/address-opt-out.ts +49 -21
  18. package/src/delivery/changes.json +12 -0
  19. package/src/delivery/constants.ts +1 -0
  20. package/src/delivery/delivery-service.ts +48 -27
  21. package/src/delivery/feature.ts +5 -1
  22. package/src/delivery/handlers/unsubscribe-address.write.ts +40 -0
  23. package/src/delivery/handlers/unsubscribe-user.write.ts +43 -0
  24. package/src/delivery/index.ts +1 -0
  25. package/src/delivery/public-names.ts +6 -0
  26. package/src/delivery/unsubscribe.ts +144 -67
  27. package/src/delivery/upsert-preference.ts +74 -60
  28. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +1 -0
  29. package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +2 -1
  30. package/src/file-derivatives/__tests__/public-variant-predicate-args.integration.test.ts +2 -1
  31. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +4 -1
  32. package/src/shared/index.ts +1 -0
  33. package/src/shared/is-tenant-db.ts +9 -0
  34. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +5 -1
  35. package/src/user-data-rights/__tests__/retention-preset-forget.integration.test.ts +272 -0
  36. package/src/user-data-rights/run-forget-cleanup.ts +6 -1
  37. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +9 -7
  38. package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +7 -2
@@ -1,10 +1,13 @@
1
- import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
1
+ import {
2
+ type ExtraRouteDefinition,
3
+ ExtraRouteRejection,
4
+ signatureRoute,
5
+ } from "@cosmicdrift/kumiko-framework/api";
2
6
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
- import { Hono } from "hono";
4
7
  import * as jose from "jose";
5
8
  import * as z from "zod";
6
- import { hashUnsubscribeAddress, upsertAddressOptOut } from "./address-opt-out";
7
- import { upsertPreference } from "./upsert-preference";
9
+ import { hashUnsubscribeAddress } from "./address-opt-out";
10
+ import { DELIVERY_UNSUBSCRIBE_PATH, DeliveryHandlers } from "./constants";
8
11
 
9
12
  // Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
10
13
  // wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
@@ -49,22 +52,36 @@ export type AddressUnsubscribeTokenPayload = {
49
52
  readonly channel: string;
50
53
  };
51
54
 
55
+ /**
56
+ * `secret` must be a value dedicated to unsubscribe-token signing — do NOT
57
+ * reuse the app's session `JWT_SECRET`. The app mounting `extraRoutes:
58
+ * [createUnsubscribeRoute({ secret })]` signs outgoing links with the same
59
+ * value via `signUnsubscribeToken` / `signAddressUnsubscribeToken`.
60
+ */
52
61
  export type UnsubscribeRouteOptions = {
53
- readonly db: DbConnection;
54
- readonly jwtSecret: string;
62
+ readonly secret: string;
55
63
  };
56
64
 
57
65
  const UNSUBSCRIBE_EXPIRY = "7d";
58
- // The route runs outside the dispatcher — no SessionUser, no JWT middleware.
59
- // Bill the event against the token-subject (the user owns their preference)
60
- // and attribute it as a system-role action. This mirrors the way jobs and
61
- // seeds attribute their out-of-band writes.
62
- const SYSTEM_ROLES = ["system"] as const;
66
+ const MIN_UNSUBSCRIBE_SECRET_LENGTH = 32;
67
+
68
+ function assertUnsubscribeSecret(secret: string, caller: string): void {
69
+ if (secret.length < MIN_UNSUBSCRIBE_SECRET_LENGTH) {
70
+ throw new Error(
71
+ `${caller}: secret must be ≥${MIN_UNSUBSCRIBE_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
72
+ );
73
+ }
74
+ }
75
+
76
+ const UNSUBSCRIBE_TOKEN_INVALID_BODY = {
77
+ error: { code: "unsubscribe_token_invalid", message: "Invalid or expired token" },
78
+ } as const;
63
79
 
64
80
  export async function signUnsubscribeToken(
65
81
  payload: UnsubscribeTokenPayload,
66
82
  secret: string,
67
83
  ): Promise<string> {
84
+ assertUnsubscribeSecret(secret, "signUnsubscribeToken");
68
85
  const encodedSecret = new TextEncoder().encode(secret);
69
86
  return new jose.SignJWT({
70
87
  tenantId: payload.tenantId,
@@ -90,6 +107,7 @@ export async function signAddressUnsubscribeToken(
90
107
  payload: AddressUnsubscribeTokenPayload,
91
108
  secret: string,
92
109
  ): Promise<string> {
110
+ assertUnsubscribeSecret(secret, "signAddressUnsubscribeToken");
93
111
  const addressHash = hashUnsubscribeAddress(payload.address);
94
112
  if (addressHash === undefined) {
95
113
  throw new Error(
@@ -118,67 +136,126 @@ export async function signAddressUnsubscribeToken(
118
136
  );
119
137
  }
120
138
 
121
- export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): Hono {
122
- const { db, jwtSecret } = options;
123
- const encodedSecret = new TextEncoder().encode(jwtSecret);
124
- const app = new Hono();
125
-
126
- app.get("/unsubscribe", async (c) => {
127
- const token = c.req.query("token");
128
- if (!token) {
129
- return c.text("Missing token", 400);
139
+ type VerifiedUnsubscribe =
140
+ | {
141
+ readonly kind: "address";
142
+ readonly tenantId: TenantId;
143
+ readonly addressHash: string;
144
+ readonly notificationType: string;
145
+ readonly channel: string;
130
146
  }
147
+ | {
148
+ readonly kind: "user";
149
+ readonly tenantId: TenantId;
150
+ readonly userId: string;
151
+ readonly notificationType: string;
152
+ readonly channel: string;
153
+ };
131
154
 
132
- let verifiedPayload: jose.JWTPayload;
133
- try {
134
- ({ payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
135
- issuer: "kumiko:unsubscribe",
136
- }));
137
- } catch {
138
- return c.text("Invalid or expired token", 400);
139
- }
155
+ export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): ExtraRouteDefinition {
156
+ assertUnsubscribeSecret(options.secret, "createUnsubscribeRoute");
157
+ const encodedSecret = new TextEncoder().encode(options.secret);
140
158
 
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);
159
+ return signatureRoute<VerifiedUnsubscribe>({
160
+ method: "GET",
161
+ path: DELIVERY_UNSUBSCRIBE_PATH,
162
+ entry: "signature",
163
+ // Every throw here must become the same ExtraRouteRejection(400,
164
+ // unsubscribe_token_invalid) — anything else falls through to the
165
+ // framework's generic 401 extra_route_signature_invalid mapping, which
166
+ // would leak jose's internal error text into the response body.
167
+ verify: async ({ query }) => {
168
+ try {
169
+ const token = query["token"];
170
+ if (!token) {
171
+ throw new ExtraRouteRejection(
172
+ 400,
173
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
174
+ "missing unsubscribe token",
175
+ );
176
+ }
177
+
178
+ const { payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
179
+ issuer: "kumiko:unsubscribe",
180
+ });
181
+
182
+ const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
183
+ if (addressAttempt.success) {
184
+ const parsed = addressAttempt.data;
185
+ if (parsed.sub !== parsed.addressHash) {
186
+ throw new ExtraRouteRejection(
187
+ 400,
188
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
189
+ "address token subject mismatch",
190
+ );
191
+ }
192
+ // @cast-boundary engine-bridge — string post-zod → branded TenantId
193
+ const tenantId = parsed.tenantId as TenantId;
194
+ return {
195
+ kind: "address",
196
+ tenantId,
197
+ addressHash: parsed.addressHash,
198
+ notificationType: parsed.notificationType,
199
+ channel: parsed.channel,
200
+ };
201
+ }
202
+
203
+ const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
204
+ if (!userAttempt.success) {
205
+ throw new ExtraRouteRejection(
206
+ 400,
207
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
208
+ "unsubscribe token payload matched neither schema",
209
+ );
210
+ }
211
+ const parsed = userAttempt.data;
212
+ // @cast-boundary engine-bridge — string post-zod → branded TenantId
213
+ const tenantId = parsed.tenantId as TenantId;
214
+ return {
215
+ kind: "user",
216
+ tenantId,
217
+ userId: parsed.sub,
218
+ notificationType: parsed.notificationType,
219
+ channel: parsed.channel,
220
+ };
221
+ } catch (err) {
222
+ if (err instanceof ExtraRouteRejection) throw err;
223
+ throw new ExtraRouteRejection(
224
+ 400,
225
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
226
+ "unsubscribe token verification failed",
227
+ );
146
228
  }
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,
229
+ },
230
+ handler: async (c, verified, deps) => {
231
+ const payload =
232
+ verified.kind === "address"
233
+ ? {
234
+ addressHash: verified.addressHash,
235
+ notificationType: verified.notificationType,
236
+ channel: verified.channel,
237
+ }
238
+ : {
239
+ userId: verified.userId,
240
+ notificationType: verified.notificationType,
241
+ channel: verified.channel,
242
+ };
243
+
244
+ // Token-verify passed — everything below is a legitimate write. Don't
245
+ // swallow write-errors as "invalid token", that would mask real bugs
246
+ // (e.g. events-table missing, DB down) behind a misleading 400.
247
+ const dispatched = await deps.dispatchSystemWrite({
248
+ handlerQn:
249
+ verified.kind === "address"
250
+ ? DeliveryHandlers.unsubscribeAddress
251
+ : DeliveryHandlers.unsubscribeUser,
252
+ tenantId: verified.tenantId,
253
+ payload,
156
254
  });
255
+ if (!dispatched.isSuccess) {
256
+ return c.text("Unsubscribe failed", 500);
257
+ }
157
258
  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
-
168
- // Token-verify passed — everything below is a legitimate write. Don't
169
- // swallow write-errors as "invalid token", that would mask real bugs
170
- // (e.g. events-table missing, DB down) behind a misleading 400.
171
- const actor = { id: parsed.sub, tenantId, roles: SYSTEM_ROLES };
172
- const tdb = createTenantDb(db, tenantId, "system");
173
- await upsertPreference(tdb, actor, {
174
- tenantId,
175
- userId: parsed.sub,
176
- notificationType: parsed.notificationType,
177
- channel: parsed.channel,
178
- enabled: false,
179
- });
180
- return c.text("You have been unsubscribed.", 200);
259
+ },
181
260
  });
182
-
183
- return app;
184
261
  }
@@ -5,14 +5,18 @@
5
5
  // concurrent requests — typical case: a user clicks the same "unsubscribe"
6
6
  // email link three times in a second.
7
7
  //
8
- // The fix: try the optimistic path (lookup + create|update), and on a
9
- // unique-index-violation race from a parallel create, re-lookup and fall
10
- // through to update. Worst case: one extra roundtrip for the loser of
11
- // the race. Happy path: same number of queries as the pre-ES upsert.
8
+ // The fix: the aggregate id is deterministic (tenant+user+type+channel), so
9
+ // concurrent first-time clicks collide at the event-store append itself
10
+ // (expectedVersion 0, confined to a savepoint) instead of racing past two
11
+ // independent creates and leaving an orphaned `created` event behind. The
12
+ // loser's create() comes back as a `version_conflict` failure; re-lookup and
13
+ // fall through to update, or no-op if the winner already set the target
14
+ // `enabled` state.
12
15
 
13
16
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
14
17
  import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
15
18
  import type { SessionUser, TenantId, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { generateDeterministicId } from "@cosmicdrift/kumiko-framework/utils";
16
20
  import { notificationPreferenceEntity, notificationPreferencesTable } from "./tables";
17
21
 
18
22
  const executor = createEventStoreExecutor(
@@ -21,11 +25,15 @@ const executor = createEventStoreExecutor(
21
25
  { entityName: "notification-preference" },
22
26
  );
23
27
 
24
- // Konkretes Lookup-Result-Shape — nur die zwei Felder die der upsert
28
+ // Konkretes Lookup-Result-Shape — nur die Felder die der upsert
25
29
  // tatsächlich für den Update-Path braucht. Vermeidet `row["x"] as T` index-
26
30
  // access casts; der Generic-Param am fetchOne macht den Cast zentralisiert
27
31
  // im Helper (db-row-boundary), nicht 4× pro Callsite.
28
- type PreferenceLookupRow = { readonly id: string; readonly version: number };
32
+ type PreferenceLookupRow = {
33
+ readonly id: string;
34
+ readonly version: number;
35
+ readonly enabled: boolean;
36
+ };
29
37
 
30
38
  // @wrapper-known semantic-alias
31
39
  async function lookup(
@@ -51,11 +59,25 @@ export type UpsertPreferenceInput = {
51
59
  readonly enabled: boolean;
52
60
  };
53
61
 
62
+ // One row per (tenant, user, type, channel): folding the tuple into the
63
+ // aggregate id means two concurrent first-time clicks collide on the same
64
+ // stream at append instead of racing two independent creates.
65
+ function preferenceAggregateId(
66
+ tenantId: TenantId,
67
+ userId: string,
68
+ notificationType: string,
69
+ channel: string,
70
+ ): string {
71
+ return generateDeterministicId(
72
+ "delivery:notification-preference",
73
+ `${tenantId}|${userId}|${notificationType}|${channel}`,
74
+ );
75
+ }
76
+
54
77
  /**
55
78
  * Idempotent "set-this-preference-to-enabled-state" against the preferences
56
79
  * aggregate stream. Emits either `.created` (first time) or `.updated`
57
- * (subsequent) and catches the race-induced unique-index violation as a
58
- * fallback to update.
80
+ * (subsequent) and converges a concurrent create-race onto the winner's row.
59
81
  */
60
82
  export async function upsertPreference(
61
83
  db: TenantDb,
@@ -71,6 +93,7 @@ export async function upsertPreference(
71
93
  );
72
94
 
73
95
  if (existing) {
96
+ if (existing.enabled === input.enabled) return { isSuccess: true, data: input };
74
97
  const result = await executor.update(
75
98
  {
76
99
  id: existing.id,
@@ -84,57 +107,48 @@ export async function upsertPreference(
84
107
  return { isSuccess: true, data: input };
85
108
  }
86
109
 
87
- try {
88
- const result = await executor.create(
89
- {
90
- userId: input.userId,
91
- notificationType: input.notificationType,
92
- channel: input.channel,
93
- enabled: input.enabled,
94
- },
95
- actor,
96
- db,
97
- );
98
- if (!result.isSuccess) return result;
99
- return { isSuccess: true, data: input };
100
- } catch (err) {
101
- // Race-fallback: another request beat us to the insert between our
102
- // lookup and the executor.create. The unique-index on
103
- // (tenant, user, type, channel) fires Postgres error 23505. Only that
104
- // specific error triggers the retry; DB-disconnect or any other
105
- // failure must bubble up unchanged so callers see the real cause.
106
- if (!isUniqueViolation(err)) throw err;
107
- const afterRace = await lookup(
108
- db,
109
- input.tenantId,
110
- input.userId,
111
- input.notificationType,
112
- input.channel,
113
- );
114
- if (!afterRace) throw err;
115
- const result = await executor.update(
116
- {
117
- id: afterRace.id,
118
- version: afterRace.version,
119
- changes: { enabled: input.enabled },
120
- },
121
- actor,
122
- db,
123
- );
124
- if (!result.isSuccess) return result;
125
- return { isSuccess: true, data: input };
126
- }
127
- }
110
+ const id = preferenceAggregateId(
111
+ input.tenantId,
112
+ input.userId,
113
+ input.notificationType,
114
+ input.channel,
115
+ );
116
+ const created = await executor.create(
117
+ {
118
+ id,
119
+ userId: input.userId,
120
+ notificationType: input.notificationType,
121
+ channel: input.channel,
122
+ enabled: input.enabled,
123
+ },
124
+ actor,
125
+ db,
126
+ );
127
+ if (created.isSuccess) return { isSuccess: true, data: input };
128
+ if (created.error.code !== "version_conflict") return created;
128
129
 
129
- // Narrow detection for Postgres "duplicate key violates unique constraint"
130
- // (SQLSTATE 23505). Drivers wrap the DB error in varying envelopes; we
131
- // check the `code` field plus a string-match fallback so the match survives
132
- // minor driver-version shifts without drifting wide.
133
- export function isUniqueViolation(err: unknown): boolean {
134
- if (typeof err !== "object" || err === null) return false;
135
- const e = err as { code?: unknown; cause?: { code?: unknown }; message?: unknown }; // @cast-boundary error-details
136
- if (e.code === "23505") return true;
137
- if (e.cause && typeof e.cause === "object" && e.cause.code === "23505") return true;
138
- if (typeof e.message === "string" && e.message.includes("23505")) return true;
139
- return false;
130
+ // Race-fallback: another request's create already won this deterministic
131
+ // id between our lookup and this create. Re-lookup and converge onto its
132
+ // row — no-op if it already set the target `enabled`, since relooking the
133
+ // same version and updating would version_conflict all but one loser.
134
+ const afterRace = await lookup(
135
+ db,
136
+ input.tenantId,
137
+ input.userId,
138
+ input.notificationType,
139
+ input.channel,
140
+ );
141
+ if (!afterRace) return created;
142
+ if (afterRace.enabled === input.enabled) return { isSuccess: true, data: input };
143
+ const result = await executor.update(
144
+ {
145
+ id: afterRace.id,
146
+ version: afterRace.version,
147
+ changes: { enabled: input.enabled },
148
+ },
149
+ actor,
150
+ db,
151
+ );
152
+ if (!result.isSuccess) return result;
153
+ return { isSuccess: true, data: input };
140
154
  }
@@ -122,6 +122,7 @@ const pngBytes = new Uint8Array([
122
122
  ]);
123
123
  const textBytes = new TextEncoder().encode("plain text, not a supported mime type");
124
124
  const oversizedPdfBytes = new Uint8Array(PROVIDER_A_MAX_BYTES + 100);
125
+ oversizedPdfBytes.set(new TextEncoder().encode("%PDF-1.4"));
125
126
  // Real ZIP local-file-header signature — validateFileContent content-verifies
126
127
  // docx uploads against it independent of options.accept.
127
128
  const docxBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...Array(32).fill(0)]);
@@ -73,7 +73,8 @@ async function uploadImage(
73
73
  ): Promise<string> {
74
74
  const token = await stack.jwt.sign(asUser);
75
75
  const fd = new FormData();
76
- fd.append("file", new File([Buffer.from([1, 2, 3])], "img.jpg", { type: "image/jpeg" }));
76
+ const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(10).fill(0)]);
77
+ fd.append("file", new File([Buffer.from(jpegBytes)], "img.jpg", { type: "image/jpeg" }));
77
78
  fd.append("entityType", "gadget");
78
79
  fd.append("entityId", entityId);
79
80
  fd.append("fieldName", "img");
@@ -98,7 +98,8 @@ beforeEach(async () => {
98
98
  async function uploadGadgetImage(): Promise<string> {
99
99
  const token = await stack.jwt.sign(user);
100
100
  const fd = new FormData();
101
- fd.append("file", new File([Buffer.from([1, 2, 3])], "img.jpg", { type: "image/jpeg" }));
101
+ const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(10).fill(0)]);
102
+ fd.append("file", new File([Buffer.from(jpegBytes)], "img.jpg", { type: "image/jpeg" }));
102
103
  fd.append("entityType", "gadget");
103
104
  fd.append("entityId", ENTITY_ID);
104
105
  fd.append("fieldName", "img");
@@ -158,7 +158,10 @@ async function uploadFile(
158
158
  // extension, not the declared part Content-Type — so the extension must
159
159
  // match `mimeType` for the upload to actually carry it through.
160
160
  const extension = mimeType.split("/")[1]?.split("+")[0] ?? "bin";
161
- fd.append("file", new File([Buffer.from([1, 2, 3])], `img.${extension}`, { type: mimeType }));
161
+ // Real JPEG magic bytes so validateFileContent doesn't 400 the default
162
+ // image/jpeg uploads; svg has no signature so this covers both cases.
163
+ const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(10).fill(0)]);
164
+ fd.append("file", new File([Buffer.from(jpegBytes)], `img.${extension}`, { type: mimeType }));
162
165
  fd.append("entityType", attach.entityType);
163
166
  fd.append("entityId", attach.entityId);
164
167
  fd.append("fieldName", fieldName);
@@ -16,6 +16,7 @@ export { entitiesOf } from "./entities-of";
16
16
  export { isWithinGracePeriod } from "./grace-period";
17
17
  export { hasWhereRule } from "./has-where-rule";
18
18
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
19
+ export { isTenantDb } from "./is-tenant-db";
19
20
  export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter";
20
21
  export { mapWithConcurrency } from "./map-with-concurrency";
21
22
  export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility";
@@ -0,0 +1,9 @@
1
+ import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+
3
+ // Structural probe: a TenantDb and a raw DbRunner both expose fetchOne/selectMany,
4
+ // so the shape alone can't tell them apart — `unsafeRaw` only exists on TenantDb.
5
+ // Shared so retention/compliance-profile resolvers that accept either (bulk cron
6
+ // vs. per-hook TenantDb) branch through one predicate instead of duplicating it.
7
+ export function isTenantDb(db: DbRunner | TenantDb): db is TenantDb {
8
+ return typeof (db as Partial<TenantDb>).unsafeRaw === "function";
9
+ }
@@ -141,7 +141,11 @@ describe("#608 unified file-storage :: route upload + GDPR erasure hit one store
141
141
  await seed.seedMembership(userId, TENANT_SYSTEM);
142
142
  const uploader: SessionUser = { id: userId, tenantId: TENANT_SYSTEM, roles: ["Member"] };
143
143
 
144
- const res = await uploadAs(uploader, "secret.pdf", new Uint8Array([10, 20, 30, 40]));
144
+ const res = await uploadAs(
145
+ uploader,
146
+ "secret.pdf",
147
+ new TextEncoder().encode("%PDF-1.4 minimal"),
148
+ );
145
149
  expect(res.status).toBe(201);
146
150
  const { storageKey } = (await res.json()) as { storageKey: string };
147
151