@cosmicdrift/kumiko-bundled-features 0.321.0 → 0.323.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 (28) hide show
  1. package/package.json +9 -9
  2. package/src/agent-tools/__tests__/kms-erase-gate.integration.test.ts +127 -0
  3. package/src/agent-tools/__tests__/tool-catalog.test.ts +16 -1
  4. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
  5. package/src/agent-tools/agent-manifest.ts +13 -3
  6. package/src/agent-tools/tool-dispatch.ts +17 -12
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +124 -5
  8. package/src/auth-email-password/changes.json +7 -0
  9. package/src/auth-email-password/constants.ts +3 -0
  10. package/src/auth-email-password/feature.ts +4 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +5 -6
  12. package/src/auth-email-password/handlers/invite-info.query.ts +89 -0
  13. package/src/crypto-shredding/handlers/forget-subject.write.ts +3 -1
  14. package/src/delivery/__tests__/delivery.integration.test.ts +232 -0
  15. package/src/delivery/address-opt-out.ts +113 -24
  16. package/src/delivery/changes.json +7 -0
  17. package/src/delivery/constants.ts +1 -0
  18. package/src/delivery/feature.ts +5 -1
  19. package/src/delivery/handlers/resubscribe-address.write.ts +42 -0
  20. package/src/delivery/handlers/resubscribe-user.write.ts +41 -0
  21. package/src/delivery/public-names.ts +13 -0
  22. package/src/delivery/tables.ts +5 -2
  23. package/src/delivery/unsubscribe.ts +104 -7
  24. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +72 -0
  25. package/src/tenant/__tests__/members-facet-i18n.test.ts +3 -2
  26. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
  27. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +40 -3
  28. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +3 -1
@@ -52,7 +52,7 @@ import {
52
52
  AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
53
53
  AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
54
54
  } from "../constants";
55
- import { invalidInviteToken, inviteEmailMismatch } from "../errors";
55
+ import { invalidCredentials, invalidInviteToken, inviteEmailMismatch } from "../errors";
56
56
  import {
57
57
  burnInviteToken,
58
58
  deleteInviteToken,
@@ -191,14 +191,13 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
191
191
  email: invitationEmail,
192
192
  isDeleted: false,
193
193
  });
194
- if (!userRow?.passwordHash) return invalidInviteToken();
194
+ // No enumeration risk: reaching here already required a valid, open
195
+ // invite token plus the matching invitation email.
196
+ if (!userRow?.passwordHash) return invalidCredentials();
195
197
 
196
198
  const lockoutGate = await gateEnforceLockout(ctx, userRow.id);
197
199
  if (!lockoutGate.ok) return lockoutGate.result;
198
200
 
199
- // Wrong password still collapses to invalidInviteToken (existing
200
- // anti-enum contract for this endpoint) — gateVerifyPassword runs
201
- // regardless, for its failed-attempt recording side effect.
202
201
  const passwordGate = await gateVerifyPassword(
203
202
  ctx,
204
203
  { id: userRow.id, passwordHash: userRow.passwordHash },
@@ -206,7 +205,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
206
205
  maxFailedAttempts,
207
206
  lockoutDurationMinutes,
208
207
  );
209
- if (!passwordGate.ok) return invalidInviteToken();
208
+ if (!passwordGate.ok) return invalidCredentials();
210
209
 
211
210
  const emailGate = gateEnforceEmailVerified(userRow, strictVerification);
212
211
  if (!emailGate.ok) return emailGate.result;
