@vllnt/convex-suppression 0.1.0-canary.261f634

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/dist/client/index.d.ts +135 -0
  4. package/dist/client/index.d.ts.map +1 -0
  5. package/dist/client/index.js +131 -0
  6. package/dist/client/index.js.map +1 -0
  7. package/dist/client/types.d.ts +70 -0
  8. package/dist/client/types.d.ts.map +1 -0
  9. package/dist/client/types.js +3 -0
  10. package/dist/client/types.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +38 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +67 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +121 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +78 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/convex.config.d.ts +3 -0
  28. package/dist/component/convex.config.d.ts.map +1 -0
  29. package/dist/component/convex.config.js +7 -0
  30. package/dist/component/convex.config.js.map +1 -0
  31. package/dist/component/mutations.d.ts +49 -0
  32. package/dist/component/mutations.d.ts.map +1 -0
  33. package/dist/component/mutations.js +110 -0
  34. package/dist/component/mutations.js.map +1 -0
  35. package/dist/component/queries.d.ts +46 -0
  36. package/dist/component/queries.d.ts.map +1 -0
  37. package/dist/component/queries.js +112 -0
  38. package/dist/component/queries.js.map +1 -0
  39. package/dist/component/schema.d.ts +51 -0
  40. package/dist/component/schema.d.ts.map +1 -0
  41. package/dist/component/schema.js +39 -0
  42. package/dist/component/schema.js.map +1 -0
  43. package/dist/component/validators.d.ts +50 -0
  44. package/dist/component/validators.d.ts.map +1 -0
  45. package/dist/component/validators.js +40 -0
  46. package/dist/component/validators.js.map +1 -0
  47. package/dist/shared.d.ts +22 -0
  48. package/dist/shared.d.ts.map +1 -0
  49. package/dist/shared.js +26 -0
  50. package/dist/shared.js.map +1 -0
  51. package/package.json +101 -0
  52. package/src/client/index.ts +271 -0
  53. package/src/client/types.ts +82 -0
  54. package/src/component/_generated/api.ts +54 -0
  55. package/src/component/_generated/component.ts +102 -0
  56. package/src/component/_generated/dataModel.ts +60 -0
  57. package/src/component/_generated/server.ts +156 -0
  58. package/src/component/convex.config.ts +9 -0
  59. package/src/component/mutations.ts +118 -0
  60. package/src/component/queries.ts +128 -0
  61. package/src/component/schema.ts +40 -0
  62. package/src/component/validators.ts +49 -0
  63. package/src/shared.ts +31 -0
  64. package/src/test.ts +15 -0
