@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
@@ -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;