@@ -0,0 +1,89 @@
1
+ // Tenant-Invite magic-link: anonymous, read-only lookup so the invite
2
+ // acceptance page can pre-fill the email and choose between the
3
+ // accept-with-login (Branch 2) and signup-complete (Branch 3) forms before
4
+ // the user submits anything. Unlike the accept handlers, this never touches
5
+ // the token store — the token is not burned and stays usable for the
6
+ // actual accept call.
7
+
8
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
11
+ import * as z from "zod";
12
+ import { decryptStoredPii } from "../../shared";
13
+ // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link), DB-row-owner ist tenant-feature
14
+ import { INVITATION_STATUS, tenantInvitationsTable } from "../../tenant/invitation-table";
15
+ // kumiko-lint-ignore cross-feature-import login-style account lookup, same as invite-accept-with-login
16
+ import { userTable } from "../../user/schema/user";
17
+ import { AuthErrors } from "../constants";
18
+ import { getInvitationIdForToken } from "../invite-token-store";
19
+
20
+ const InviteInfoSchema = z.object({
21
+ token: z.string().min(1),
22
+ });
23
+
24
+ export type InviteInfoData = {
25
+ readonly email: string;
26
+ readonly hasAccount: boolean;
27
+ };
28
+
29
+ const READ_PENDING_INVITATION_REASON =
30
+ "reads the pending invitation by id for the anonymous invite-info lookup; the caller has no tenant context yet";
31
+
32
+ // Every failure branch (unknown/expired token, non-pending invitation, no
33
+ // redis) collapses onto the same invalidInviteToken code as the accept
34
+ // handlers — anti-enumeration, same contract as invite-accept-with-login.
35
+ function throwInvalidInviteToken(): never {
36
+ throw new UnprocessableError(AuthErrors.invalidInviteToken, {
37
+ i18nKey: "auth.errors.invalidInviteToken",
38
+ });
39
+ }
40
+
41
+ export const inviteInfoQuery = defineQueryHandler({
42
+ name: "invite-info",
43
+ schema: InviteInfoSchema,
44
+ access: { roles: ["anonymous"] },
45
+ rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
46
+ agent: { expose: false },
47
+ escapeHatch: {
48
+ reason: READ_PENDING_INVITATION_REASON,
49
+ },
50
+ description:
51
+ "Anonymous, read-only invite lookup for the invite-acceptance page: reveals the invited " +
52
+ "email and whether an account already exists for it, without consuming the token.",
53
+ handler: async (query, ctx) => {
54
+ if (!ctx.redis) throwInvalidInviteToken();
55
+
56
+ const invitationId = await getInvitationIdForToken(ctx.redis, query.payload.token);
57
+ if (!invitationId) throwInvalidInviteToken();
58
+
59
+ type InvitationRow = {
60
+ readonly status: string;
61
+ readonly email: string;
62
+ };
63
+ type UserRow = {
64
+ readonly id: string;
65
+ };
66
+
67
+ const invitation = await fetchOne<InvitationRow>(
68
+ ctx.db.unsafeRaw(READ_PENDING_INVITATION_REASON),
69
+ tenantInvitationsTable,
70
+ { id: invitationId },
71
+ );
72
+ if (!invitation || invitation.status !== INVITATION_STATUS.pending) {
73
+ throwInvalidInviteToken();
74
+ }
75
+
76
+ const invitationEmail = await decryptStoredPii(invitation.email, "email", "auth:invite-info");
77
+
78
+ const userRow = await ctx.db.global(userTable).fetchOne<UserRow>({
79
+ email: invitationEmail,
80
+ isDeleted: false,
81
+ });
82
+
83
+ const data: InviteInfoData = {
84
+ email: invitationEmail,
85
+ hasAccount: userRow !== undefined,
86
+ };
87
+ return data;
88
+ },
89
+ });
@@ -28,6 +28,7 @@ import {
28
28
  writeFailure,
29
29
  } from "@cosmicdrift/kumiko-framework/errors";
30
30
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
31
+ import { assertIrreversibleOperationAllowed } from "@cosmicdrift/kumiko-framework/pipeline";
31
32
  import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
32
33
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
33
34
  import * as z from "zod";
