@marktiderman/genesis-notifications 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +251 -2
- package/dist/blocks.d.ts +10 -0
- package/dist/blocks.js +35 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-2BG336II.js +124 -0
- package/dist/chunk-2BG336II.js.map +1 -0
- package/dist/chunk-7PHA3WVG.js +51 -0
- package/dist/chunk-7PHA3WVG.js.map +1 -0
- package/dist/chunk-EWZB35V6.js +113 -0
- package/dist/chunk-EWZB35V6.js.map +1 -0
- package/dist/chunk-UXNWI7OS.js +118 -0
- package/dist/chunk-UXNWI7OS.js.map +1 -0
- package/dist/email-render-CID_pXDy.d.ts +53 -0
- package/dist/email.d.ts +46 -0
- package/dist/email.js +64 -0
- package/dist/email.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.js +170 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +98 -0
- package/dist/react.js +4 -0
- package/dist/react.js.map +1 -0
- package/dist/runner.d.ts +174 -0
- package/dist/runner.js +3 -0
- package/dist/runner.js.map +1 -0
- package/dist/server.d.ts +658 -0
- package/dist/server.js +995 -0
- package/dist/server.js.map +1 -0
- package/dist/supabase.d.ts +118 -0
- package/dist/supabase.js +3 -0
- package/dist/supabase.js.map +1 -0
- package/dist/types-BilguCPW.d.ts +332 -0
- package/dist/web.d.ts +79 -0
- package/dist/web.js +233 -0
- package/dist/web.js.map +1 -0
- package/functions/genesis-notifications/index.ts +60 -0
- package/functions/genesis-notifications/notifications.ts +18 -0
- package/migrations/0001_genesis_notifications.sql +2057 -0
- package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
- package/package.json +191 -3
- package/src/blocks.ts +42 -0
- package/src/core/index.ts +43 -0
- package/src/core/registry.ts +354 -0
- package/src/core/types.ts +90 -0
- package/src/core/validate.ts +73 -0
- package/src/email/index.tsx +142 -0
- package/src/index.ts +6 -0
- package/src/native/index.tsx +283 -0
- package/src/react/index.tsx +226 -0
- package/src/runner/index.ts +299 -0
- package/src/server/crypto.ts +44 -0
- package/src/server/dns.ts +106 -0
- package/src/server/email-render.ts +90 -0
- package/src/server/errors.ts +61 -0
- package/src/server/expo.ts +133 -0
- package/src/server/handler.ts +127 -0
- package/src/server/index.ts +57 -0
- package/src/server/notify.ts +100 -0
- package/src/server/rate.ts +47 -0
- package/src/server/resend.ts +143 -0
- package/src/server/runner.ts +524 -0
- package/src/server/secret-links.ts +53 -0
- package/src/server/store.ts +150 -0
- package/src/server/unsubscribe.ts +78 -0
- package/src/server/webhook.ts +55 -0
- package/src/supabase/index.ts +210 -0
- package/src/web/index.tsx +428 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret links: a link whose query is a credential (a one-time sign-in, an
|
|
3
|
+
* invite, a response's edit link). They never go in `notifications.data` or
|
|
4
|
+
* a push. The domain stores a reference (`{ response_id }`) and, for a token
|
|
5
|
+
* that exists only hashed, passes the plaintext as `notify({ secret })`; the
|
|
6
|
+
* runner builds the link at send time, for the email only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** What a secret link is built from. */
|
|
10
|
+
export interface SecretLinkRef {
|
|
11
|
+
/** The notification's `data` (references, never secrets). */
|
|
12
|
+
data: Record<string, unknown>;
|
|
13
|
+
/** The plaintext from `notify({ secret })`, decrypted for this send only. */
|
|
14
|
+
secret: string | null;
|
|
15
|
+
notificationId: string;
|
|
16
|
+
tenantId: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Type key → how its link is built. A builder signs the link with its own key, or puts `secret` in it. */
|
|
20
|
+
export type SecretLinks = Readonly<Record<string, (ref: SecretLinkRef) => string | Promise<string>>>;
|
|
21
|
+
|
|
22
|
+
/** No builder for a type whose emails need one, or a builder that gave back something that isn't an https link. */
|
|
23
|
+
export class SecretLinkError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "SecretLinkError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The secret link for one notification, built server-side at send time.
|
|
32
|
+
* Throws a `SecretLinkError` when there is no builder for `type` or it gives
|
|
33
|
+
* something other than an `https:` URL (`http:` only for localhost).
|
|
34
|
+
*
|
|
35
|
+
* @stability Beta
|
|
36
|
+
*/
|
|
37
|
+
export async function resolveSecretLink(links: SecretLinks, type: string, ref: SecretLinkRef): Promise<string> {
|
|
38
|
+
const build = Object.prototype.hasOwnProperty.call(links, type) ? links[type] : undefined;
|
|
39
|
+
if (!build) throw new SecretLinkError(`No secret link builder for type ${type}`);
|
|
40
|
+
const link = await build(ref);
|
|
41
|
+
let url: URL;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(link);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new SecretLinkError(`The secret link for type ${type} is not a URL`);
|
|
46
|
+
}
|
|
47
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
48
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) throw new SecretLinkError(`The secret link for type ${type} is not https`);
|
|
49
|
+
return url.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a template puts where the secret link goes; the runner swaps it in at send time, so the stored render never holds it. */
|
|
53
|
+
export const SECRET_LINK_PLACEHOLDER = "https://genesis-secret-link.invalid/";
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The database half, through the service role: what the runner and the
|
|
3
|
+
* handler call. `supabaseNotificationStore(admin)` wraps migration 0001's
|
|
4
|
+
* functions; tests (and an app with another queue) pass their own.
|
|
5
|
+
*/
|
|
6
|
+
import type { Channel, NotificationTypeRow } from "../core/registry";
|
|
7
|
+
|
|
8
|
+
type RpcResult = { data: unknown; error: { message?: string; code?: string | null } | null };
|
|
9
|
+
|
|
10
|
+
/** The part of a Supabase client the store uses, signed in with the service role (`SUPABASE_SECRET_KEY`). */
|
|
11
|
+
export interface RpcClient {
|
|
12
|
+
// Loose on purpose: a client typed by an app's Database narrows `rpc`'s names to its own.
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
rpc: (fn: any, args?: any) => PromiseLike<RpcResult>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A database refusal, with its SQLSTATE; never the row it refused. */
|
|
18
|
+
export class NotificationStoreError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
readonly operation: string,
|
|
21
|
+
readonly code: string | null,
|
|
22
|
+
) {
|
|
23
|
+
super(`${operation} failed${code ? ` (${code})` : ""}`);
|
|
24
|
+
this.name = "NotificationStoreError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ClaimedDevice {
|
|
29
|
+
id: string;
|
|
30
|
+
token: string;
|
|
31
|
+
platform: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SenderIdentity {
|
|
35
|
+
from_name: string | null;
|
|
36
|
+
reply_to: string | null;
|
|
37
|
+
domain: string | null;
|
|
38
|
+
domain_verified: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One claimed send job, as genesis_notifications_claim hands it over. */
|
|
42
|
+
export interface ClaimedJob {
|
|
43
|
+
id: string;
|
|
44
|
+
claim_id: string;
|
|
45
|
+
notification_id: string;
|
|
46
|
+
channel: Exclude<Channel, "in_app">;
|
|
47
|
+
attempt: number;
|
|
48
|
+
deferrals: number;
|
|
49
|
+
redelivery: boolean;
|
|
50
|
+
type: string;
|
|
51
|
+
category: string;
|
|
52
|
+
tenant_id: string | null;
|
|
53
|
+
user_id: string | null;
|
|
54
|
+
email: string | null;
|
|
55
|
+
recipient_name: string | null;
|
|
56
|
+
data: Record<string, unknown>;
|
|
57
|
+
thread_key: string | null;
|
|
58
|
+
collapse_key: string | null;
|
|
59
|
+
badge: number | null;
|
|
60
|
+
brand: Record<string, unknown> | null;
|
|
61
|
+
transactional: boolean;
|
|
62
|
+
/** Preferences can't turn it off, so its email has no unsubscribe link. */
|
|
63
|
+
critical: boolean;
|
|
64
|
+
push_privacy: "default" | "generic";
|
|
65
|
+
ios_category: string | null;
|
|
66
|
+
hide_previews: boolean;
|
|
67
|
+
devices: ClaimedDevice[] | null;
|
|
68
|
+
sender: SenderIdentity | null;
|
|
69
|
+
rendered: RenderedEmail | null;
|
|
70
|
+
rendered_hash: string | null;
|
|
71
|
+
secret: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** An email as first rendered: what every retry sends. */
|
|
75
|
+
export interface RenderedEmail {
|
|
76
|
+
from: string;
|
|
77
|
+
to: string[];
|
|
78
|
+
reply_to?: string;
|
|
79
|
+
subject: string;
|
|
80
|
+
html: string;
|
|
81
|
+
text: string;
|
|
82
|
+
headers?: Record<string, string>;
|
|
83
|
+
tags?: { name: string; value: string }[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type SettleOutcome =
|
|
87
|
+
| { status: "sent"; provider_id?: string | null }
|
|
88
|
+
| { status: "skipped"; reason: string }
|
|
89
|
+
| { status: "filtered" }
|
|
90
|
+
| { status: "failed"; error: string; provider_id?: string | null }
|
|
91
|
+
| { status: "retry"; error: string; retry_at: string; counted: boolean; provider_id?: string | null };
|
|
92
|
+
|
|
93
|
+
export interface PushTicketRow {
|
|
94
|
+
device_id: string;
|
|
95
|
+
ticket_id: string | null;
|
|
96
|
+
status: "sent" | "failed";
|
|
97
|
+
error: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface NotificationStore {
|
|
101
|
+
/** `maxAttempts`: a job whose lease ran out that many times fails (`lease_expired`) instead of coming back. */
|
|
102
|
+
claim(channel: "push" | "email", limit: number, leaseSeconds: number, maxAttempts?: number): Promise<ClaimedJob[]>;
|
|
103
|
+
storeRendered(id: string, claimId: string, rendered: RenderedEmail, hash: string): Promise<{ rendered: RenderedEmail; hash: string } | null>;
|
|
104
|
+
settle(id: string, claimId: string, outcome: SettleOutcome): Promise<boolean>;
|
|
105
|
+
/** Keeps the provider's id the moment a send returns, before it settles, so an early webhook finds the job. False when the claim is gone. */
|
|
106
|
+
recordProviderId(id: string, claimId: string, providerId: string): Promise<boolean>;
|
|
107
|
+
pushTickets(deliveryId: string, tickets: PushTicketRow[]): Promise<void>;
|
|
108
|
+
receiptsClaim(limit: number): Promise<{ id: number; ticket_id: string }[]>;
|
|
109
|
+
receiptsSettle(results: { id: number; status: "delivered" | "failed"; error: string | null }[]): Promise<void>;
|
|
110
|
+
emailEvent(providerId: string, event: "delivered" | "bounced" | "complained", permanent: boolean): Promise<boolean>;
|
|
111
|
+
/** For a login (`userId`), or someone with none by the SHA-256 of their address; for one tenant's mail (`tenantId`), or mail sent for none. */
|
|
112
|
+
unsubscribe(userId: string | null, emailHash: string | null, category: string, tenantId: string | null): Promise<boolean>;
|
|
113
|
+
syncTypes(rows: NotificationTypeRow[]): Promise<number>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The store over migration 0001's functions.
|
|
118
|
+
*
|
|
119
|
+
* @stability Beta
|
|
120
|
+
*/
|
|
121
|
+
export function supabaseNotificationStore(client: RpcClient): NotificationStore {
|
|
122
|
+
const call = async <T>(fn: string, args: Record<string, unknown>): Promise<T> => {
|
|
123
|
+
const { data, error } = await client.rpc(fn, args);
|
|
124
|
+
if (error) throw new NotificationStoreError(fn, error.code ?? null);
|
|
125
|
+
return data as T;
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
claim: (channel, limit, leaseSeconds, maxAttempts = 8) =>
|
|
129
|
+
call<ClaimedJob[]>("genesis_notifications_claim", { p_channel: channel, p_limit: limit, p_lease_seconds: leaseSeconds, p_max_attempts: maxAttempts }).then((rows) => rows ?? []),
|
|
130
|
+
async storeRendered(id, claimId, rendered, hash) {
|
|
131
|
+
const rows = await call<{ rendered: RenderedEmail | null; rendered_hash: string | null }[]>("genesis_notifications_store_rendered", { p_id: id, p_claim: claimId, p_rendered: rendered, p_hash: hash });
|
|
132
|
+
const row = rows?.[0];
|
|
133
|
+
return row?.rendered && row.rendered_hash ? { rendered: row.rendered, hash: row.rendered_hash } : null;
|
|
134
|
+
},
|
|
135
|
+
settle: (id, claimId, outcome) => call<boolean>("genesis_notifications_settle", { p_id: id, p_claim: claimId, p_outcome: outcome }).then(Boolean),
|
|
136
|
+
recordProviderId: (id, claimId, providerId) =>
|
|
137
|
+
call<boolean>("genesis_notifications_record_provider_id", { p_id: id, p_claim: claimId, p_provider_id: providerId }).then(Boolean),
|
|
138
|
+
async pushTickets(deliveryId, tickets) {
|
|
139
|
+
await call("genesis_notifications_push_tickets", { p_delivery: deliveryId, p_tickets: tickets });
|
|
140
|
+
},
|
|
141
|
+
receiptsClaim: (limit) => call<{ id: number; ticket_id: string }[]>("genesis_notifications_receipts_claim", { p_limit: limit }).then((r) => r ?? []),
|
|
142
|
+
async receiptsSettle(results) {
|
|
143
|
+
await call("genesis_notifications_receipts_settle", { p_results: results });
|
|
144
|
+
},
|
|
145
|
+
emailEvent: (providerId, event, permanent) => call<boolean>("genesis_notifications_email_event", { p_provider_id: providerId, p_event: event, p_permanent: permanent }).then(Boolean),
|
|
146
|
+
unsubscribe: (userId, emailHash, category, tenantId) =>
|
|
147
|
+
call<boolean>("genesis_notifications_unsubscribe", { p_user_id: userId, p_email_hash: emailHash, p_category: category, p_tenant_id: tenantId }).then(Boolean),
|
|
148
|
+
syncTypes: (rows) => call<number>("genesis_notifications_sync_types", { p_rows: rows }),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-click unsubscribe (RFC 8058) for mail that isn't transactional: the
|
|
3
|
+
* `List-Unsubscribe` and `List-Unsubscribe-Post` headers, and a signed token
|
|
4
|
+
* that names who, which category and which tenant, so the endpoint needs no
|
|
5
|
+
* sign-in. The token never carries an address: someone with no login is named
|
|
6
|
+
* by the SHA-256 of their lower-cased address (what the suppression list keeps).
|
|
7
|
+
* The unsubscribe is for the tenant the email came from, not every tenant.
|
|
8
|
+
*/
|
|
9
|
+
import { base64url, fromBase64url, hmacSha256, sha256Hex, timingSafeEqual } from "./crypto";
|
|
10
|
+
|
|
11
|
+
export interface UnsubscribeClaims {
|
|
12
|
+
/** The login, when the recipient has one. */
|
|
13
|
+
userId?: string;
|
|
14
|
+
/** SHA-256 (hex) of the lower-cased, trimmed address, when they have none (`emailHash`). */
|
|
15
|
+
emailHash?: string;
|
|
16
|
+
category: string;
|
|
17
|
+
/** The tenant the email came from; the unsubscribe holds for it only. */
|
|
18
|
+
tenantId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the suppression list keys an address by: SHA-256 of it lower-cased and trimmed (as `genesis_notifications_email_hash`). */
|
|
22
|
+
export function emailHash(email: string): Promise<string> {
|
|
23
|
+
return sha256Hex(email.trim().toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The secret must be at least 32 characters. */
|
|
27
|
+
function checkSecret(secret: string) {
|
|
28
|
+
if (typeof secret !== "string" || secret.length < 32) throw new Error("The unsubscribe secret must be at least 32 characters");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A signed, URL-safe token for `claims`. */
|
|
32
|
+
export async function createUnsubscribeToken(secret: string, claims: UnsubscribeClaims): Promise<string> {
|
|
33
|
+
checkSecret(secret);
|
|
34
|
+
if (claims.emailHash !== undefined && !/^[0-9a-f]{64}$/.test(claims.emailHash)) throw new Error("createUnsubscribeToken: emailHash is a SHA-256 hex digest (emailHash(address))");
|
|
35
|
+
const payload = base64url(new TextEncoder().encode(JSON.stringify({ u: claims.userId, h: claims.emailHash, c: claims.category, t: claims.tenantId, v: 2 })));
|
|
36
|
+
const sig = base64url(await hmacSha256(secret, `unsubscribe.${payload}`));
|
|
37
|
+
return `${payload}.${sig}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The claims of a token this secret signed, or null for anything else. */
|
|
41
|
+
export async function verifyUnsubscribeToken(secret: string, token: string): Promise<UnsubscribeClaims | null> {
|
|
42
|
+
checkSecret(secret);
|
|
43
|
+
if (typeof token !== "string" || token.length > 2000) return null;
|
|
44
|
+
const [payload, sig, extra] = token.split(".");
|
|
45
|
+
if (!payload || !sig || extra !== undefined) return null;
|
|
46
|
+
let given: Uint8Array;
|
|
47
|
+
try {
|
|
48
|
+
given = fromBase64url(sig);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const expected = await hmacSha256(secret, `unsubscribe.${payload}`);
|
|
53
|
+
if (!timingSafeEqual(given, expected)) return null;
|
|
54
|
+
try {
|
|
55
|
+
const raw = JSON.parse(new TextDecoder().decode(fromBase64url(payload))) as { u?: unknown; h?: unknown; c?: unknown; t?: unknown; v?: unknown };
|
|
56
|
+
if (raw.v !== 2 || typeof raw.c !== "string") return null;
|
|
57
|
+
const hash = typeof raw.h === "string" && /^[0-9a-f]{64}$/.test(raw.h) ? raw.h : undefined;
|
|
58
|
+
if (typeof raw.u !== "string" && !hash) return null;
|
|
59
|
+
return {
|
|
60
|
+
category: raw.c,
|
|
61
|
+
...(typeof raw.u === "string" ? { userId: raw.u } : {}),
|
|
62
|
+
...(hash ? { emailHash: hash } : {}),
|
|
63
|
+
...(typeof raw.t === "string" ? { tenantId: raw.t } : {}),
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The RFC 8058 headers for one email. `url` is your unsubscribe endpoint; the token goes in its query. */
|
|
71
|
+
export function unsubscribeHeaders(url: string, token: string): Record<string, string> {
|
|
72
|
+
const link = new URL(url);
|
|
73
|
+
link.searchParams.set("t", token);
|
|
74
|
+
return {
|
|
75
|
+
"List-Unsubscribe": `<${link.toString()}>`,
|
|
76
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resend's webhooks are signed the Standard Webhooks way (Svix): headers
|
|
3
|
+
* `svix-id`, `svix-timestamp` and `svix-signature` (`v1,<base64 HMAC-SHA256
|
|
4
|
+
* of "id.timestamp.body">`, with the base64 secret after `whsec_`).
|
|
5
|
+
*/
|
|
6
|
+
import { fromBase64, hmacSha256, timingSafeEqual } from "./crypto";
|
|
7
|
+
|
|
8
|
+
/** How far a webhook's timestamp may be from now: five minutes, as Svix checks. */
|
|
9
|
+
export const WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
10
|
+
|
|
11
|
+
/** Whether `body` was signed with `secret`; false for anything stale, unsigned or malformed. */
|
|
12
|
+
export async function verifyResendWebhook(secret: string, headers: Headers, body: string, now = Date.now()): Promise<boolean> {
|
|
13
|
+
const id = headers.get("svix-id") ?? headers.get("webhook-id");
|
|
14
|
+
const timestamp = headers.get("svix-timestamp") ?? headers.get("webhook-timestamp");
|
|
15
|
+
const signatures = headers.get("svix-signature") ?? headers.get("webhook-signature");
|
|
16
|
+
if (!secret || !id || !timestamp || !signatures || !/^\d{1,12}$/.test(timestamp)) return false;
|
|
17
|
+
if (Math.abs(now / 1000 - Number(timestamp)) > WEBHOOK_TOLERANCE_SECONDS) return false;
|
|
18
|
+
let key: Uint8Array;
|
|
19
|
+
try {
|
|
20
|
+
key = fromBase64(secret.startsWith("whsec_") ? secret.slice(6) : secret);
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const expected = await hmacSha256(key, `${id}.${timestamp}.${body}`);
|
|
25
|
+
for (const part of signatures.split(" ")) {
|
|
26
|
+
const [version, sig] = part.split(",");
|
|
27
|
+
if (version !== "v1" || !sig) continue;
|
|
28
|
+
let given: Uint8Array;
|
|
29
|
+
try {
|
|
30
|
+
given = fromBase64(sig);
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (timingSafeEqual(given, expected)) return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What the notifier does with an email event; null for the events it doesn't use. */
|
|
40
|
+
export function emailEventOf(payload: unknown): { providerId: string; event: "delivered" | "bounced" | "complained"; permanent: boolean } | null {
|
|
41
|
+
const p = payload as { type?: unknown; data?: { email_id?: unknown; bounce?: { type?: unknown } } } | null;
|
|
42
|
+
const id = p?.data?.email_id;
|
|
43
|
+
if (typeof id !== "string" || !id || id.length > 200) return null;
|
|
44
|
+
switch (p?.type) {
|
|
45
|
+
case "email.delivered":
|
|
46
|
+
return { providerId: id, event: "delivered", permanent: false };
|
|
47
|
+
case "email.bounced":
|
|
48
|
+
// Only what Resend names "Permanent" is a hard bounce; anything else (Transient, Undetermined, none) doesn't suppress.
|
|
49
|
+
return { providerId: id, event: "bounced", permanent: p.data?.bounce?.type === "Permanent" };
|
|
50
|
+
case "email.complained":
|
|
51
|
+
return { providerId: id, event: "complained", permanent: true };
|
|
52
|
+
default:
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@marktiderman/genesis-notifications/supabase`: the migration this package
|
|
3
|
+
* ships (`migrations/0001_genesis_notifications.sql`, with its pgTAP test),
|
|
4
|
+
* and `notificationsClient(supabase)`, the browser and phone side over it:
|
|
5
|
+
* the person's feed, unread count, marks and preferences under row level
|
|
6
|
+
* security, the realtime nudge on `user:<id>`, and the admin views.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
import type { Channel, ChannelChoices, NotificationRow } from "../core/index";
|
|
11
|
+
|
|
12
|
+
export const NOTIFICATIONS_PACKAGE = "@marktiderman/genesis-notifications";
|
|
13
|
+
|
|
14
|
+
/** The migrations to copy, in order, with their tests. */
|
|
15
|
+
export const NOTIFICATIONS_MIGRATIONS = [
|
|
16
|
+
{ package: NOTIFICATIONS_PACKAGE, file: "migrations/0001_genesis_notifications.sql", test: "migrations/tests/0001_genesis_notifications.test.sql" },
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
type Result = { data: unknown; error: { message?: string; code?: string | null } | null; count?: number | null };
|
|
20
|
+
|
|
21
|
+
interface Query extends PromiseLike<Result> {
|
|
22
|
+
select(columns: string, options?: { count?: "exact"; head?: boolean }): Query;
|
|
23
|
+
eq(column: string, value: unknown): Query;
|
|
24
|
+
is(column: string, value: null): Query;
|
|
25
|
+
not(column: string, op: string, value: unknown): Query;
|
|
26
|
+
lt(column: string, value: unknown): Query;
|
|
27
|
+
order(column: string, options?: { ascending?: boolean }): Query;
|
|
28
|
+
limit(n: number): Query;
|
|
29
|
+
maybeSingle(): PromiseLike<Result>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Channel_ {
|
|
33
|
+
on(type: "broadcast", filter: { event: string }, callback: (payload: unknown) => void): Channel_;
|
|
34
|
+
subscribe(callback?: (status: string) => void): Channel_;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The parts of a Supabase client used here; a `SupabaseClient` fits, typed by your Database or not. */
|
|
38
|
+
export interface NotificationsSupabaseLike {
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
rpc: (fn: any, args?: any) => PromiseLike<Result>;
|
|
41
|
+
from(table: string): unknown;
|
|
42
|
+
channel(name: string, options?: { config?: { private?: boolean } }): unknown;
|
|
43
|
+
removeChannel(channel: unknown): unknown;
|
|
44
|
+
realtime?: { setAuth?: (token?: string | null) => unknown };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A database refusal, with its SQLSTATE. */
|
|
48
|
+
export class NotificationsClientError extends Error {
|
|
49
|
+
constructor(
|
|
50
|
+
readonly operation: string,
|
|
51
|
+
readonly code: string | null,
|
|
52
|
+
) {
|
|
53
|
+
super(`${operation} failed${code ? ` (${code})` : ""}`);
|
|
54
|
+
this.name = "NotificationsClientError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type FeedFilter = "all" | "unread" | "archived";
|
|
59
|
+
|
|
60
|
+
/** One row of the admin's delivery log: no content, a masked address. */
|
|
61
|
+
export interface DeliveryLogRow {
|
|
62
|
+
id: string;
|
|
63
|
+
notification_id: string;
|
|
64
|
+
tenant_id: string | null;
|
|
65
|
+
type: string;
|
|
66
|
+
channel: "push" | "email";
|
|
67
|
+
status: string;
|
|
68
|
+
attempts: number;
|
|
69
|
+
error: string | null;
|
|
70
|
+
skip_reason: string | null;
|
|
71
|
+
recipient_masked: string | null;
|
|
72
|
+
created_at: string;
|
|
73
|
+
sent_at: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SuppressionRow {
|
|
77
|
+
email_hash: string;
|
|
78
|
+
category: string;
|
|
79
|
+
reason: "bounce" | "complaint" | "unsubscribe" | "manual";
|
|
80
|
+
email_masked: string | null;
|
|
81
|
+
created_at: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface NotificationsClient {
|
|
85
|
+
feed(options?: { filter?: FeedFilter; limit?: number; before?: string }): Promise<NotificationRow[]>;
|
|
86
|
+
unreadCount(): Promise<number>;
|
|
87
|
+
mark(ids: readonly string[], state: "seen" | "read" | "unread" | "archived" | "unarchived"): Promise<number>;
|
|
88
|
+
markAll(state: "seen" | "read"): Promise<number>;
|
|
89
|
+
/** The signed-in person's choices (category → channel → on); `{}` before they chose. */
|
|
90
|
+
choices(userId: string): Promise<ChannelChoices>;
|
|
91
|
+
/** One category and channel; `null` returns it to the default. Resolves the person's choices after. */
|
|
92
|
+
setChoice(category: string, channel: Channel, on: boolean | null): Promise<ChannelChoices>;
|
|
93
|
+
/** Calls `onChange` whenever the person's feed changes (a broadcast on `user:<id>`). Returns the stop. */
|
|
94
|
+
subscribe(userId: string, onChange: () => void): () => void;
|
|
95
|
+
/** Admin: the delivery log (row level security shows an admin their tenants'). */
|
|
96
|
+
deliveryLog(options?: { tenantId?: string | null; status?: string; limit?: number }): Promise<DeliveryLogRow[]>;
|
|
97
|
+
/** Admin: the suppression list (platform admins). */
|
|
98
|
+
suppressions(options?: { limit?: number }): Promise<SuppressionRow[]>;
|
|
99
|
+
removeSuppression(emailHash: string, category?: string): Promise<boolean>;
|
|
100
|
+
/** Admin: a test notification to yourself, on every channel you have. */
|
|
101
|
+
sendTest(tenantId?: string | null): Promise<string | null>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A preferences row as category → channel → on: `channels`, with the push
|
|
106
|
+
* and email of a category switched off as a whole (migration 0005's
|
|
107
|
+
* `categories`) filled in where no channel was chosen, as the server reads it.
|
|
108
|
+
*/
|
|
109
|
+
export function choicesOf(row: { channels?: unknown; categories?: unknown } | null): ChannelChoices {
|
|
110
|
+
const object = (v: unknown): Record<string, unknown> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
|
|
111
|
+
const out: ChannelChoices = {};
|
|
112
|
+
for (const [category, chosen] of Object.entries(object(row?.channels))) {
|
|
113
|
+
const cell: Partial<Record<Channel, boolean>> = {};
|
|
114
|
+
for (const [ch, on] of Object.entries(object(chosen))) if (typeof on === "boolean" && (ch === "in_app" || ch === "push" || ch === "email")) cell[ch] = on;
|
|
115
|
+
out[category] = cell;
|
|
116
|
+
}
|
|
117
|
+
for (const [category, on] of Object.entries(object(row?.categories))) {
|
|
118
|
+
if (on !== false) continue;
|
|
119
|
+
const cell = { ...out[category] };
|
|
120
|
+
cell.push ??= false;
|
|
121
|
+
cell.email ??= false;
|
|
122
|
+
out[category] = cell;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const FEED_COLUMNS = "id, type, category, tenant_id, data, thread_key, created_at, seen_at, read_at, archived_at";
|
|
128
|
+
const LOG_COLUMNS = "id, notification_id, tenant_id, type, channel, status, attempts, error, skip_reason, recipient_masked, created_at, sent_at";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The browser and phone side of the notifier, under the signed-in person's
|
|
132
|
+
* row level security.
|
|
133
|
+
*
|
|
134
|
+
* @stability Beta
|
|
135
|
+
*/
|
|
136
|
+
export function notificationsClient(client: NotificationsSupabaseLike): NotificationsClient {
|
|
137
|
+
const must = (operation: string, result: Result) => {
|
|
138
|
+
if (result.error) throw new NotificationsClientError(operation, result.error.code ?? null);
|
|
139
|
+
return result;
|
|
140
|
+
};
|
|
141
|
+
const table = (name: string) => client.from(name) as Query;
|
|
142
|
+
return {
|
|
143
|
+
async feed({ filter = "all", limit = 50, before } = {}) {
|
|
144
|
+
let q = table("notifications").select(FEED_COLUMNS).eq("in_feed", true);
|
|
145
|
+
q = filter === "archived" ? q.not("archived_at", "is", null) : q.is("archived_at", null);
|
|
146
|
+
if (filter === "unread") q = q.is("read_at", null);
|
|
147
|
+
if (before) q = q.lt("created_at", before);
|
|
148
|
+
const { data } = must("notifications", await q.order("created_at", { ascending: false }).limit(Math.min(Math.max(1, limit), 200)));
|
|
149
|
+
return (data as NotificationRow[] | null) ?? [];
|
|
150
|
+
},
|
|
151
|
+
async unreadCount() {
|
|
152
|
+
const { count } = must(
|
|
153
|
+
"notifications",
|
|
154
|
+
await table("notifications").select("id", { count: "exact", head: true }).eq("in_feed", true).is("read_at", null).is("archived_at", null),
|
|
155
|
+
);
|
|
156
|
+
return count ?? 0;
|
|
157
|
+
},
|
|
158
|
+
async mark(ids, state) {
|
|
159
|
+
if (!ids.length) return 0;
|
|
160
|
+
const { data } = must("genesis_notifications_mark", await client.rpc("genesis_notifications_mark", { p_ids: ids, p_state: state }));
|
|
161
|
+
return Number(data ?? 0);
|
|
162
|
+
},
|
|
163
|
+
async markAll(state) {
|
|
164
|
+
const { data } = must("genesis_notifications_mark_all", await client.rpc("genesis_notifications_mark_all", { p_state: state }));
|
|
165
|
+
return Number(data ?? 0);
|
|
166
|
+
},
|
|
167
|
+
async choices(userId) {
|
|
168
|
+
const { data } = must("notification_preferences", await table("notification_preferences").select("channels, categories").eq("user_id", userId).maybeSingle());
|
|
169
|
+
return choicesOf(data as { channels?: unknown; categories?: unknown } | null);
|
|
170
|
+
},
|
|
171
|
+
async setChoice(category, channel, on) {
|
|
172
|
+
const { data } = must("genesis_notifications_set_preference", await client.rpc("genesis_notifications_set_preference", { p_category: category, p_channel: channel, p_on: on }));
|
|
173
|
+
return (data as ChannelChoices | null) ?? {};
|
|
174
|
+
},
|
|
175
|
+
subscribe(userId, onChange) {
|
|
176
|
+
if (typeof client.channel !== "function") {
|
|
177
|
+
// Loud, not fatal: without realtime the feed still refetches on focus and pull to refresh.
|
|
178
|
+
console.warn("[genesis-notifications] this Supabase client has no realtime (client.channel); the feed won't update live.");
|
|
179
|
+
return () => {};
|
|
180
|
+
}
|
|
181
|
+
const channel = client.channel(`user:${userId}`, { config: { private: true } }) as Channel_;
|
|
182
|
+
channel.on("broadcast", { event: "notification" }, () => onChange()).subscribe();
|
|
183
|
+
return () => {
|
|
184
|
+
client.removeChannel(channel);
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
async deliveryLog({ tenantId, status, limit = 100 } = {}) {
|
|
188
|
+
let q = table("notification_deliveries").select(LOG_COLUMNS);
|
|
189
|
+
if (tenantId !== undefined) q = tenantId === null ? q.is("tenant_id", null) : q.eq("tenant_id", tenantId);
|
|
190
|
+
if (status) q = q.eq("status", status);
|
|
191
|
+
const { data } = must("notification_deliveries", await q.order("created_at", { ascending: false }).limit(Math.min(Math.max(1, limit), 500)));
|
|
192
|
+
return (data as DeliveryLogRow[] | null) ?? [];
|
|
193
|
+
},
|
|
194
|
+
async suppressions({ limit = 200 } = {}) {
|
|
195
|
+
const { data } = must(
|
|
196
|
+
"notification_suppressions",
|
|
197
|
+
await table("notification_suppressions").select("email_hash, category, reason, email_masked, created_at").order("created_at", { ascending: false }).limit(Math.min(Math.max(1, limit), 1000)),
|
|
198
|
+
);
|
|
199
|
+
return (data as SuppressionRow[] | null) ?? [];
|
|
200
|
+
},
|
|
201
|
+
async removeSuppression(emailHash, category = "*") {
|
|
202
|
+
const { data } = must("genesis_notifications_remove_suppression", await client.rpc("genesis_notifications_remove_suppression", { p_email_hash: emailHash, p_category: category }));
|
|
203
|
+
return Boolean(data);
|
|
204
|
+
},
|
|
205
|
+
async sendTest(tenantId = null) {
|
|
206
|
+
const { data } = must("genesis_notifications_send_test", await client.rpc("genesis_notifications_send_test", { p_tenant: tenantId }));
|
|
207
|
+
return (data as string | null) ?? null;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|