@@ -0,0 +1,271 @@
1
+ import type {
2
+ FunctionArgs,
3
+ FunctionReference,
4
+ FunctionReturnType,
5
+ } from "convex/server";
6
+ import type {
7
+ IsEligibleOptions,
8
+ OptInProofView,
9
+ Parser,
10
+ RecordOptInOptions,
11
+ SuppressionOptions,
12
+ SuppressionReason,
13
+ SuppressionView,
14
+ SuppressOptions,
15
+ } from "./types.js";
16
+ import { GLOBAL_CHANNEL } from "../shared.js";
17
+
18
+ /**
19
+ * The component's raw opt-in proof view, before the client narrows opaque host
20
+ * evidence. `proof` is `unknown` here; the {@link Suppression} client runs the
21
+ * host validator over it at its typed boundary.
22
+ */
23
+ type RawProofView = {
24
+ contactHash: string;
25
+ listKey: string | null;
26
+ source: string;
27
+ proof?: unknown;
28
+ confirmedAt: number;
29
+ };
30
+
31
+ /**
32
+ * The suppression component's function references, as exposed on the host via
33
+ * `components.suppression`. The host's stored opt-in `proof` is opaque here
34
+ * (`unknown`); the {@link Suppression} client narrows it at its own typed boundary.
35
+ */
36
+ export interface SuppressionComponent {
37
+ mutations: {
38
+ suppress: FunctionReference<
39
+ "mutation",
40
+ "internal",
41
+ { contactHash: string; channel: string; reason: SuppressionReason },
42
+ null
43
+ >;
44
+ unsuppress: FunctionReference<
45
+ "mutation",
46
+ "internal",
47
+ { contactHash: string; channel: string },
48
+ boolean
49
+ >;
50
+ recordOptIn: FunctionReference<
51
+ "mutation",
52
+ "internal",
53
+ { contactHash: string; listKey: string; source: string; proof?: unknown },
54
+ null
55
+ >;
56
+ };
57
+ queries: {
58
+ isSuppressed: FunctionReference<
59
+ "query",
60
+ "internal",
61
+ { contactHash: string; channel: string },
62
+ SuppressionView | null
63
+ >;
64
+ getOptInProof: FunctionReference<
65
+ "query",
66
+ "internal",
67
+ { contactHash: string; listKey: string },
68
+ RawProofView | null
69
+ >;
70
+ isEligible: FunctionReference<
71
+ "query",
72
+ "internal",
73
+ {
74
+ contactHash: string;
75
+ channel: string;
76
+ listKey: string;
77
+ requireOptIn: boolean;
78
+ },
79
+ boolean
80
+ >;
81
+ };
82
+ }
83
+
84
+ interface RunQueryCtx {
85
+ runQuery<Q extends FunctionReference<"query", "internal">>(
86
+ reference: Q,
87
+ args: FunctionArgs<Q>,
88
+ ): Promise<FunctionReturnType<Q>>;
89
+ }
90
+
91
+ interface RunMutationCtx {
92
+ runMutation<M extends FunctionReference<"mutation", "internal">>(
93
+ reference: M,
94
+ args: FunctionArgs<M>,
95
+ ): Promise<FunctionReturnType<M>>;
96
+ }
97
+
98
+ /**
99
+ * Consumer-facing client for the do-not-contact suppression gate (GDPR opt-out /
100
+ * CAN-SPAM). The host hashes a contact (`hash(normalize(email|phone))`) and passes
101
+ * the opaque `contactHash`; the component stores a `(contactHash, channel)`
102
+ * anti-membership tombstone that survives erasure of the subject. A sender calls
103
+ * `isEligible` before every send (`¬suppressed [∧ confirmed]`); an unsubscribe /
104
+ * bounce / complaint webhook calls `suppress`; a double-opt-in confirmation calls
105
+ * `recordOptIn`. The host owns meaning and auth — it resolves identity, hashes the
106
+ * contact, and decides the channel/list semantics. Pass `proofValidator` to narrow
107
+ * the opaque opt-in evidence to `TProof` at the boundary — there is no unchecked
108
+ * cast.
109
+ *
110
+ * @typeParam TProof - The host's opt-in proof evidence type (defaults to `unknown`).
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * const dnc = new Suppression(components.suppression, {
115
+ * proofValidator: v.object({ ip: v.string() }).parse,
116
+ * });
117
+ * // a webhook suppresses on complaint:
118
+ * await dnc.suppress(ctx, contactHash, "complaint", { channel: "email" });
119
+ * // a sender gates a marketing send:
120
+ * if (await dnc.isEligible(ctx, contactHash, { channel: "email", listKey: "news", requireOptIn: true })) {
121
+ * // ...send
122
+ * }
123
+ * ```
124
+ */
125
+ export class Suppression<TProof = unknown> {
126
+ private readonly proofValidator: Parser<TProof> | undefined;
127
+
128
+ constructor(
129
+ private readonly component: SuppressionComponent,
130
+ options: SuppressionOptions<TProof> = {},
131
+ ) {
132
+ this.proofValidator = options.proofValidator;
133
+ }
134
+
135
+ /** Narrow an opaque value through a host parser; pass `undefined` and unset parsers through. */
136
+ private parse(value: unknown): TProof | undefined {
137
+ if (value === undefined) {
138
+ return undefined;
139
+ }
140
+ if (this.proofValidator === undefined) {
141
+ return value as TProof;
142
+ }
143
+ return this.proofValidator(value);
144
+ }
145
+
146
+ /**
147
+ * Suppress a `(contactHash, channel)` — add it to the do-not-contact list.
148
+ * `opts.channel` scopes the suppression to one channel; omit it for a global
149
+ * (all-channel) suppression. Idempotent on `(contactHash, channel)`. `reason` is
150
+ * recorded for audit.
151
+ */
152
+ suppress(
153
+ ctx: RunMutationCtx,
154
+ contactHash: string,
155
+ reason: SuppressionReason,
156
+ opts: SuppressOptions = {},
157
+ ): Promise<null> {
158
+ return ctx.runMutation(this.component.mutations.suppress, {
159
+ contactHash,
160
+ channel: opts.channel ?? GLOBAL_CHANNEL,
161
+ reason,
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Remove a `(contactHash, channel)` from the do-not-contact list (a rare,
167
+ * audited re-subscribe). Omit `channel` to clear the global entry. Returns `true`
168
+ * if an entry was removed, `false` if none matched.
169
+ */
170
+ unsuppress(
171
+ ctx: RunMutationCtx,
172
+ contactHash: string,
173
+ channel?: string,
174
+ ): Promise<boolean> {
175
+ return ctx.runMutation(this.component.mutations.unsuppress, {
176
+ contactHash,
177
+ channel: channel ?? GLOBAL_CHANNEL,
178
+ });
179
+ }
180
+
181
+ /**
182
+ * The matching suppression for `(contactHash, channel)`, or `null` if the
183
+ * contact is not suppressed on that channel. A global suppression matches every
184
+ * channel and wins. Omit `channel` to check the global entry only.
185
+ */
186
+ isSuppressed(
187
+ ctx: RunQueryCtx,
188
+ contactHash: string,
189
+ channel?: string,
190
+ ): Promise<SuppressionView | null> {
191
+ return ctx.runQuery(this.component.queries.isSuppressed, {
192
+ contactHash,
193
+ channel: channel ?? GLOBAL_CHANNEL,
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Record an opt-in proof for a `(contactHash, listKey)` — the legal evidence of
199
+ * a confirmed opt-in. `opts.listKey` scopes the proof to one list; omit it for a
200
+ * global opt-in. `opts.proof` is opaque host evidence validated against
201
+ * `proofValidator` before storage. Idempotent on `(contactHash, listKey)`.
202
+ */
203
+ recordOptIn(
204
+ ctx: RunMutationCtx,
205
+ contactHash: string,
206
+ opts: RecordOptInOptions<TProof>,
207
+ ): Promise<null> {
208
+ return ctx.runMutation(this.component.mutations.recordOptIn, {
209
+ contactHash,
210
+ listKey: opts.listKey ?? GLOBAL_CHANNEL,
211
+ source: opts.source,
212
+ proof: opts.proof === undefined ? undefined : this.parse(opts.proof),
213
+ });
214
+ }
215
+
216
+ /**
217
+ * The opt-in proof for `(contactHash, listKey)`, or `null` if none is recorded.
218
+ * Omit `listKey` to fetch a global opt-in. `proof` is narrowed by the host
219
+ * validator on read.
220
+ */
221
+ async getOptInProof(
222
+ ctx: RunQueryCtx,
223
+ contactHash: string,
224
+ listKey?: string,
225
+ ): Promise<OptInProofView<TProof> | null> {
226
+ const raw = await ctx.runQuery(this.component.queries.getOptInProof, {
227
+ contactHash,
228
+ listKey: listKey ?? GLOBAL_CHANNEL,
229
+ });
230
+ if (raw === null) {
231
+ return null;
232
+ }
233
+ return {
234
+ contactHash: raw.contactHash,
235
+ listKey: raw.listKey,
236
+ source: raw.source,
237
+ proof: this.parse(raw.proof),
238
+ confirmedAt: raw.confirmedAt,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * The send gate: `true` when the contact may be contacted — NOT suppressed on
244
+ * `opts.channel` (global or channel-specific) and, when `opts.requireOptIn` is
245
+ * set, holding a recorded opt-in proof for `opts.listKey`. Suppression always
246
+ * blocks; the opt-in requirement is per call.
247
+ */
248
+ isEligible(
249
+ ctx: RunQueryCtx,
250
+ contactHash: string,
251
+ opts: IsEligibleOptions = {},
252
+ ): Promise<boolean> {
253
+ return ctx.runQuery(this.component.queries.isEligible, {
254
+ contactHash,
255
+ channel: opts.channel ?? GLOBAL_CHANNEL,
256
+ listKey: opts.listKey ?? GLOBAL_CHANNEL,
257
+ requireOptIn: opts.requireOptIn ?? false,
258
+ });
259
+ }
260
+ }
261
+
262
+ export type {
263
+ IsEligibleOptions,
264
+ OptInProofView,
265
+ Parser,
266
+ RecordOptInOptions,
267
+ SuppressionOptions,
268
+ SuppressionReason,
269
+ SuppressionView,
270
+ SuppressOptions,
271
+ };
@@ -0,0 +1,82 @@
1
+ /** Public TypeScript surface for the suppression client. */
2
+
3
+ /** The five standard reasons a `(contactHash, channel)` is suppressed. */
4
+ export type SuppressionReason =
5
+ | "unsubscribe"
6
+ | "bounce"
7
+ | "complaint"
8
+ | "manual"
9
+ | "global";
10
+
11
+ /**
12
+ * Validates and narrows opaque host opt-in evidence to a host type `T` at the
13
+ * client boundary. Receives the raw value the component returned (`unknown`) and
14
+ * MUST return a typed `T` or throw. A `convex/values` validator's `.parse` (or a
15
+ * Zod `.parse`) fits directly; omit it to keep the value unvalidated.
16
+ *
17
+ * @typeParam T - The host's stored opt-in `proof` type.
18
+ */
19
+ export type Parser<T> = (value: unknown) => T;
20
+
21
+ /** The public view of a suppression returned by {@link Suppression.isSuppressed}. */
22
+ export interface SuppressionView {
23
+ /** The host's opaque contact hash — never a raw email/phone. */
24
+ contactHash: string;
25
+ /** The channel this entry applies to, or `null` for a global (all-channel) suppression. */
26
+ channel: string | null;
27
+ /** Why the contact was suppressed (audit only — never changes the effect). */
28
+ reason: SuppressionReason;
29
+ /** Absolute ms timestamp the suppression was recorded. */
30
+ createdAt: number;
31
+ }
32
+
33
+ /** The public view of an opt-in proof returned by {@link Suppression.getOptInProof}. */
34
+ export interface OptInProofView<TProof = unknown> {
35
+ /** The host's opaque contact hash. */
36
+ contactHash: string;
37
+ /** The list/purpose the opt-in applies to, or `null` for a global opt-in. */
38
+ listKey: string | null;
39
+ /** How the opt-in was captured (e.g. `"double-opt-in"`, `"checkbox"`). */
40
+ source: string;
41
+ /** The opaque host evidence (narrowed if a `proofValidator` is set). */
42
+ proof?: TProof;
43
+ /** Absolute ms timestamp the opt-in was confirmed. */
44
+ confirmedAt: number;
45
+ }
46
+
47
+ /** Per-call options for {@link Suppression.suppress}. */
48
+ export interface SuppressOptions {
49
+ /** The channel to suppress (`"email"`/`"sms"`/…). Omit for a global suppression. */
50
+ channel?: string;
51
+ }
52
+
53
+ /** Per-call options for {@link Suppression.recordOptIn}. */
54
+ export interface RecordOptInOptions<TProof> {
55
+ /** The list/purpose the opt-in applies to. Omit for a global opt-in. */
56
+ listKey?: string;
57
+ /** How the opt-in was captured. */
58
+ source: string;
59
+ /** Opaque host evidence (validated against `proofValidator` before storage). */
60
+ proof?: TProof;
61
+ }
62
+
63
+ /** Per-call options for {@link Suppression.isEligible}. */
64
+ export interface IsEligibleOptions {
65
+ /** The channel to check (`"email"`/`"sms"`/…). Omit to check the global gate only. */
66
+ channel?: string;
67
+ /** The list/purpose to require an opt-in for (used only when `requireOptIn`). */
68
+ listKey?: string;
69
+ /** When `true`, also require a recorded opt-in proof for `listKey`. */
70
+ requireOptIn?: boolean;
71
+ }
72
+
73
+ /** Construction options for the {@link Suppression} client. */
74
+ export interface SuppressionOptions<TProof> {
75
+ /**
76
+ * Validates/narrows opaque opt-in `proof` to `TProof` at the boundary — applied
77
+ * to the `proof` passed into `recordOptIn` (before storage) and the `proof`
78
+ * returned by `getOptInProof` (on read). Throws on a mismatch. Omit to leave
79
+ * proof evidence unvalidated.
80
+ */
81
+ proofValidator?: Parser<TProof>;
82
+ }
@@ -0,0 +1,54 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Generated `api` utility.
4
+ *
5
+ * THIS CODE IS AUTOMATICALLY GENERATED.
6
+ *
7
+ * To regenerate, run `npx convex dev`.
8
+ * @module
9
+ */
10
+
11
+ import type * as mutations from "../mutations.js";
12
+ import type * as queries from "../queries.js";
13
+ import type * as validators from "../validators.js";
14
+
15
+ import type {
16
+ ApiFromModules,
17
+ FilterApi,
18
+ FunctionReference,
19
+ } from "convex/server";
20
+ import { anyApi, componentsGeneric } from "convex/server";
21
+
22
+ const fullApi: ApiFromModules<{
23
+ mutations: typeof mutations;
24
+ queries: typeof queries;
25
+ validators: typeof validators;
26
+ }> = anyApi as any;
27
+
28
+ /**
29
+ * A utility for referencing Convex functions in your app's public API.
30
+ *
31
+ * Usage:
32
+ * ```js
33
+ * const myFunctionReference = api.myModule.myFunction;
34
+ * ```
35
+ */
36
+ export const api: FilterApi<
37
+ typeof fullApi,
38
+ FunctionReference<any, "public">
39
+ > = anyApi as any;
40
+
41
+ /**
42
+ * A utility for referencing Convex functions in your app's internal API.
43
+ *
44
+ * Usage:
45
+ * ```js
46
+ * const myFunctionReference = internal.myModule.myFunction;
47
+ * ```
48
+ */
49
+ export const internal: FilterApi<
50
+ typeof fullApi,
51
+ FunctionReference<any, "internal">
52
+ > = anyApi as any;
53
+
54
+ export const components = componentsGeneric() as unknown as {};
@@ -0,0 +1,102 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Generated `ComponentApi` utility.
4
+ *
5
+ * THIS CODE IS AUTOMATICALLY GENERATED.
6
+ *
7
+ * To regenerate, run `npx convex dev`.
8
+ * @module
9
+ */
10
+
11
+ import type { FunctionReference } from "convex/server";
12
+
13
+ /**
14
+ * A utility for referencing a Convex component's exposed API.
15
+ *
16
+ * Useful when expecting a parameter like `components.myComponent`.
17
+ * Usage:
18
+ * ```ts
19
+ * async function myFunction(ctx: QueryCtx, component: ComponentApi) {
20
+ * return ctx.runQuery(component.someFile.someQuery, { ...args });
21
+ * }
22
+ * ```
23
+ */
24
+ export type ComponentApi<Name extends string | undefined = string | undefined> =
25
+ {
26
+ mutations: {
27
+ suppress: FunctionReference<
28
+ "mutation",
29
+ "internal",
30
+ {
31
+ channel: string;
32
+ contactHash: string;
33
+ reason:
34
+ | "unsubscribe"
35
+ | "bounce"
36
+ | "complaint"
37
+ | "manual"
38
+ | "global";
39
+ },
40
+ null,
41
+ Name
42
+ >;
43
+ unsuppress: FunctionReference<
44
+ "mutation",
45
+ "internal",
46
+ { channel: string; contactHash: string },
47
+ boolean,
48
+ Name
49
+ >;
50
+ recordOptIn: FunctionReference<
51
+ "mutation",
52
+ "internal",
53
+ { contactHash: string; listKey: string; proof?: any; source: string },
54
+ null,
55
+ Name
56
+ >;
57
+ };
58
+ queries: {
59
+ isSuppressed: FunctionReference<
60
+ "query",
61
+ "internal",
62
+ { channel: string; contactHash: string },
63
+ null | {
64
+ channel: string | null;
65
+ contactHash: string;
66
+ createdAt: number;
67
+ reason:
68
+ | "unsubscribe"
69
+ | "bounce"
70
+ | "complaint"
71
+ | "manual"
72
+ | "global";
73
+ },
74
+ Name
75
+ >;
76
+ getOptInProof: FunctionReference<
77
+ "query",
78
+ "internal",
79
+ { contactHash: string; listKey: string },
80
+ null | {
81
+ confirmedAt: number;
82
+ contactHash: string;
83
+ listKey: string | null;
84
+ proof?: any;
85
+ source: string;
86
+ },
87
+ Name
88
+ >;
89
+ isEligible: FunctionReference<
90
+ "query",
91
+ "internal",
92
+ {
93
+ channel: string;
94
+ contactHash: string;
95
+ listKey: string;
96
+ requireOptIn: boolean;
97
+ },
98
+ boolean,
99
+ Name
100
+ >;
101
+ };
102
+ };
@@ -0,0 +1,60 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Generated data model types.
4
+ *
5
+ * THIS CODE IS AUTOMATICALLY GENERATED.
6
+ *
7
+ * To regenerate, run `npx convex dev`.
8
+ * @module
9
+ */
10
+
11
+ import type {
12
+ DataModelFromSchemaDefinition,
13
+ DocumentByName,
14
+ TableNamesInDataModel,
15
+ SystemTableNames,
16
+ } from "convex/server";
17
+ import type { GenericId } from "convex/values";
18
+ import schema from "../schema.js";
19
+
20
+ /**
21
+ * The names of all of your Convex tables.
22
+ */
23
+ export type TableNames = TableNamesInDataModel<DataModel>;
24
+
25
+ /**
26
+ * The type of a document stored in Convex.
27
+ *
28
+ * @typeParam TableName - A string literal type of the table name (like "users").
29
+ */
30
+ export type Doc<TableName extends TableNames> = DocumentByName<
31
+ DataModel,
32
+ TableName
33
+ >;
34
+
35
+ /**
36
+ * An identifier for a document in Convex.
37
+ *
38
+ * Convex documents are uniquely identified by their `Id`, which is accessible
39
+ * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
40
+ *
41
+ * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
42
+ *
43
+ * IDs are just strings at runtime, but this type can be used to distinguish them from other
44
+ * strings when type checking.
45
+ *
46
+ * @typeParam TableName - A string literal type of the table name (like "users").
47
+ */
48
+ export type Id<TableName extends TableNames | SystemTableNames> =
49
+ GenericId<TableName>;
50
+
51
+ /**
52
+ * A type describing your Convex data model.
53
+ *
54
+ * This type includes information about what tables you have, the type of
55
+ * documents stored in those tables, and the indexes defined on them.
56
+ *
57
+ * This type is used to parameterize methods like `queryGeneric` and
58
+ * `mutationGeneric` to make them type-safe.
59
+ */
60
+ export type DataModel = DataModelFromSchemaDefinition<typeof schema>;