@cosmicdrift/kumiko-bundled-features 0.311.0 → 0.313.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/__tests__/extension-points-typed.test.ts +8 -0
- package/src/agent-tools/__tests__/agent-manifest.test.ts +30 -0
- package/src/agent-tools/agent-manifest.ts +7 -1
- package/src/agent-tools/changes.json +8 -1
- package/src/audit/changes.json +6 -0
- package/src/audit/feature.ts +1 -2
- package/src/audit/i18n.ts +3 -0
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +424 -0
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +75 -5
- package/src/auth-email-password/handlers/signup-request.write.ts +60 -0
- package/src/auth-email-password/signup-token-store.ts +87 -1
- package/src/auth-email-password/web/auth-client.ts +10 -1
- package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +160 -0
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/run-retention-cleanup.ts +160 -18
- package/src/delivery/__tests__/delivery.integration.test.ts +206 -3
- package/src/delivery/address-opt-out.ts +87 -0
- package/src/delivery/changes.json +7 -0
- package/src/delivery/db/queries/address-opt-outs.ts +23 -0
- package/src/delivery/delivery-service.ts +32 -0
- package/src/delivery/feature.ts +9 -1
- package/src/delivery/index.ts +5 -0
- package/src/delivery/tables.ts +37 -0
- package/src/delivery/unsubscribe.ts +101 -16
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +621 -43
- package/src/document-ingest-foundation/__tests__/feature.test.ts +20 -3
- package/src/document-ingest-foundation/__tests__/providers.test.ts +204 -0
- package/src/document-ingest-foundation/boot-checks.ts +58 -0
- package/src/document-ingest-foundation/changes.json +18 -0
- package/src/document-ingest-foundation/events.ts +3 -0
- package/src/document-ingest-foundation/executor.ts +12 -0
- package/src/document-ingest-foundation/feature.ts +125 -72
- package/src/document-ingest-foundation/file-ref-liveness.ts +10 -0
- package/src/document-ingest-foundation/forget-extract-with-file-ref.ts +84 -0
- package/src/document-ingest-foundation/index.ts +17 -0
- package/src/document-ingest-foundation/providers.ts +78 -0
- package/src/document-ingest-foundation/tenant-destroy-hook.ts +3 -7
- package/src/document-ingest-foundation/write-document-extract.ts +48 -0
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +5 -0
- package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
- package/src/shared/index.ts +10 -1
- package/src/shared/run-in-sub-transaction.ts +35 -0
- package/src/shared/signup-handover.ts +67 -0
- package/src/shared/single-use-token-store.ts +25 -5
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
- package/src/subscription-stripe/changes.json +9 -1
- package/src/subscription-stripe/feature.ts +13 -1
- package/src/subscription-stripe/plugin-methods.ts +17 -2
- package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant/index.ts +1 -0
- package/src/tenant/is-tenant-serving-public-content.ts +13 -0
- package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
- package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
- package/src/tenant/web/member-roles-cell.tsx +11 -2
- package/src/tenant/web/member-status-cell.tsx +6 -2
- package/src/tenant/web/translate-or-raw.ts +13 -0
- package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
- package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
- package/src/tenant-handover/changes.json +12 -0
- package/src/tenant-handover/events.ts +6 -0
- package/src/tenant-handover/feature.ts +11 -0
- package/src/tenant-handover/handlers/claim.write.ts +8 -25
- package/src/tenant-handover/move-entity-graph.ts +30 -1
- package/src/tenant-handover/root-anchor.ts +50 -0
- package/src/tenant-handover/signup-handover-provider.ts +62 -0
- package/src/tenant-handover/transfer-graph.ts +6 -2
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/feature.ts +1 -2
- package/src/user-data-rights/i18n.ts +3 -0
- package/src/user-data-rights/run-forget-cleanup.ts +1 -38
|
@@ -14,9 +14,11 @@ import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipelin
|
|
|
14
14
|
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
|
|
15
15
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
16
16
|
import type { Redis } from "ioredis";
|
|
17
|
+
import { hashUnsubscribeAddress } from "./address-opt-out";
|
|
17
18
|
import { appendAttemptEvent, logAttempt } from "./attempt-log";
|
|
18
19
|
import { buildChannelContext } from "./channel-context";
|
|
19
20
|
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants";
|
|
21
|
+
import { isAddressOptedOut } from "./db/queries/address-opt-outs";
|
|
20
22
|
import { selectNotificationPreferences } from "./db/queries/preferences";
|
|
21
23
|
import {
|
|
22
24
|
type ChannelContext,
|
|
@@ -479,6 +481,36 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
479
481
|
const message = buildMessage(notificationType, data, channel.name);
|
|
480
482
|
if (!address) continue;
|
|
481
483
|
|
|
484
|
+
// Address opt-out (critical priority skips it, same rule as user
|
|
485
|
+
// preferences). No blind-index key configured → no hash → nothing to
|
|
486
|
+
// look up, skip the check instead of failing the send.
|
|
487
|
+
if (priority !== "critical") {
|
|
488
|
+
const addressHash = hashUnsubscribeAddress(address);
|
|
489
|
+
if (addressHash !== undefined) {
|
|
490
|
+
const optedOut = await isAddressOptedOut(
|
|
491
|
+
db,
|
|
492
|
+
tenantId,
|
|
493
|
+
addressHash,
|
|
494
|
+
notificationType,
|
|
495
|
+
channel.name,
|
|
496
|
+
);
|
|
497
|
+
if (optedOut) {
|
|
498
|
+
await logDelivery({
|
|
499
|
+
tenantId,
|
|
500
|
+
notificationType,
|
|
501
|
+
channel: channel.name,
|
|
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
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
482
514
|
if (rateLimit) {
|
|
483
515
|
const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
|
|
484
516
|
if (!allowed) {
|
package/src/delivery/feature.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { deliveryRenderJob, deliverySendJob } from "./jobs";
|
|
|
23
23
|
import {
|
|
24
24
|
deliveryAttemptsTable,
|
|
25
25
|
deliveryAttemptsTableMeta,
|
|
26
|
+
notificationAddressOptOutEntity,
|
|
27
|
+
notificationAddressOptOutsTable,
|
|
26
28
|
notificationPreferenceEntity,
|
|
27
29
|
notificationPreferencesTable,
|
|
28
30
|
} from "./tables";
|
|
@@ -40,7 +42,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
40
42
|
const resolvedAccess = options?.access ?? access.admin;
|
|
41
43
|
return defineFeature("delivery", (r) => {
|
|
42
44
|
r.describe(
|
|
43
|
-
"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, 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.",
|
|
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.",
|
|
44
46
|
);
|
|
45
47
|
r.uiHints({
|
|
46
48
|
displayLabel: "Notifications \u00b7 Dispatch Core",
|
|
@@ -56,6 +58,12 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
56
58
|
r.entity("notification-preference", notificationPreferenceEntity, {
|
|
57
59
|
table: notificationPreferencesTable,
|
|
58
60
|
});
|
|
61
|
+
// Same ride-along reason as notification-preference above: the
|
|
62
|
+
// (tenant,addressHash,type,channel) uniqueIndex lives only on the
|
|
63
|
+
// physical table.
|
|
64
|
+
r.entity("notification-address-opt-out", notificationAddressOptOutEntity, {
|
|
65
|
+
table: notificationAddressOptOutsTable,
|
|
66
|
+
});
|
|
59
67
|
r.storeTable(deliveryAttemptsTableMeta, {
|
|
60
68
|
reason: "read_side.delivery_attempt_log",
|
|
61
69
|
});
|
package/src/delivery/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { hashUnsubscribeAddress } from "./address-opt-out";
|
|
1
2
|
export type { DeliveryStatusValue } from "./constants";
|
|
2
3
|
export {
|
|
3
4
|
DELIVERY_CHANNEL_EXTENSION,
|
|
@@ -19,6 +20,8 @@ export {
|
|
|
19
20
|
export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature";
|
|
20
21
|
export {
|
|
21
22
|
deliveryAttemptsTable,
|
|
23
|
+
notificationAddressOptOutEntity,
|
|
24
|
+
notificationAddressOptOutsTable,
|
|
22
25
|
notificationPreferenceEntity,
|
|
23
26
|
notificationPreferencesTable,
|
|
24
27
|
} from "./tables";
|
|
@@ -38,7 +41,9 @@ export {
|
|
|
38
41
|
type RendererInput,
|
|
39
42
|
} from "./types";
|
|
40
43
|
export {
|
|
44
|
+
type AddressUnsubscribeTokenPayload,
|
|
41
45
|
createUnsubscribeRoute,
|
|
46
|
+
signAddressUnsubscribeToken,
|
|
42
47
|
signUnsubscribeToken,
|
|
43
48
|
type UnsubscribeRouteOptions,
|
|
44
49
|
type UnsubscribeTokenPayload,
|
package/src/delivery/tables.ts
CHANGED
|
@@ -105,3 +105,40 @@ export const notificationPreferencesTable = pgTable(
|
|
|
105
105
|
),
|
|
106
106
|
],
|
|
107
107
|
);
|
|
108
|
+
|
|
109
|
+
// Opt-out for a recipient ADDRESS that has no user account (direct sends via
|
|
110
|
+
// `ctx.notify(type, { route: { email } })`). addressHash is a keyed HMAC
|
|
111
|
+
// (computeBlindIndex over the normalized address) — the plaintext address
|
|
112
|
+
// never reaches this entity or the token that unsubscribes it.
|
|
113
|
+
// A row's mere existence is the opt-out; there is no `enabled` toggle to flip
|
|
114
|
+
// back (re-subscribing a no-account address has no signed-in flow to do it from).
|
|
115
|
+
export const notificationAddressOptOutEntity = createEntity({
|
|
116
|
+
table: "read_notification_address_opt_outs",
|
|
117
|
+
fields: {
|
|
118
|
+
addressHash: createTextField({ required: true, personal: false, reason: "keyed_hash" }),
|
|
119
|
+
notificationType: createTextField({
|
|
120
|
+
required: true,
|
|
121
|
+
personal: false,
|
|
122
|
+
reason: "technical_reference",
|
|
123
|
+
}),
|
|
124
|
+
channel: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export const notificationAddressOptOutsTable = pgTable(
|
|
129
|
+
"read_notification_address_opt_outs",
|
|
130
|
+
{
|
|
131
|
+
...buildBaseColumns(false, "uuid"),
|
|
132
|
+
addressHash: text("address_hash").notNull(),
|
|
133
|
+
notificationType: text("notification_type").notNull(),
|
|
134
|
+
channel: text("channel").notNull(),
|
|
135
|
+
},
|
|
136
|
+
(table) => [
|
|
137
|
+
uniqueIndex("read_notification_address_opt_outs_unique").on(
|
|
138
|
+
table.tenantId,
|
|
139
|
+
table.addressHash,
|
|
140
|
+
table.notificationType,
|
|
141
|
+
table.channel,
|
|
142
|
+
),
|
|
143
|
+
],
|
|
144
|
+
);
|
|
@@ -3,17 +3,38 @@ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import * as jose from "jose";
|
|
5
5
|
import * as z from "zod";
|
|
6
|
+
import { hashUnsubscribeAddress, upsertAddressOptOut } from "./address-opt-out";
|
|
6
7
|
import { upsertPreference } from "./upsert-preference";
|
|
7
8
|
|
|
8
9
|
// Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
|
|
9
10
|
// wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
|
|
11
|
+
//
|
|
12
|
+
// `kind` is explicitly typed as "absent or undefined, nothing else" here
|
|
13
|
+
// (not bare `.optional()`, which would also accept any other value on an
|
|
14
|
+
// unrelated key) so an address-token payload — which always carries
|
|
15
|
+
// `kind: "address"` — fails this schema instead of silently parsing with
|
|
16
|
+
// `addressHash` mistaken for a userId.
|
|
10
17
|
const unsubscribeJwtPayloadSchema = z.object({
|
|
18
|
+
kind: z.undefined().optional(),
|
|
11
19
|
sub: z.string().min(1),
|
|
12
20
|
tenantId: z.string().min(1),
|
|
13
21
|
notificationType: z.string().min(1),
|
|
14
22
|
channel: z.string().min(1),
|
|
15
23
|
});
|
|
16
24
|
|
|
25
|
+
// Address-token payload. `sub` and `addressHash` are the same value (the
|
|
26
|
+
// subject claim IS the blind-index hash) — kept as two fields because the
|
|
27
|
+
// subject claim is jose's own mechanism while `addressHash` is what the
|
|
28
|
+
// route actually writes; the handler cross-checks them below.
|
|
29
|
+
const addressUnsubscribeJwtPayloadSchema = z.object({
|
|
30
|
+
kind: z.literal("address"),
|
|
31
|
+
sub: z.string().min(1),
|
|
32
|
+
tenantId: z.string().min(1),
|
|
33
|
+
notificationType: z.string().min(1),
|
|
34
|
+
channel: z.string().min(1),
|
|
35
|
+
addressHash: z.string().min(1),
|
|
36
|
+
});
|
|
37
|
+
|
|
17
38
|
export type UnsubscribeTokenPayload = {
|
|
18
39
|
readonly userId: string;
|
|
19
40
|
readonly tenantId: TenantId;
|
|
@@ -21,6 +42,13 @@ export type UnsubscribeTokenPayload = {
|
|
|
21
42
|
readonly channel: string;
|
|
22
43
|
};
|
|
23
44
|
|
|
45
|
+
export type AddressUnsubscribeTokenPayload = {
|
|
46
|
+
readonly tenantId: TenantId;
|
|
47
|
+
readonly address: string;
|
|
48
|
+
readonly notificationType: string;
|
|
49
|
+
readonly channel: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
24
52
|
export type UnsubscribeRouteOptions = {
|
|
25
53
|
readonly db: DbConnection;
|
|
26
54
|
readonly jwtSecret: string;
|
|
@@ -51,6 +79,45 @@ export async function signUnsubscribeToken(
|
|
|
51
79
|
.sign(encodedSecret);
|
|
52
80
|
}
|
|
53
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Signs an unsubscribe link for a recipient ADDRESS with no user account
|
|
84
|
+
* (direct sends via `ctx.notify(type, { route: { email } })`). Throws when
|
|
85
|
+
* no blind-index key is configured — fail closed, since without a key the
|
|
86
|
+
* opt-out row this token later writes could never be matched against a
|
|
87
|
+
* future send (the hash wouldn't be reproducible).
|
|
88
|
+
*/
|
|
89
|
+
export async function signAddressUnsubscribeToken(
|
|
90
|
+
payload: AddressUnsubscribeTokenPayload,
|
|
91
|
+
secret: string,
|
|
92
|
+
): Promise<string> {
|
|
93
|
+
const addressHash = hashUnsubscribeAddress(payload.address);
|
|
94
|
+
if (addressHash === undefined) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"signAddressUnsubscribeToken requires a configured blind-index key (configureBlindIndexKey)",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const encodedSecret = new TextEncoder().encode(secret);
|
|
100
|
+
return (
|
|
101
|
+
new jose.SignJWT({
|
|
102
|
+
tenantId: payload.tenantId,
|
|
103
|
+
notificationType: payload.notificationType,
|
|
104
|
+
channel: payload.channel,
|
|
105
|
+
addressHash,
|
|
106
|
+
kind: "address",
|
|
107
|
+
})
|
|
108
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
109
|
+
.setSubject(addressHash)
|
|
110
|
+
.setIssuer("kumiko:unsubscribe")
|
|
111
|
+
.setIssuedAt()
|
|
112
|
+
// No expiry, unlike the user token above: a leaked address token can
|
|
113
|
+
// only opt that one address out of one notificationType/channel, and
|
|
114
|
+
// an unsubscribe link mailed out today must keep working indefinitely
|
|
115
|
+
// — there is no signed-in flow for a no-account address to re-subscribe
|
|
116
|
+
// or request a fresh link from.
|
|
117
|
+
.sign(encodedSecret)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
54
121
|
export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): Hono {
|
|
55
122
|
const { db, jwtSecret } = options;
|
|
56
123
|
const encodedSecret = new TextEncoder().encode(jwtSecret);
|
|
@@ -62,34 +129,52 @@ export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): Hono {
|
|
|
62
129
|
return c.text("Missing token", 400);
|
|
63
130
|
}
|
|
64
131
|
|
|
65
|
-
let
|
|
66
|
-
let tenantId: TenantId;
|
|
67
|
-
let notificationType: string;
|
|
68
|
-
let channel: string;
|
|
132
|
+
let verifiedPayload: jose.JWTPayload;
|
|
69
133
|
try {
|
|
70
|
-
|
|
134
|
+
({ payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
|
|
71
135
|
issuer: "kumiko:unsubscribe",
|
|
72
|
-
});
|
|
73
|
-
const parsed = unsubscribeJwtPayloadSchema.parse(payload);
|
|
74
|
-
userId = parsed.sub;
|
|
75
|
-
// @cast-boundary engine-bridge — string post-zod → branded TenantId
|
|
76
|
-
tenantId = parsed.tenantId as TenantId;
|
|
77
|
-
notificationType = parsed.notificationType;
|
|
78
|
-
channel = parsed.channel;
|
|
136
|
+
}));
|
|
79
137
|
} catch {
|
|
80
138
|
return c.text("Invalid or expired token", 400);
|
|
81
139
|
}
|
|
82
140
|
|
|
141
|
+
const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
|
|
142
|
+
if (addressAttempt.success) {
|
|
143
|
+
const parsed = addressAttempt.data;
|
|
144
|
+
if (parsed.sub !== parsed.addressHash) {
|
|
145
|
+
return c.text("Invalid or expired token", 400);
|
|
146
|
+
}
|
|
147
|
+
// @cast-boundary engine-bridge — string post-zod → branded TenantId
|
|
148
|
+
const tenantId = parsed.tenantId as TenantId;
|
|
149
|
+
const actor = { id: parsed.addressHash, tenantId, roles: SYSTEM_ROLES };
|
|
150
|
+
const tdb = createTenantDb(db, tenantId, "system");
|
|
151
|
+
await upsertAddressOptOut(tdb, actor, {
|
|
152
|
+
tenantId,
|
|
153
|
+
addressHash: parsed.addressHash,
|
|
154
|
+
notificationType: parsed.notificationType,
|
|
155
|
+
channel: parsed.channel,
|
|
156
|
+
});
|
|
157
|
+
return c.text("You have been unsubscribed.", 200);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
|
|
161
|
+
if (!userAttempt.success) {
|
|
162
|
+
return c.text("Invalid or expired token", 400);
|
|
163
|
+
}
|
|
164
|
+
const parsed = userAttempt.data;
|
|
165
|
+
// @cast-boundary engine-bridge — string post-zod → branded TenantId
|
|
166
|
+
const tenantId = parsed.tenantId as TenantId;
|
|
167
|
+
|
|
83
168
|
// Token-verify passed — everything below is a legitimate write. Don't
|
|
84
169
|
// swallow write-errors as "invalid token", that would mask real bugs
|
|
85
170
|
// (e.g. events-table missing, DB down) behind a misleading 400.
|
|
86
|
-
const actor = { id:
|
|
171
|
+
const actor = { id: parsed.sub, tenantId, roles: SYSTEM_ROLES };
|
|
87
172
|
const tdb = createTenantDb(db, tenantId, "system");
|
|
88
173
|
await upsertPreference(tdb, actor, {
|
|
89
174
|
tenantId,
|
|
90
|
-
userId,
|
|
91
|
-
notificationType,
|
|
92
|
-
channel,
|
|
175
|
+
userId: parsed.sub,
|
|
176
|
+
notificationType: parsed.notificationType,
|
|
177
|
+
channel: parsed.channel,
|
|
93
178
|
enabled: false,
|
|
94
179
|
});
|
|
95
180
|
return c.text("You have been unsubscribed.", 200);
|
|
@@ -130,7 +130,7 @@ export async function upsertPreference(
|
|
|
130
130
|
// (SQLSTATE 23505). Drivers wrap the DB error in varying envelopes; we
|
|
131
131
|
// check the `code` field plus a string-match fallback so the match survives
|
|
132
132
|
// minor driver-version shifts without drifting wide.
|
|
133
|
-
function isUniqueViolation(err: unknown): boolean {
|
|
133
|
+
export function isUniqueViolation(err: unknown): boolean {
|
|
134
134
|
if (typeof err !== "object" || err === null) return false;
|
|
135
135
|
const e = err as { code?: unknown; cause?: { code?: unknown }; message?: unknown }; // @cast-boundary error-details
|
|
136
136
|
if (e.code === "23505") return true;
|