@cosmicdrift/kumiko-bundled-features 0.322.0 → 0.324.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.
- package/package.json +9 -9
- package/src/agent-tools/__tests__/kms-erase-gate.integration.test.ts +127 -0
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +66 -1
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +124 -5
- package/src/auth-email-password/changes.json +7 -0
- package/src/auth-email-password/constants.ts +3 -0
- package/src/auth-email-password/feature.ts +4 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +5 -6
- package/src/auth-email-password/handlers/invite-info.query.ts +89 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +232 -0
- package/src/delivery/address-opt-out.ts +113 -24
- package/src/delivery/changes.json +7 -0
- package/src/delivery/constants.ts +1 -0
- package/src/delivery/feature.ts +5 -1
- package/src/delivery/handlers/resubscribe-address.write.ts +42 -0
- package/src/delivery/handlers/resubscribe-user.write.ts +41 -0
- package/src/delivery/public-names.ts +13 -0
- package/src/delivery/tables.ts +5 -2
- package/src/delivery/unsubscribe.ts +104 -7
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +72 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +40 -3
|
@@ -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
|
-
`${
|
|
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.
|
|
67
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 (
|
|
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",
|
package/src/delivery/feature.ts
CHANGED
|
@@ -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
|
|
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 = {
|
package/src/delivery/tables.ts
CHANGED
|
@@ -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
|
-
//
|
|
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: {
|