@cosmicdrift/kumiko-bundled-features 0.322.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.
@@ -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 = {
@@ -0,0 +1,42 @@
1
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import * as z from "zod";
4
+ import { removeAddressOptOut } from "../address-opt-out";
5
+
6
+ export const resubscribeAddressWrite = defineWriteHandler({
7
+ name: "resubscribeAddress",
8
+ schema: z.object({
9
+ addressHash: z.string().min(1),
10
+ notificationType: z.string().min(1),
11
+ channel: z.string().min(1),
12
+ }),
13
+ access: { roles: access.systemAdmin },
14
+ // Only reachable via createUnsubscribeRoutes' POST /resubscribe
15
+ // dispatchSystemWrite, once verify() has already proven the address-token
16
+ // signature — not a surface for the agent tool-picker to offer directly.
17
+ // risk: "high" — hard-deletes the opt-out row (see removeAddressOptOut,
18
+ // the entity has no softDelete).
19
+ agent: { expose: false, risk: "high" },
20
+ description:
21
+ "Removes a no-account address opt-out for one notificationType/channel combination; dispatched by the signed resubscribe route, not meant for UI callers.",
22
+ handler: async (event, ctx) => {
23
+ const { addressHash, notificationType, channel } = event.payload;
24
+ const { tenantId } = event.user;
25
+
26
+ if (!ctx.systemDb) {
27
+ throw new InternalError({
28
+ message: "resubscribeAddress: ctx.systemDb missing on a system-scoped handler",
29
+ });
30
+ }
31
+ const db = ctx.systemDb.assertTenantMatch(tenantId);
32
+
33
+ const result = await removeAddressOptOut(db, event.user, {
34
+ tenantId,
35
+ addressHash,
36
+ notificationType,
37
+ channel,
38
+ });
39
+ if (!result.isSuccess) return result;
40
+ return { isSuccess: true, data: { notificationType, channel } };
41
+ },
42
+ });
@@ -0,0 +1,41 @@
1
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import * as z from "zod";
4
+ import { upsertPreference } from "../upsert-preference";
5
+
6
+ export const resubscribeUserWrite = defineWriteHandler({
7
+ name: "resubscribeUser",
8
+ schema: z.object({
9
+ userId: z.string().min(1),
10
+ notificationType: z.string().min(1),
11
+ channel: z.string().min(1),
12
+ }),
13
+ access: { roles: access.systemAdmin },
14
+ // Only reachable via createUnsubscribeRoutes' POST /resubscribe
15
+ // dispatchSystemWrite, once verify() has already proven the user-token
16
+ // signature — not a surface for the agent tool-picker to offer directly.
17
+ agent: { expose: false },
18
+ description:
19
+ "Re-enables one notification type and channel combination for the userId carried in a verified unsubscribe token; dispatched by the signed resubscribe route, not meant for UI callers.",
20
+ handler: async (event, ctx) => {
21
+ const { userId, notificationType, channel } = event.payload;
22
+ const { tenantId } = event.user;
23
+
24
+ if (!ctx.systemDb) {
25
+ throw new InternalError({
26
+ message: "resubscribeUser: ctx.systemDb missing on a system-scoped handler",
27
+ });
28
+ }
29
+ const db = ctx.systemDb.assertTenantMatch(tenantId);
30
+
31
+ const result = await upsertPreference(db, event.user, {
32
+ tenantId,
33
+ userId,
34
+ notificationType,
35
+ channel,
36
+ enabled: true,
37
+ });
38
+ if (!result.isSuccess) return result;
39
+ return { isSuccess: true, data: { notificationType, channel } };
40
+ },
41
+ });
@@ -7,12 +7,21 @@ export const DeliveryHandlers = {
7
7
  setPreference: "delivery:write:set-preference",
8
8
  unsubscribeAddress: "delivery:write:unsubscribe-address",
9
9
  unsubscribeUser: "delivery:write:unsubscribe-user",
10
+ resubscribeAddress: "delivery:write:resubscribe-address",
11
+ resubscribeUser: "delivery:write:resubscribe-user",
10
12
  } as const;
11
13
 
12
14
  // Fixed so links mailed out today keep working — the unsubscribe route is
13
15
  // mounted at this exact path via `extraRoutes: [...createUnsubscribeRoutes(...)]`.
14
16
  export const DELIVERY_UNSUBSCRIBE_PATH = "/api/delivery/unsubscribe" as const;
15
17
 
18
+ // Mounted alongside the unsubscribe route by the same
19
+ // `createUnsubscribeRoutes(...)` call — same token, same verify() path, the
20
+ // undo direction. Token is taken from the JSON/form body only, never the
21
+ // query, so a mail-client link prefetcher can't accidentally resubscribe
22
+ // someone.
23
+ export const DELIVERY_RESUBSCRIBE_PATH = "/api/delivery/resubscribe" as const;
24
+
16
25
  export const DeliveryQueries = {
17
26
  log: "delivery:query:log",
18
27
  preferences: "delivery:query:preferences",
@@ -28,6 +37,10 @@ export const DELIVERY_STATUS_CELL_COMPONENT = "DeliveryStatusCell" as const;
28
37
  export const DeliveryErrors = {
29
38
  noRecipient: "delivery_no_recipient",
30
39
  channelFailed: "delivery_channel_failed",
40
+ // removeAddressOptOut refuses to delete the opt-out row at the last
41
+ // available id generation, so an address can always still unsubscribe
42
+ // (see MAX_ADDRESS_OPT_OUT_GENERATIONS in address-opt-out.ts).
43
+ resubscribeLimitReached: "resubscribe_limit_reached",
31
44
  } as const;
32
45
 
33
46
  export const DeliveryStatus = {
@@ -110,8 +110,11 @@ export const notificationPreferencesTable = pgTable(
110
110
  // `ctx.notify(type, { route: { email } })`). addressHash is a keyed HMAC
111
111
  // (computeBlindIndex over the normalized address) — the plaintext address
112
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).
113
+ // A row's mere existence is the opt-out; there is no `enabled` toggle to flip.
114
+ // Resubscribing (removeAddressOptOut) hard-deletes the row instead — the
115
+ // entity has no softDelete, and a later re-opt-out for the same address
116
+ // picks the next id generation (see addressOptOutAggregateId) since the
117
+ // deleted stream can't be revived.
115
118
  export const notificationAddressOptOutEntity = createEntity({
116
119
  table: "read_notification_address_opt_outs",
117
120
  fields: {