@@ -267,7 +268,7 @@ export const forgetSubjectWrite = defineWriteHandler({
267
268
  "Irreversibly crypto-shreds one user, tenant or record subject by erasing its encryption key, nulling its blind indexes, purging its search documents and closing the user's login, for supervisory-authority requests and operator recovery outside the automated Art. 17 cleanup pipeline.",
268
269
  // Erasing the subject key is irreversible: there is no undo, so an agent must
269
270
  // not be able to reach it at all.
270
- agent: { expose: false },
271
+ agent: { expose: false, risk: "high" },
271
272
  escapeHatch: {
272
273
  reason:
273
274
  "denial audit append names the prober's own tenant stream on the outside-transaction db; " +
@@ -276,6 +277,7 @@ export const forgetSubjectWrite = defineWriteHandler({
276
277
  "lifecycle update and PAT revoke run on the SYSTEM user stream.",
277
278
  },
278
279
  handler: async (event, ctx) => {
280
+ assertIrreversibleOperationAllowed("forget-subject key erase");
279
281
  const kms = configuredPiiSubjectKms();
280
282
  if (!kms) {
281
283
  return writeFailure(
@@ -48,7 +48,14 @@ import { createTenantFeature } from "../../tenant/feature";
48
48
  import { tenantMembershipsTable } from "../../tenant/membership-table";
49
49
  import { tenantEntity } from "../../tenant/schema/tenant";
50
50
  import {
51
+ addressOptOutAggregateIdForTests,
52
+ hashUnsubscribeAddress,
53
+ MAX_ADDRESS_OPT_OUT_GENERATIONS_FOR_TESTS,
54
+ } from "../address-opt-out";
55
+ import {
56
+ DELIVERY_RESUBSCRIBE_PATH,
51
57
  DELIVERY_UNSUBSCRIBE_PATH,
58
+ DeliveryErrors,
52
59
  DeliveryHandlers,
53
60
  DeliveryJobs,
54
61
  DeliveryQueries,
@@ -58,6 +65,7 @@ import { createDeliveryFeature } from "../feature";
58
65
  import { deliveryRenderJob, deliverySendJob } from "../jobs";
59
66
  import {
60
67
  deliveryAttemptsTable,
68
+ notificationAddressOptOutEntity,
61
69
  notificationAddressOptOutsTable,
62
70
  notificationPreferencesTable,
63
71
  } from "../tables";
@@ -86,6 +94,15 @@ function postUnsubscribe(token: string) {
86
94
  });
87
95
  }
88
96
 
97
+ // Mirrors the unsubscribe-page's "Undo" button: JSON body, never the query.
98
+ function postResubscribe(token: string) {
99
+ return stack.app.request(DELIVERY_RESUBSCRIBE_PATH, {
100
+ method: "POST",
101
+ headers: { "content-type": "application/json" },
102
+ body: JSON.stringify({ token }),
103
+ });
104
+ }
105
+
89
106
  // Email test infrastructure
90
107
  const emailTransport = createInMemoryTransport();
91
108
  const testEmail = (userId: string | number) => `user-${userId}@test.com`;
@@ -2269,3 +2286,218 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
2269
2286
  expect(rows).toHaveLength(1);
2270
2287
  });
2271
2288
  });
2289
+
2290
+ // --- Flow 20: resubscribe endpoint (undo direction of unsubscribe) ---
2291
+
2292
+ describe("flow 20: resubscribe endpoint", () => {
2293
+ const ADDRESS_BIDX_KEY = Buffer.alloc(32, 3).toString("base64");
2294
+
2295
+ beforeAll(() => {
2296
+ configureBlindIndexKey(ADDRESS_BIDX_KEY);
2297
+ });
2298
+
2299
+ afterAll(() => {
2300
+ resetBlindIndexKeyForTests();
2301
+ });
2302
+
2303
+ test("address token: unsubscribe → resubscribe → unsubscribe again → resubscribe (id generations)", async () => {
2304
+ const address = "flow20-address@test.com";
2305
+ const notificationType = "app:notify:address-resub-20a";
2306
+
2307
+ const token = await signAddressUnsubscribeToken(
2308
+ { tenantId: admin.tenantId, address, notificationType, channel: "email" },
2309
+ UNSUBSCRIBE_SECRET,
2310
+ );
2311
+
2312
+ const unsubRes = await postUnsubscribe(token);
2313
+ expect(unsubRes.status).toBe(200);
2314
+ expect(
2315
+ await selectMany(db, notificationAddressOptOutsTable, {
2316
+ tenantId: admin.tenantId,
2317
+ notificationType,
2318
+ channel: "email",
2319
+ }),
2320
+ ).toHaveLength(1);
2321
+
2322
+ const resubRes = await postResubscribe(token);
2323
+ expect(resubRes.status).toBe(200);
2324
+ const resubBody = (await resubRes.json()) as { isSuccess?: boolean };
2325
+ expect(resubBody.isSuccess).toBe(true);
2326
+ expect(
2327
+ await selectMany(db, notificationAddressOptOutsTable, {
2328
+ tenantId: admin.tenantId,
2329
+ notificationType,
2330
+ channel: "email",
2331
+ }),
2332
+ ).toHaveLength(0);
2333
+
2334
+ // Opting out again must land on a fresh generation — the first
2335
+ // generation's stream was hard-deleted by the resubscribe above.
2336
+ const unsubRes2 = await postUnsubscribe(token);
2337
+ expect(unsubRes2.status).toBe(200);
2338
+ expect(
2339
+ await selectMany(db, notificationAddressOptOutsTable, {
2340
+ tenantId: admin.tenantId,
2341
+ notificationType,
2342
+ channel: "email",
2343
+ }),
2344
+ ).toHaveLength(1);
2345
+
2346
+ const resubRes2 = await postResubscribe(token);
2347
+ expect(resubRes2.status).toBe(200);
2348
+ expect(
2349
+ await selectMany(db, notificationAddressOptOutsTable, {
2350
+ tenantId: admin.tenantId,
2351
+ notificationType,
2352
+ channel: "email",
2353
+ }),
2354
+ ).toHaveLength(0);
2355
+ });
2356
+
2357
+ test("address token: resubscribe at the last generation is refused with 409, opt-out survives, unsubscribe still works", async () => {
2358
+ const address = "flow20-generation-cap@test.com";
2359
+ const notificationType = "app:notify:address-resub-20-cap";
2360
+ const channel = "email";
2361
+ const addressHash = hashUnsubscribeAddress(address);
2362
+ if (!addressHash) throw new Error("blind-index key not configured for this test");
2363
+
2364
+ // Seed directly at the last generation instead of looping resubscribe
2365
+ // 99 times — same executor.create seed pattern production code itself
2366
+ // uses (see ticketExecutor() above), not a raw table write.
2367
+ const lastGenerationId = addressOptOutAggregateIdForTests(
2368
+ admin.tenantId,
2369
+ addressHash,
2370
+ notificationType,
2371
+ channel,
2372
+ MAX_ADDRESS_OPT_OUT_GENERATIONS_FOR_TESTS - 1,
2373
+ );
2374
+ const optOutExecutor = createEventStoreExecutor(
2375
+ notificationAddressOptOutsTable,
2376
+ notificationAddressOptOutEntity,
2377
+ { entityName: "notification-address-opt-out" },
2378
+ );
2379
+ const tenantDb = createTenantDb(db, admin.tenantId);
2380
+ const seeded = await optOutExecutor.create(
2381
+ { id: lastGenerationId, addressHash, notificationType, channel },
2382
+ admin,
2383
+ tenantDb,
2384
+ );
2385
+ expect(seeded.isSuccess).toBe(true);
2386
+
2387
+ const token = await signAddressUnsubscribeToken(
2388
+ { tenantId: admin.tenantId, address, notificationType, channel },
2389
+ UNSUBSCRIBE_SECRET,
2390
+ );
2391
+
2392
+ const resubRes = await postResubscribe(token);
2393
+ expect(resubRes.status).toBe(409);
2394
+ const resubBody = (await resubRes.json()) as { error?: { code?: string } };
2395
+ expect(resubBody.error?.code).toBe(DeliveryErrors.resubscribeLimitReached);
2396
+
2397
+ expect(
2398
+ await selectMany(db, notificationAddressOptOutsTable, {
2399
+ tenantId: admin.tenantId,
2400
+ notificationType,
2401
+ channel,
2402
+ }),
2403
+ ).toHaveLength(1);
2404
+
2405
+ const unsubRes = await postUnsubscribe(token);
2406
+ expect(unsubRes.status).toBe(200);
2407
+ expect(
2408
+ await selectMany(db, notificationAddressOptOutsTable, {
2409
+ tenantId: admin.tenantId,
2410
+ notificationType,
2411
+ channel,
2412
+ }),
2413
+ ).toHaveLength(1);
2414
+ });
2415
+
2416
+ test("address token: resubscribe without a prior opt-out is a no-op 200", async () => {
2417
+ const address = "flow20-no-prior-optout@test.com";
2418
+ const notificationType = "app:notify:address-resub-20b";
2419
+
2420
+ const token = await signAddressUnsubscribeToken(
2421
+ { tenantId: admin.tenantId, address, notificationType, channel: "email" },
2422
+ UNSUBSCRIBE_SECRET,
2423
+ );
2424
+
2425
+ const res = await postResubscribe(token);
2426
+ expect(res.status).toBe(200);
2427
+ const body = (await res.json()) as { isSuccess?: boolean };
2428
+ expect(body.isSuccess).toBe(true);
2429
+ });
2430
+
2431
+ test("user token: resubscribe re-enables the preference", async () => {
2432
+ const token = await signUnsubscribeToken(
2433
+ {
2434
+ userId: user2.id,
2435
+ tenantId: user2.tenantId,
2436
+ notificationType: "app:notify:user-resub-20c",
2437
+ channel: "inApp",
2438
+ },
2439
+ UNSUBSCRIBE_SECRET,
2440
+ );
2441
+
2442
+ await postUnsubscribe(token);
2443
+ const disabled = await selectMany(db, notificationPreferencesTable, {
2444
+ userId: user2.id,
2445
+ notificationType: "app:notify:user-resub-20c",
2446
+ channel: "inApp",
2447
+ });
2448
+ expect(disabled[0]?.["enabled"]).toBe(false);
2449
+
2450
+ const res = await postResubscribe(token);
2451
+ expect(res.status).toBe(200);
2452
+ const enabled = await selectMany(db, notificationPreferencesTable, {
2453
+ userId: user2.id,
2454
+ notificationType: "app:notify:user-resub-20c",
2455
+ channel: "inApp",
2456
+ });
2457
+ expect(enabled[0]?.["enabled"]).toBe(true);
2458
+ });
2459
+
2460
+ test("invalid token returns 400 unsubscribe_token_invalid", async () => {
2461
+ const res = await postResubscribe("invalid-jwt-token");
2462
+ expect(res.status).toBe(400);
2463
+ const body = (await res.json()) as { error?: { code?: string } };
2464
+ expect(body.error?.code).toBe("unsubscribe_token_invalid");
2465
+ });
2466
+
2467
+ test("token only in the query string is rejected (no query fallback, unlike unsubscribe)", async () => {
2468
+ const token = await signUnsubscribeToken(
2469
+ {
2470
+ userId: user2.id,
2471
+ tenantId: user2.tenantId,
2472
+ notificationType: "app:notify:user-resub-20d",
2473
+ channel: "inApp",
2474
+ },
2475
+ UNSUBSCRIBE_SECRET,
2476
+ );
2477
+
2478
+ const res = await stack.app.request(`${DELIVERY_RESUBSCRIBE_PATH}?token=${token}`, {
2479
+ method: "POST",
2480
+ });
2481
+ expect(res.status).toBe(400);
2482
+ });
2483
+
2484
+ test("normal user cannot dispatch the resubscribe write handlers directly", async () => {
2485
+ const addressError = await stack.http.writeErr(
2486
+ DeliveryHandlers.resubscribeAddress,
2487
+ {
2488
+ addressHash: "x".repeat(32),
2489
+ notificationType: "app:notify:direct-resub",
2490
+ channel: "email",
2491
+ },
2492
+ user1,
2493
+ );
2494
+ expect(addressError.code).toBe("access_denied");
2495
+
2496
+ const userError = await stack.http.writeErr(
2497
+ DeliveryHandlers.resubscribeUser,
2498
+ { userId: user2.id, notificationType: "app:notify:direct-resub", channel: "email" },
2499
+ user1,
2500
+ );
2501
+ expect(userError.code).toBe("access_denied");
2502
+ });
2503
+ });
@@ -2,7 +2,9 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { computeBlindIndex, configuredBlindIndexKey } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type { SessionUser, TenantId, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
6
  import { generateDeterministicId } from "@cosmicdrift/kumiko-framework/utils";
7
+ import { DeliveryErrors } from "./public-names";
6
8
  import { notificationAddressOptOutEntity, notificationAddressOptOutsTable } from "./tables";
7
9
 
8
10
  const executor = createEventStoreExecutor(
@@ -49,22 +51,56 @@ async function lookup(
49
51
  // preferenceAggregateId in upsert-preference.ts — concurrent first-time
50
52
  // opt-outs collide on the same stream at append, not on the projection's
51
53
  // unique index.
54
+ //
55
+ // `generation` picks a distinct deterministic id for the same business key —
56
+ // needed because removeAddressOptOut() hard-deletes the row (no softDelete on
57
+ // this entity, see tables.ts), which leaves generation 0's stream in a
58
+ // deleted state forever (event-store streams are immutable once deleted).
59
+ // A later opt-out for the same address/type/channel must land on a fresh
60
+ // stream instead of colliding with the dead one. Generation 0 keeps the
61
+ // unsuffixed id for backwards-compatibility with rows written before
62
+ // resubscribe existed.
52
63
  function addressOptOutAggregateId(
53
64
  tenantId: TenantId,
54
65
  addressHash: string,
55
66
  notificationType: string,
56
67
  channel: string,
68
+ generation: number,
57
69
  ): string {
70
+ const base = `${tenantId}|${addressHash}|${notificationType}|${channel}`;
58
71
  return generateDeterministicId(
59
72
  "delivery:notification-address-opt-out",
60
- `${tenantId}|${addressHash}|${notificationType}|${channel}`,
73
+ generation === 0 ? base : `${base}|g${generation}`,
61
74
  );
62
75
  }
63
76
 
77
+ // Hard-cap on generation retries — a version_conflict with no row means the
78
+ // stream at that generation was deleted by a previous resubscribe; this
79
+ // bounds the opt-out/resubscribe ping-pong instead of looping forever on a
80
+ // pathological retry storm.
81
+ const MAX_ADDRESS_OPT_OUT_GENERATIONS = 100;
82
+
83
+ // Test-only: lets integration tests seed a row at an arbitrary generation
84
+ // (e.g. the last one, to exercise removeAddressOptOut's limit) via the same
85
+ // executor.create seed pattern production code uses, instead of looping
86
+ // upsertAddressOptOut 100 times or writing the table directly.
87
+ export function addressOptOutAggregateIdForTests(
88
+ tenantId: TenantId,
89
+ addressHash: string,
90
+ notificationType: string,
91
+ channel: string,
92
+ generation: number,
93
+ ): string {
94
+ return addressOptOutAggregateId(tenantId, addressHash, notificationType, channel, generation);
95
+ }
96
+
97
+ export const MAX_ADDRESS_OPT_OUT_GENERATIONS_FOR_TESTS = MAX_ADDRESS_OPT_OUT_GENERATIONS;
98
+
64
99
  /**
65
100
  * Create-or-noop: opting the same address out twice must not produce a
66
- * second row or fail the second click. There is no update path — unlike
67
- * user preferences an address opt-out has no `enabled` flag to flip back.
101
+ * second row or fail the second click. Resubscribing (removeAddressOptOut)
102
+ * hard-deletes the row, so a later opt-out probes forward through
103
+ * generations until it finds one whose stream isn't already dead.
68
104
  */
69
105
  export async function upsertAddressOptOut(
70
106
  db: TenantDb,
@@ -80,36 +116,89 @@ export async function upsertAddressOptOut(
80
116
  );
81
117
  if (existing) return { isSuccess: true, data: input };
82
118
 
83
- const id = addressOptOutAggregateId(
119
+ let lastFailure: WriteResult<UpsertAddressOptOutInput> | undefined;
120
+ for (let generation = 0; generation < MAX_ADDRESS_OPT_OUT_GENERATIONS; generation++) {
121
+ const id = addressOptOutAggregateId(
122
+ input.tenantId,
123
+ input.addressHash,
124
+ input.notificationType,
125
+ input.channel,
126
+ generation,
127
+ );
128
+ const created = await executor.create(
129
+ {
130
+ id,
131
+ addressHash: input.addressHash,
132
+ notificationType: input.notificationType,
133
+ channel: input.channel,
134
+ },
135
+ actor,
136
+ db,
137
+ );
138
+ if (created.isSuccess) return { isSuccess: true, data: input };
139
+ if (created.error.code !== "version_conflict") return created;
140
+
141
+ // Race-fallback: another request's create already won this generation's
142
+ // id between our lookup and this create — the existing row already IS
143
+ // the opt-out, so the race loser just reports success too.
144
+ const afterRace = await lookup(
145
+ db,
146
+ input.tenantId,
147
+ input.addressHash,
148
+ input.notificationType,
149
+ input.channel,
150
+ );
151
+ if (afterRace) return { isSuccess: true, data: input };
152
+
153
+ // No row after the conflict: this generation's stream was deleted by a
154
+ // prior resubscribe. Try the next generation instead of failing.
155
+ lastFailure = created;
156
+ }
157
+ return lastFailure ?? { isSuccess: true, data: input };
158
+ }
159
+
160
+ /**
161
+ * Resubscribe: hard-deletes the opt-out row so `isAddressOptedOut` reports
162
+ * false again. Idempotent — no row means the address was never (or is no
163
+ * longer) opted out, which is already the desired end state.
164
+ *
165
+ * Refuses to delete the row sitting at the last available id generation:
166
+ * unsubscribe must always succeed, and once every generation below the cap
167
+ * is dead, only a live row at the last generation still guarantees that
168
+ * (upsertAddressOptOut's existing-row check, no generation loop needed).
169
+ */
170
+ export async function removeAddressOptOut(
171
+ db: TenantDb,
172
+ actor: SessionUser,
173
+ input: UpsertAddressOptOutInput,
174
+ ): Promise<WriteResult<UpsertAddressOptOutInput>> {
175
+ const existing = await lookup(
176
+ db,
84
177
  input.tenantId,
85
178
  input.addressHash,
86
179
  input.notificationType,
87
180
  input.channel,
88
181
  );
89
- const created = await executor.create(
90
- {
91
- id,
92
- addressHash: input.addressHash,
93
- notificationType: input.notificationType,
94
- channel: input.channel,
95
- },
96
- actor,
97
- db,
98
- );
99
- if (created.isSuccess) return { isSuccess: true, data: input };
100
- // Race-fallback: another request's create already won this deterministic
101
- // id between our lookup and this create — the existing row already IS the
102
- // opt-out, so the race loser just reports success too.
103
- if (created.error.code !== "version_conflict") return created;
104
- // A conflict without a row means the stream exists but its row is gone —
105
- // report that instead of a silent "unsubscribed" that never persisted.
106
- const afterRace = await lookup(
107
- db,
182
+ if (!existing) return { isSuccess: true, data: input };
183
+
184
+ const lastGenerationId = addressOptOutAggregateId(
108
185
  input.tenantId,
109
186
  input.addressHash,
110
187
  input.notificationType,
111
188
  input.channel,
189
+ MAX_ADDRESS_OPT_OUT_GENERATIONS - 1,
112
190
  );
113
- if (!afterRace) return created;
191
+ if (existing.id === lastGenerationId) {
192
+ return writeFailure(
193
+ new ConflictError({
194
+ message: "resubscribe limit reached: this address must stay opted out",
195
+ i18nKey: "delivery.errors.resubscribeLimitReached",
196
+ details: { reason: DeliveryErrors.resubscribeLimitReached },
197
+ }),
198
+ );
199
+ }
200
+
201
+ const deleted = await executor.delete({ id: existing.id }, actor, db);
202
+ if (!deleted.isSuccess) return deleted;
114
203
  return { isSuccess: true, data: input };
115
204
  }
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.323.0",
4
+ "type": "improvement",
5
+ "title": "New POST /api/delivery/resubscribe route undoes an unsubscribe/opt-out",
6
+ "detail": "createUnsubscribeRoutes now also mounts POST /api/delivery/resubscribe (JSON {token} or form body only, never the query string, to stay safe against mail-client link prefetchers). It accepts the same signed tokens as the unsubscribe routes and re-enables the preference (signed-in user) or removes the address opt-out (no-account recipient). A resubscribed address opt-out is hard-deleted; a later re-opt-out for the same address/type/channel lands on a fresh event-stream generation instead of reviving the deleted one.",
7
+ "migration": "No action needed: the route is additive and mounted automatically wherever createUnsubscribeRoutes({ secret }) already runs. Sign resubscribe links with the same signUnsubscribeToken/signAddressUnsubscribeToken used for unsubscribe links."
8
+ },
2
9
  {
3
10
  "version": "0.321.0",
4
11
  "type": "breaking",
@@ -10,6 +10,7 @@ export {
10
10
  DELIVERY_ATTEMPT_EVENT,
11
11
  DELIVERY_FEATURE,
12
12
  DELIVERY_LOG_SCREEN_ID,
13
+ DELIVERY_RESUBSCRIBE_PATH,
13
14
  DELIVERY_STATUS_CELL_COMPONENT,
14
15
  DELIVERY_UNSUBSCRIBE_PATH,
15
16
  DeliveryErrors,
@@ -17,6 +17,8 @@ import {
17
17
  import { deliveryAttemptSchema } from "./events";
18
18
  import { logQuery } from "./handlers/log.query";
19
19
  import { preferencesQuery } from "./handlers/preferences.query";
20
+ import { resubscribeAddressWrite } from "./handlers/resubscribe-address.write";
21
+ import { resubscribeUserWrite } from "./handlers/resubscribe-user.write";
20
22
  import { setPreferenceWrite } from "./handlers/set-preference.write";
21
23
  import { unsubscribeAddressWrite } from "./handlers/unsubscribe-address.write";
22
24
  import { unsubscribeUserWrite } from "./handlers/unsubscribe-user.write";
@@ -44,7 +46,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
44
46
  const resolvedAccess = options?.access ?? access.admin;
45
47
  return defineFeature("delivery", (r) => {
46
48
  r.describe(
47
- "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. Unsubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe`: `GET ?token=` renders a confirmation page (no write), `POST` performs the opt-out (RFC 8058 one-click, token from the form body or query) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at this route.",
49
+ "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. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route.",
48
50
  );
49
51
  r.uiHints({
50
52
  displayLabel: "Notifications \u00b7 Dispatch Core",
@@ -152,6 +154,8 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
152
154
  setPreference: r.writeHandler(setPreferenceWrite),
153
155
  unsubscribeAddress: r.writeHandler(unsubscribeAddressWrite),
154
156
  unsubscribeUser: r.writeHandler(unsubscribeUserWrite),
157
+ resubscribeAddress: r.writeHandler(resubscribeAddressWrite),
158
+ resubscribeUser: r.writeHandler(resubscribeUserWrite),
155
159
  };
156
160
 
157
161
  const queries = {