@cosmicdrift/kumiko-bundled-features 0.313.0 → 0.315.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/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +66 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +10 -4
- package/src/compliance-profiles/resolve-for-tenant.ts +15 -5
- package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +6 -6
- package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +1 -1
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +10 -10
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/feature.ts +1 -1
- package/src/data-retention/handlers/policy-for.query.ts +14 -31
- package/src/data-retention/index.ts +2 -0
- package/src/data-retention/resolve-for-tenant.ts +25 -17
- package/src/data-retention/resolve-tenant-preset.ts +5 -2
- package/src/data-retention/run-retention-cleanup.ts +5 -4
- package/src/delivery/__tests__/delivery.integration.test.ts +189 -19
- package/src/delivery/address-opt-out.ts +49 -21
- package/src/delivery/changes.json +12 -0
- package/src/delivery/constants.ts +1 -0
- package/src/delivery/delivery-service.ts +48 -27
- package/src/delivery/feature.ts +5 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +40 -0
- package/src/delivery/handlers/unsubscribe-user.write.ts +43 -0
- package/src/delivery/index.ts +1 -0
- package/src/delivery/public-names.ts +6 -0
- package/src/delivery/unsubscribe.ts +144 -67
- package/src/delivery/upsert-preference.ts +74 -60
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +1 -0
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +2 -1
- package/src/file-derivatives/__tests__/public-variant-predicate-args.integration.test.ts +2 -1
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +4 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/is-tenant-db.ts +9 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +5 -1
- package/src/user-data-rights/__tests__/retention-preset-forget.integration.test.ts +272 -0
- package/src/user-data-rights/run-forget-cleanup.ts +6 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +9 -7
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +7 -2
|
@@ -47,7 +47,12 @@ import { TenantQueries } from "../../tenant/constants";
|
|
|
47
47
|
import { createTenantFeature } from "../../tenant/feature";
|
|
48
48
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
49
49
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
DELIVERY_UNSUBSCRIBE_PATH,
|
|
52
|
+
DeliveryHandlers,
|
|
53
|
+
DeliveryJobs,
|
|
54
|
+
DeliveryQueries,
|
|
55
|
+
} from "../constants";
|
|
51
56
|
import { collectChannels, createDeliveryService } from "../delivery-service";
|
|
52
57
|
import { createDeliveryFeature } from "../feature";
|
|
53
58
|
import { deliveryRenderJob, deliverySendJob } from "../jobs";
|
|
@@ -69,7 +74,7 @@ import {
|
|
|
69
74
|
let stack: TestStack;
|
|
70
75
|
let db: DbConnection;
|
|
71
76
|
let deliveryService: DeliveryService;
|
|
72
|
-
const
|
|
77
|
+
const UNSUBSCRIBE_SECRET = "test-stack-unsubscribe-secret-32-chars-min!!";
|
|
73
78
|
|
|
74
79
|
// Email test infrastructure
|
|
75
80
|
const emailTransport = createInMemoryTransport();
|
|
@@ -368,12 +373,10 @@ beforeAll(async () => {
|
|
|
368
373
|
deliveryService = ctx.deliveryService;
|
|
369
374
|
return ctx;
|
|
370
375
|
},
|
|
376
|
+
extraRoutes: [createUnsubscribeRoute({ secret: UNSUBSCRIBE_SECRET })],
|
|
371
377
|
});
|
|
372
378
|
db = stack.db;
|
|
373
379
|
|
|
374
|
-
// Mount unsubscribe route BEFORE any requests (Hono router locks after first match)
|
|
375
|
-
stack.app.route("/delivery", createUnsubscribeRoute({ db, jwtSecret: JWT_SECRET }));
|
|
376
|
-
|
|
377
380
|
// deliveryAttemptsTable is auto-pushed by setupTestStack as MSP-projection-table;
|
|
378
381
|
// notificationPreferencesTable is an ES-entity, so it still needs explicit
|
|
379
382
|
// push here (entity-tables are not auto-provisioned — only projection ones).
|
|
@@ -798,10 +801,10 @@ describe("flow 7: unsubscribe endpoint", () => {
|
|
|
798
801
|
notificationType: "app:notify:announcement",
|
|
799
802
|
channel: "inApp",
|
|
800
803
|
},
|
|
801
|
-
|
|
804
|
+
UNSUBSCRIBE_SECRET,
|
|
802
805
|
);
|
|
803
806
|
|
|
804
|
-
const res = await stack.app.request(
|
|
807
|
+
const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
|
|
805
808
|
expect(res.status).toBe(200);
|
|
806
809
|
const text = await res.text();
|
|
807
810
|
expect(text).toContain("unsubscribed");
|
|
@@ -819,14 +822,100 @@ describe("flow 7: unsubscribe endpoint", () => {
|
|
|
819
822
|
expect(pref?.["enabled"]).toBe(false);
|
|
820
823
|
});
|
|
821
824
|
|
|
825
|
+
test("preference row belongs to the token's userId, not the dispatching system user", async () => {
|
|
826
|
+
const token = await signUnsubscribeToken(
|
|
827
|
+
{
|
|
828
|
+
userId: user1.id,
|
|
829
|
+
tenantId: user1.tenantId,
|
|
830
|
+
notificationType: "app:notify:flow7-actor-check",
|
|
831
|
+
channel: "inApp",
|
|
832
|
+
},
|
|
833
|
+
UNSUBSCRIBE_SECRET,
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
|
|
837
|
+
expect(res.status).toBe(200);
|
|
838
|
+
|
|
839
|
+
const rows = await selectMany(db, notificationPreferencesTable, {
|
|
840
|
+
userId: user1.id,
|
|
841
|
+
notificationType: "app:notify:flow7-actor-check",
|
|
842
|
+
channel: "inApp",
|
|
843
|
+
});
|
|
844
|
+
expect(rows).toHaveLength(1);
|
|
845
|
+
expect(rows[0]?.["enabled"]).toBe(false);
|
|
846
|
+
});
|
|
847
|
+
|
|
822
848
|
test("invalid token returns 400", async () => {
|
|
823
|
-
const res = await stack.app.request(
|
|
849
|
+
const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=invalid-jwt-token`);
|
|
824
850
|
expect(res.status).toBe(400);
|
|
825
851
|
});
|
|
826
852
|
|
|
827
853
|
test("missing token returns 400", async () => {
|
|
828
|
-
const res = await stack.app.request(
|
|
854
|
+
const res = await stack.app.request(DELIVERY_UNSUBSCRIBE_PATH);
|
|
855
|
+
expect(res.status).toBe(400);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test("normal user cannot dispatch the unsubscribe write handlers directly", async () => {
|
|
859
|
+
const error = await stack.http.writeErr(
|
|
860
|
+
DeliveryHandlers.unsubscribeAddress,
|
|
861
|
+
{ addressHash: "x".repeat(32), notificationType: "app:notify:direct-call", channel: "email" },
|
|
862
|
+
user1,
|
|
863
|
+
);
|
|
864
|
+
expect(error.code).toBe("access_denied");
|
|
865
|
+
|
|
866
|
+
const rows = await selectMany(db, notificationAddressOptOutsTable, {
|
|
867
|
+
notificationType: "app:notify:direct-call",
|
|
868
|
+
channel: "email",
|
|
869
|
+
});
|
|
870
|
+
expect(rows).toHaveLength(0);
|
|
871
|
+
|
|
872
|
+
const userError = await stack.http.writeErr(
|
|
873
|
+
DeliveryHandlers.unsubscribeUser,
|
|
874
|
+
{ userId: user2.id, notificationType: "app:notify:direct-call", channel: "email" },
|
|
875
|
+
user1,
|
|
876
|
+
);
|
|
877
|
+
expect(userError.code).toBe("access_denied");
|
|
878
|
+
|
|
879
|
+
const preferenceRows = await selectMany(db, notificationPreferencesTable, {
|
|
880
|
+
userId: user2.id,
|
|
881
|
+
notificationType: "app:notify:direct-call",
|
|
882
|
+
});
|
|
883
|
+
expect(preferenceRows).toHaveLength(0);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test("wrong issuer is rejected with the generic invalid-token code, not a jose error text", async () => {
|
|
887
|
+
const tamperedIssuer = await new jose.SignJWT({
|
|
888
|
+
tenantId: user2.tenantId,
|
|
889
|
+
notificationType: "app:notify:wrong-issuer",
|
|
890
|
+
channel: "inApp",
|
|
891
|
+
})
|
|
892
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
893
|
+
.setSubject(user2.id)
|
|
894
|
+
.setIssuer("not-kumiko:unsubscribe")
|
|
895
|
+
.setIssuedAt()
|
|
896
|
+
.sign(new TextEncoder().encode(UNSUBSCRIBE_SECRET));
|
|
897
|
+
|
|
898
|
+
const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${tamperedIssuer}`);
|
|
899
|
+
expect(res.status).toBe(400);
|
|
900
|
+
const body = (await res.json()) as { error?: { code?: string; message?: string } };
|
|
901
|
+
expect(body.error?.code).toBe("unsubscribe_token_invalid");
|
|
902
|
+
expect(JSON.stringify(body)).not.toContain("signature verification failed");
|
|
903
|
+
expect(JSON.stringify(body)).not.toContain("claim");
|
|
904
|
+
|
|
905
|
+
const rows = await selectMany(db, notificationPreferencesTable, {
|
|
906
|
+
userId: user2.id,
|
|
907
|
+
notificationType: "app:notify:wrong-issuer",
|
|
908
|
+
channel: "inApp",
|
|
909
|
+
});
|
|
910
|
+
expect(rows).toHaveLength(0);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
test("a real stack session JWT is rejected at the unsubscribe route", async () => {
|
|
914
|
+
const sessionToken = await stack.jwt.sign(user1);
|
|
915
|
+
const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${sessionToken}`);
|
|
829
916
|
expect(res.status).toBe(400);
|
|
917
|
+
const body = (await res.json()) as { error?: { code?: string } };
|
|
918
|
+
expect(body.error?.code).toBe("unsubscribe_token_invalid");
|
|
830
919
|
});
|
|
831
920
|
});
|
|
832
921
|
|
|
@@ -1481,10 +1570,10 @@ describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
|
|
|
1481
1570
|
notificationType: "app:notify:concurrent-unsub",
|
|
1482
1571
|
channel: "email",
|
|
1483
1572
|
},
|
|
1484
|
-
|
|
1573
|
+
UNSUBSCRIBE_SECRET,
|
|
1485
1574
|
);
|
|
1486
1575
|
|
|
1487
|
-
const url =
|
|
1576
|
+
const url = `${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`;
|
|
1488
1577
|
const results = await Promise.all([
|
|
1489
1578
|
stack.app.request(url),
|
|
1490
1579
|
stack.app.request(url),
|
|
@@ -1873,14 +1962,14 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
|
|
|
1873
1962
|
notificationType: "app:notify:address-unsub-19a",
|
|
1874
1963
|
channel: "email",
|
|
1875
1964
|
},
|
|
1876
|
-
|
|
1965
|
+
UNSUBSCRIBE_SECRET,
|
|
1877
1966
|
);
|
|
1878
|
-
const res = await stack.app.request(
|
|
1967
|
+
const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
|
|
1879
1968
|
expect(res.status).toBe(200);
|
|
1880
1969
|
expect(await res.text()).toContain("unsubscribed");
|
|
1881
1970
|
|
|
1882
1971
|
// Clicking twice must stay a no-op (one row, no crash).
|
|
1883
|
-
const res2 = await stack.app.request(
|
|
1972
|
+
const res2 = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
|
|
1884
1973
|
expect(res2.status).toBe(200);
|
|
1885
1974
|
const optOutRows = await selectMany(db, notificationAddressOptOutsTable, {
|
|
1886
1975
|
notificationType: "app:notify:address-unsub-19a",
|
|
@@ -1946,7 +2035,7 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
|
|
|
1946
2035
|
FOREIGN_JWT_SECRET,
|
|
1947
2036
|
);
|
|
1948
2037
|
|
|
1949
|
-
const res = await stack.app.request(
|
|
2038
|
+
const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${forgedToken}`);
|
|
1950
2039
|
expect(res.status).toBe(400);
|
|
1951
2040
|
|
|
1952
2041
|
const rows = await selectMany(db, notificationAddressOptOutsTable, {
|
|
@@ -1965,7 +2054,7 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
|
|
|
1965
2054
|
notificationType: "app:notify:address-unsub-19d",
|
|
1966
2055
|
channel: "email",
|
|
1967
2056
|
},
|
|
1968
|
-
|
|
2057
|
+
UNSUBSCRIBE_SECRET,
|
|
1969
2058
|
);
|
|
1970
2059
|
|
|
1971
2060
|
const payload = jose.decodeJwt(token);
|
|
@@ -1986,9 +2075,9 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
|
|
|
1986
2075
|
notificationType: "app:notify:address-unsub-19e",
|
|
1987
2076
|
channel: "email",
|
|
1988
2077
|
},
|
|
1989
|
-
|
|
2078
|
+
UNSUBSCRIBE_SECRET,
|
|
1990
2079
|
);
|
|
1991
|
-
const res = await stack.app.request(
|
|
2080
|
+
const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
|
|
1992
2081
|
expect(res.status).toBe(200);
|
|
1993
2082
|
|
|
1994
2083
|
await deliveryService.notify(
|
|
@@ -2016,11 +2105,92 @@ describe("flow 19: address unsubscribe (route-based sends, no user account)", ()
|
|
|
2016
2105
|
notificationType: "app:notify:address-unsub-19f",
|
|
2017
2106
|
channel: "email",
|
|
2018
2107
|
},
|
|
2019
|
-
|
|
2108
|
+
UNSUBSCRIBE_SECRET,
|
|
2020
2109
|
),
|
|
2021
2110
|
).rejects.toThrow(/blind-index key/);
|
|
2022
2111
|
} finally {
|
|
2023
2112
|
configureBlindIndexKey(ADDRESS_BIDX_KEY);
|
|
2024
2113
|
}
|
|
2025
2114
|
});
|
|
2115
|
+
|
|
2116
|
+
// #3275: deliverToUser must also honor an address opt-out, not just
|
|
2117
|
+
// deliverDirect's route-based sends — the two paths shared the same
|
|
2118
|
+
// isAddressSuppressed check as of this change.
|
|
2119
|
+
test("account send is suppressed when the resolved address has an opt-out row (#3275)", async () => {
|
|
2120
|
+
await stack.redis.redis.del(RATE_KEY_EMAIL);
|
|
2121
|
+
const notificationType = "app:notify:account-unsub-20";
|
|
2122
|
+
const address = testEmail(user1.id);
|
|
2123
|
+
|
|
2124
|
+
const token = await signAddressUnsubscribeToken(
|
|
2125
|
+
{ tenantId: user1.tenantId, address, notificationType, channel: "email" },
|
|
2126
|
+
UNSUBSCRIBE_SECRET,
|
|
2127
|
+
);
|
|
2128
|
+
const res = await stack.app.request(`${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`);
|
|
2129
|
+
expect(res.status).toBe(200);
|
|
2130
|
+
|
|
2131
|
+
emailTransport.sent.length = 0;
|
|
2132
|
+
stack.events.reset();
|
|
2133
|
+
|
|
2134
|
+
await stack.http.writeOk(
|
|
2135
|
+
"app:write:send-notification",
|
|
2136
|
+
{ notificationType, toUserId: user1.id, title: "Konto-Unsub", body: "X" },
|
|
2137
|
+
admin,
|
|
2138
|
+
);
|
|
2139
|
+
|
|
2140
|
+
expect(emailTransport.sent.some((e) => e.to === address)).toBe(false);
|
|
2141
|
+
const emailLogs = await selectMany(db, deliveryAttemptsTable, {
|
|
2142
|
+
notificationType,
|
|
2143
|
+
recipientId: user1.id,
|
|
2144
|
+
channel: "email",
|
|
2145
|
+
});
|
|
2146
|
+
expect(emailLogs.some((l) => l["status"] === "skipped" && l["error"] === "unsubscribed")).toBe(
|
|
2147
|
+
true,
|
|
2148
|
+
);
|
|
2149
|
+
expect(emailLogs.every((l) => l["recipientAddress"] !== address)).toBe(true);
|
|
2150
|
+
|
|
2151
|
+
// inApp is unaffected — the opt-out is per-channel.
|
|
2152
|
+
const inAppNotifs = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
2153
|
+
expect(inAppNotifs.some((e) => e.data["userId"] === user1.id)).toBe(true);
|
|
2154
|
+
|
|
2155
|
+
// Critical priority still gets delivered, same rule as the direct-route path.
|
|
2156
|
+
emailTransport.sent.length = 0;
|
|
2157
|
+
await deliveryService.notify(
|
|
2158
|
+
notificationType,
|
|
2159
|
+
{ to: user1.id, data: { title: "Critical", body: "X" }, priority: "critical" },
|
|
2160
|
+
admin,
|
|
2161
|
+
admin.tenantId,
|
|
2162
|
+
);
|
|
2163
|
+
expect(emailTransport.sent.some((e) => e.to === address)).toBe(true);
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
// Address-path counterpart to flow 16 — same deterministic-aggregate-id
|
|
2167
|
+
// race, but through upsertAddressOptOut's create-or-noop instead of
|
|
2168
|
+
// upsertPreference's create-or-update.
|
|
2169
|
+
test("clicking the same address unsubscribe link three times concurrently does not error", async () => {
|
|
2170
|
+
const address = "flow19-concurrent@test.com";
|
|
2171
|
+
const notificationType = "app:notify:address-unsub-concurrent";
|
|
2172
|
+
|
|
2173
|
+
const token = await signAddressUnsubscribeToken(
|
|
2174
|
+
{ tenantId: admin.tenantId, address, notificationType, channel: "email" },
|
|
2175
|
+
UNSUBSCRIBE_SECRET,
|
|
2176
|
+
);
|
|
2177
|
+
|
|
2178
|
+
const url = `${DELIVERY_UNSUBSCRIBE_PATH}?token=${token}`;
|
|
2179
|
+
const results = await Promise.all([
|
|
2180
|
+
stack.app.request(url),
|
|
2181
|
+
stack.app.request(url),
|
|
2182
|
+
stack.app.request(url),
|
|
2183
|
+
]);
|
|
2184
|
+
|
|
2185
|
+
for (const res of results) {
|
|
2186
|
+
expect(res.status).toBe(200);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
const rows = await selectMany(db, notificationAddressOptOutsTable, {
|
|
2190
|
+
tenantId: admin.tenantId,
|
|
2191
|
+
notificationType,
|
|
2192
|
+
channel: "email",
|
|
2193
|
+
});
|
|
2194
|
+
expect(rows).toHaveLength(1);
|
|
2195
|
+
});
|
|
2026
2196
|
});
|
|
@@ -2,8 +2,8 @@ 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 { generateDeterministicId } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
6
|
import { notificationAddressOptOutEntity, notificationAddressOptOutsTable } from "./tables";
|
|
6
|
-
import { isUniqueViolation } from "./upsert-preference";
|
|
7
7
|
|
|
8
8
|
const executor = createEventStoreExecutor(
|
|
9
9
|
notificationAddressOptOutsTable,
|
|
@@ -45,6 +45,22 @@ async function lookup(
|
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// One row per (tenant, addressHash, type, channel): mirrors
|
|
49
|
+
// preferenceAggregateId in upsert-preference.ts — concurrent first-time
|
|
50
|
+
// opt-outs collide on the same stream at append, not on the projection's
|
|
51
|
+
// unique index.
|
|
52
|
+
function addressOptOutAggregateId(
|
|
53
|
+
tenantId: TenantId,
|
|
54
|
+
addressHash: string,
|
|
55
|
+
notificationType: string,
|
|
56
|
+
channel: string,
|
|
57
|
+
): string {
|
|
58
|
+
return generateDeterministicId(
|
|
59
|
+
"delivery:notification-address-opt-out",
|
|
60
|
+
`${tenantId}|${addressHash}|${notificationType}|${channel}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
/**
|
|
49
65
|
* Create-or-noop: opting the same address out twice must not produce a
|
|
50
66
|
* second row or fail the second click. There is no update path — unlike
|
|
@@ -64,24 +80,36 @@ export async function upsertAddressOptOut(
|
|
|
64
80
|
);
|
|
65
81
|
if (existing) return { isSuccess: true, data: input };
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
const id = addressOptOutAggregateId(
|
|
84
|
+
input.tenantId,
|
|
85
|
+
input.addressHash,
|
|
86
|
+
input.notificationType,
|
|
87
|
+
input.channel,
|
|
88
|
+
);
|
|
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,
|
|
108
|
+
input.tenantId,
|
|
109
|
+
input.addressHash,
|
|
110
|
+
input.notificationType,
|
|
111
|
+
input.channel,
|
|
112
|
+
);
|
|
113
|
+
if (!afterRace) return created;
|
|
114
|
+
return { isSuccess: true, data: input };
|
|
87
115
|
}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.315.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "delivery honors an address opt-out on sends to a user account",
|
|
6
|
+
"detail": "`deliverToUser` only checked the user's notification preferences, so an\naddress that had been opted out via an address unsubscribe token still got\nmail once it belonged to a user account. It now runs the same check\n`deliverDirect` already applied: when the resolved channel address has a\n`notification-address-opt-out` row for that tenant, notificationType and\nchannel, the attempt is logged as `skipped` / `unsubscribed` with\n`recipientAddress: null`. Critical priority still bypasses it\n(kumiko-framework#3275)."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.315.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Delivery unsubscribe route is now mountable via extraRoutes",
|
|
12
|
+
"detail": "`createUnsubscribeRoute` previously returned a raw Hono app that needed a\n`DbConnection` (`{ db, jwtSecret }`) and had to be mounted by hand with\n`stack.app.route(...)`, bypassing the framework's request pipeline. It now\nreturns an `ExtraRouteDefinition` built via `signatureRoute`, taking only\n`{ secret }`, and mounts at the fixed path `GET /api/delivery/unsubscribe?token=`\n(exported as `DELIVERY_UNSUBSCRIBE_PATH`). Mount it with\n`extraRoutes: [createUnsubscribeRoute({ secret: env.UNSUBSCRIBE_SECRET })]`.\n`signUnsubscribeToken` / `signAddressUnsubscribeToken` must sign with the\nsame secret. The secret must be at least 32 characters and must NOT reuse\nthe app's session `JWT_SECRET` — all three entry points fail fast on a\nshort secret.\nThe write itself now goes through two new SystemAdmin-only handlers,\n`delivery:write:unsubscribe-address` and `delivery:write:unsubscribe-user`,\ndispatched via `dispatchSystemWrite` once the route's `verify()` has proven\nthe token's authenticity. An invalid or expired token always responds 400\nwith `{ error: { code: \"unsubscribe_token_invalid\" } }`, never leaking the\nunderlying JWT-library error text.\nNew notification-preference and notification-address-opt-out rows get a\ndeterministic aggregate id derived from (tenant, user or address hash,\nnotificationType, channel), so concurrent clicks on the same link collide\nat the event-store append and converge on one row instead of failing with\na unique or version conflict. Existing rows keep their ids."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.313.0",
|
|
4
16
|
"type": "breaking",
|
|
@@ -321,6 +321,20 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
321
321
|
};
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
// Shared by deliverToUser (resolved account address) and deliverDirect (a
|
|
325
|
+
// route address with no user account) — same suppression rule either way:
|
|
326
|
+
// no blind-index key configured means no hash, so nothing to look up.
|
|
327
|
+
async function isAddressSuppressed(
|
|
328
|
+
address: string,
|
|
329
|
+
tenantId: TenantId,
|
|
330
|
+
notificationType: string,
|
|
331
|
+
channelName: string,
|
|
332
|
+
): Promise<boolean> {
|
|
333
|
+
const addressHash = hashUnsubscribeAddress(address);
|
|
334
|
+
if (addressHash === undefined) return false;
|
|
335
|
+
return isAddressOptedOut(db, tenantId, addressHash, notificationType, channelName);
|
|
336
|
+
}
|
|
337
|
+
|
|
324
338
|
// Check if user has disabled this notification+channel combo.
|
|
325
339
|
// Specificity order: exact > any wildcard. When only wildcards match and they
|
|
326
340
|
// disagree, "disabled wins" — the user has asked to be opted out somewhere,
|
|
@@ -439,6 +453,23 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
439
453
|
continue;
|
|
440
454
|
}
|
|
441
455
|
|
|
456
|
+
if (
|
|
457
|
+
priority !== "critical" &&
|
|
458
|
+
(await isAddressSuppressed(address, tenantId, notificationType, channel.name))
|
|
459
|
+
) {
|
|
460
|
+
await logDelivery({
|
|
461
|
+
tenantId,
|
|
462
|
+
notificationType,
|
|
463
|
+
channel: channel.name,
|
|
464
|
+
recipientId: userId,
|
|
465
|
+
recipientAddress: null,
|
|
466
|
+
status: "skipped",
|
|
467
|
+
error: "unsubscribed",
|
|
468
|
+
priority,
|
|
469
|
+
});
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
442
473
|
await deliverViaChannel({
|
|
443
474
|
channel,
|
|
444
475
|
address,
|
|
@@ -482,33 +513,23 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
482
513
|
if (!address) continue;
|
|
483
514
|
|
|
484
515
|
// Address opt-out (critical priority skips it, same rule as user
|
|
485
|
-
// preferences).
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
recipientId,
|
|
503
|
-
// The recipient withdrew — suppressed attempts must not keep recording the address.
|
|
504
|
-
recipientAddress: null,
|
|
505
|
-
status: "skipped",
|
|
506
|
-
error: "unsubscribed",
|
|
507
|
-
priority,
|
|
508
|
-
});
|
|
509
|
-
continue;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
516
|
+
// preferences).
|
|
517
|
+
if (
|
|
518
|
+
priority !== "critical" &&
|
|
519
|
+
(await isAddressSuppressed(address, tenantId, notificationType, channel.name))
|
|
520
|
+
) {
|
|
521
|
+
await logDelivery({
|
|
522
|
+
tenantId,
|
|
523
|
+
notificationType,
|
|
524
|
+
channel: channel.name,
|
|
525
|
+
recipientId,
|
|
526
|
+
// The recipient withdrew — suppressed attempts must not keep recording the address.
|
|
527
|
+
recipientAddress: null,
|
|
528
|
+
status: "skipped",
|
|
529
|
+
error: "unsubscribed",
|
|
530
|
+
priority,
|
|
531
|
+
});
|
|
532
|
+
continue;
|
|
512
533
|
}
|
|
513
534
|
|
|
514
535
|
if (rateLimit) {
|
package/src/delivery/feature.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { deliveryAttemptSchema } from "./events";
|
|
|
18
18
|
import { logQuery } from "./handlers/log.query";
|
|
19
19
|
import { preferencesQuery } from "./handlers/preferences.query";
|
|
20
20
|
import { setPreferenceWrite } from "./handlers/set-preference.write";
|
|
21
|
+
import { unsubscribeAddressWrite } from "./handlers/unsubscribe-address.write";
|
|
22
|
+
import { unsubscribeUserWrite } from "./handlers/unsubscribe-user.write";
|
|
21
23
|
import { DELIVERY_I18N } from "./i18n";
|
|
22
24
|
import { deliveryRenderJob, deliverySendJob } from "./jobs";
|
|
23
25
|
import {
|
|
@@ -42,7 +44,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
42
44
|
const resolvedAccess = options?.access ?? access.admin;
|
|
43
45
|
return defineFeature("delivery", (r) => {
|
|
44
46
|
r.describe(
|
|
45
|
-
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
|
|
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 `createUnsubscribeRoute({ secret })` mounted via the app's `extraRoutes` at `GET /api/delivery/unsubscribe?token=`; sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret.",
|
|
46
48
|
);
|
|
47
49
|
r.uiHints({
|
|
48
50
|
displayLabel: "Notifications \u00b7 Dispatch Core",
|
|
@@ -148,6 +150,8 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
148
150
|
|
|
149
151
|
const handlers = {
|
|
150
152
|
setPreference: r.writeHandler(setPreferenceWrite),
|
|
153
|
+
unsubscribeAddress: r.writeHandler(unsubscribeAddressWrite),
|
|
154
|
+
unsubscribeUser: r.writeHandler(unsubscribeUserWrite),
|
|
151
155
|
};
|
|
152
156
|
|
|
153
157
|
const queries = {
|
|
@@ -0,0 +1,40 @@
|
|
|
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 { upsertAddressOptOut } from "../address-opt-out";
|
|
5
|
+
|
|
6
|
+
export const unsubscribeAddressWrite = defineWriteHandler({
|
|
7
|
+
name: "unsubscribeAddress",
|
|
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 createUnsubscribeRoute's dispatchSystemWrite, once
|
|
15
|
+
// verify() has already proven the address-token signature — not a surface
|
|
16
|
+
// for the agent tool-picker to offer a user directly.
|
|
17
|
+
agent: { expose: false },
|
|
18
|
+
description:
|
|
19
|
+
"Records a no-account address opt-out for one notificationType/channel combination; dispatched by the signed unsubscribe route, not meant for UI callers.",
|
|
20
|
+
handler: async (event, ctx) => {
|
|
21
|
+
const { addressHash, notificationType, channel } = event.payload;
|
|
22
|
+
const { tenantId } = event.user;
|
|
23
|
+
|
|
24
|
+
if (!ctx.systemDb) {
|
|
25
|
+
throw new InternalError({
|
|
26
|
+
message: "unsubscribeAddress: ctx.systemDb missing on a system-scoped handler",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const db = ctx.systemDb.assertTenantMatch(tenantId);
|
|
30
|
+
|
|
31
|
+
const result = await upsertAddressOptOut(db, event.user, {
|
|
32
|
+
tenantId,
|
|
33
|
+
addressHash,
|
|
34
|
+
notificationType,
|
|
35
|
+
channel,
|
|
36
|
+
});
|
|
37
|
+
if (!result.isSuccess) return result;
|
|
38
|
+
return { isSuccess: true, data: { notificationType, channel } };
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
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 unsubscribeUserWrite = defineWriteHandler({
|
|
7
|
+
name: "unsubscribeUser",
|
|
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 createUnsubscribeRoute's dispatchSystemWrite, once
|
|
15
|
+
// verify() has already proven the user-token signature — not a surface for
|
|
16
|
+
// the agent tool-picker to offer a user directly.
|
|
17
|
+
agent: { expose: false },
|
|
18
|
+
description:
|
|
19
|
+
"Disables one notification type and channel combination for the userId carried in a verified unsubscribe token; dispatched by the signed unsubscribe route, not meant for UI callers.",
|
|
20
|
+
handler: async (event, ctx) => {
|
|
21
|
+
// userId comes from the payload, not event.user.id — the caller here is
|
|
22
|
+
// the system user dispatchSystemWrite runs as, not the user unsubscribing.
|
|
23
|
+
const { userId, notificationType, channel } = event.payload;
|
|
24
|
+
const { tenantId } = event.user;
|
|
25
|
+
|
|
26
|
+
if (!ctx.systemDb) {
|
|
27
|
+
throw new InternalError({
|
|
28
|
+
message: "unsubscribeUser: ctx.systemDb missing on a system-scoped handler",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const db = ctx.systemDb.assertTenantMatch(tenantId);
|
|
32
|
+
|
|
33
|
+
const result = await upsertPreference(db, event.user, {
|
|
34
|
+
tenantId,
|
|
35
|
+
userId,
|
|
36
|
+
notificationType,
|
|
37
|
+
channel,
|
|
38
|
+
enabled: false,
|
|
39
|
+
});
|
|
40
|
+
if (!result.isSuccess) return result;
|
|
41
|
+
return { isSuccess: true, data: { notificationType, channel } };
|
|
42
|
+
},
|
|
43
|
+
});
|
package/src/delivery/index.ts
CHANGED
|
@@ -5,8 +5,14 @@ export const DELIVERY_FEATURE = "delivery" as const;
|
|
|
5
5
|
|
|
6
6
|
export const DeliveryHandlers = {
|
|
7
7
|
setPreference: "delivery:write:set-preference",
|
|
8
|
+
unsubscribeAddress: "delivery:write:unsubscribe-address",
|
|
9
|
+
unsubscribeUser: "delivery:write:unsubscribe-user",
|
|
8
10
|
} as const;
|
|
9
11
|
|
|
12
|
+
// Fixed so links mailed out today keep working — the unsubscribe route is
|
|
13
|
+
// mounted at this exact path via `extraRoutes: [createUnsubscribeRoute(...)]`.
|
|
14
|
+
export const DELIVERY_UNSUBSCRIBE_PATH = "/api/delivery/unsubscribe" as const;
|
|
15
|
+
|
|
10
16
|
export const DeliveryQueries = {
|
|
11
17
|
log: "delivery:query:log",
|
|
12
18
|
preferences: "delivery:query:preferences",
|