@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.
- package/package.json +9 -9
- package/src/agent-tools/__tests__/kms-erase-gate.integration.test.ts +127 -0
- package/src/agent-tools/__tests__/tool-catalog.test.ts +16 -1
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
- package/src/agent-tools/agent-manifest.ts +13 -3
- package/src/agent-tools/tool-dispatch.ts +17 -12
- 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/crypto-shredding/handlers/forget-subject.write.ts +3 -1
- 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/tenant/__tests__/members-facet-i18n.test.ts +3 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +40 -3
- 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
|
-
|
|
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
|
|
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
|
-
`${
|
|
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 = {
|