@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.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/client/index.d.ts +135 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +131 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +70 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +3 -0
- package/dist/client/types.js.map +1 -0
- package/dist/component/_generated/api.d.ts +38 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +67 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +7 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/mutations.d.ts +49 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +110 -0
- package/dist/component/mutations.js.map +1 -0
- package/dist/component/queries.d.ts +46 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +112 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +51 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +39 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/validators.d.ts +50 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +40 -0
- package/dist/component/validators.js.map +1 -0
- package/dist/shared.d.ts +22 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +26 -0
- package/dist/shared.js.map +1 -0
- package/package.json +101 -0
- package/src/client/index.ts +271 -0
- package/src/client/types.ts +82 -0
- package/src/component/_generated/api.ts +54 -0
- package/src/component/_generated/component.ts +102 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/mutations.ts +118 -0
- package/src/component/queries.ts +128 -0
- package/src/component/schema.ts +40 -0
- package/src/component/validators.ts +49 -0
- package/src/shared.ts +31 -0
- package/src/test.ts +15 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { query } from "./_generated/server";
|
|
3
|
+
import { GLOBAL_CHANNEL } from "../shared";
|
|
4
|
+
import { optInProofView, suppressionView } from "./validators";
|
|
5
|
+
/** Project a stored suppression row to its public view (drops internal fields). */
|
|
6
|
+
function view(row) {
|
|
7
|
+
return {
|
|
8
|
+
contactHash: row.contactHash,
|
|
9
|
+
channel: row.channel === GLOBAL_CHANNEL ? null : row.channel,
|
|
10
|
+
reason: row.reason,
|
|
11
|
+
createdAt: row.createdAt,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The matching suppression for `(contactHash, channel)`, or `null` if the contact
|
|
16
|
+
* is not suppressed on that channel. A `channel` argument matches a global
|
|
17
|
+
* (all-channel) suppression OR a channel-specific one — a global tombstone wins
|
|
18
|
+
* and is returned first. Omit `channel` (the sentinel) to check the global entry
|
|
19
|
+
* only. Two bounded equality reads on `by_hash_channel`; never spans contacts.
|
|
20
|
+
*/
|
|
21
|
+
export const isSuppressed = query({
|
|
22
|
+
args: { contactHash: v.string(), channel: v.string() },
|
|
23
|
+
returns: v.union(v.null(), suppressionView),
|
|
24
|
+
handler: async (ctx, args) => {
|
|
25
|
+
const globalRow = await ctx.db
|
|
26
|
+
.query("suppressions")
|
|
27
|
+
.withIndex("by_hash_channel", (q) => q.eq("contactHash", args.contactHash).eq("channel", GLOBAL_CHANNEL))
|
|
28
|
+
.unique();
|
|
29
|
+
if (globalRow !== null) {
|
|
30
|
+
return view(globalRow);
|
|
31
|
+
}
|
|
32
|
+
if (args.channel === GLOBAL_CHANNEL) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const channelRow = await ctx.db
|
|
36
|
+
.query("suppressions")
|
|
37
|
+
.withIndex("by_hash_channel", (q) => q.eq("contactHash", args.contactHash).eq("channel", args.channel))
|
|
38
|
+
.unique();
|
|
39
|
+
return channelRow === null ? null : view(channelRow);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* The opt-in proof for `(contactHash, listKey)`, or `null` if none is recorded.
|
|
44
|
+
* `listKey` holds the global sentinel to fetch a global opt-in. `proof` is the
|
|
45
|
+
* opaque host evidence, narrowed by the host validator at the client boundary.
|
|
46
|
+
*/
|
|
47
|
+
export const getOptInProof = query({
|
|
48
|
+
args: { contactHash: v.string(), listKey: v.string() },
|
|
49
|
+
returns: v.union(v.null(), optInProofView),
|
|
50
|
+
handler: async (ctx, args) => {
|
|
51
|
+
const row = await ctx.db
|
|
52
|
+
.query("optInProofs")
|
|
53
|
+
.withIndex("by_hash_list", (q) => q.eq("contactHash", args.contactHash).eq("listKey", args.listKey))
|
|
54
|
+
.unique();
|
|
55
|
+
if (row === null) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
contactHash: row.contactHash,
|
|
60
|
+
listKey: row.listKey === GLOBAL_CHANNEL ? null : row.listKey,
|
|
61
|
+
source: row.source,
|
|
62
|
+
proof: row.proof,
|
|
63
|
+
confirmedAt: row.confirmedAt,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* The send gate: `true` when the contact may be contacted. A contact is eligible
|
|
69
|
+
* when it is NOT suppressed on `channel` (global or channel-specific) and — when
|
|
70
|
+
* `requireOptIn` is set — has a recorded opt-in proof for `listKey`. This is the
|
|
71
|
+
* single call a sender makes before every send: `¬suppressed [∧ confirmed]`.
|
|
72
|
+
* Suppression always blocks; the opt-in requirement is opt-in per call (marketing
|
|
73
|
+
* mail sets it; a transactional send may not).
|
|
74
|
+
*/
|
|
75
|
+
export const isEligible = query({
|
|
76
|
+
args: {
|
|
77
|
+
contactHash: v.string(),
|
|
78
|
+
channel: v.string(),
|
|
79
|
+
listKey: v.string(),
|
|
80
|
+
requireOptIn: v.boolean(),
|
|
81
|
+
},
|
|
82
|
+
returns: v.boolean(),
|
|
83
|
+
handler: async (ctx, args) => {
|
|
84
|
+
const globalRow = await ctx.db
|
|
85
|
+
.query("suppressions")
|
|
86
|
+
.withIndex("by_hash_channel", (q) => q.eq("contactHash", args.contactHash).eq("channel", GLOBAL_CHANNEL))
|
|
87
|
+
.unique();
|
|
88
|
+
if (globalRow !== null) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (args.channel !== GLOBAL_CHANNEL) {
|
|
92
|
+
const channelRow = await ctx.db
|
|
93
|
+
.query("suppressions")
|
|
94
|
+
.withIndex("by_hash_channel", (q) => q.eq("contactHash", args.contactHash).eq("channel", args.channel))
|
|
95
|
+
.unique();
|
|
96
|
+
if (channelRow !== null) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (args.requireOptIn) {
|
|
101
|
+
const proof = await ctx.db
|
|
102
|
+
.query("optInProofs")
|
|
103
|
+
.withIndex("by_hash_list", (q) => q.eq("contactHash", args.contactHash).eq("listKey", args.listKey))
|
|
104
|
+
.unique();
|
|
105
|
+
if (proof === null) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
//# sourceMappingURL=queries.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queries.js","sourceRoot":"","sources":["../../src/component/queries.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two sandboxed tables — the do-not-contact gate's own concern.
|
|
3
|
+
*
|
|
4
|
+
* `suppressions` is the anti-membership: a `(contactHash, channel)` tombstone that
|
|
5
|
+
* says "never contact this hash on this channel". `contactHash` is the host's
|
|
6
|
+
* opaque `hash(normalize(contact))` — the component never stores a raw email or
|
|
7
|
+
* phone, so a suppression survives erasure of the underlying subject. `channel`
|
|
8
|
+
* holds the host channel (`"email"`/`"sms"`/…) or the `GLOBAL_CHANNEL` sentinel
|
|
9
|
+
* `"*"` for an all-channel suppression. `reason` is recorded for audit. Indexed
|
|
10
|
+
* `by_hash_channel` (the exact-channel and global lookups an `isSuppressed` check
|
|
11
|
+
* makes) and `by_hash` (every entry for a hash, for `unsuppress` and audit).
|
|
12
|
+
*
|
|
13
|
+
* `optInProofs` is the legal evidence (not an authz relation): a `(contactHash,
|
|
14
|
+
* listKey)` record of a confirmed opt-in. `listKey` scopes it to one list/purpose
|
|
15
|
+
* or holds `GLOBAL_CHANNEL` for a global opt-in; `source` tags the capture
|
|
16
|
+
* channel; `proof` is opaque host evidence narrowed by a host validator. Indexed
|
|
17
|
+
* `by_hash_list` (the exact lookup `getOptInProof`/`isEligible` make).
|
|
18
|
+
*/
|
|
19
|
+
declare const _default: import("convex/server").SchemaDefinition<{
|
|
20
|
+
suppressions: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
21
|
+
contactHash: string;
|
|
22
|
+
channel: string;
|
|
23
|
+
reason: "unsubscribe" | "bounce" | "complaint" | "manual" | "global";
|
|
24
|
+
createdAt: number;
|
|
25
|
+
}, {
|
|
26
|
+
contactHash: import("convex/values").VString<string, "required">;
|
|
27
|
+
channel: import("convex/values").VString<string, "required">;
|
|
28
|
+
reason: import("convex/values").VUnion<"unsubscribe" | "bounce" | "complaint" | "manual" | "global", [import("convex/values").VLiteral<"unsubscribe", "required">, import("convex/values").VLiteral<"bounce", "required">, import("convex/values").VLiteral<"complaint", "required">, import("convex/values").VLiteral<"manual", "required">, import("convex/values").VLiteral<"global", "required">], "required", never>;
|
|
29
|
+
createdAt: import("convex/values").VFloat64<number, "required">;
|
|
30
|
+
}, "required", "contactHash" | "channel" | "reason" | "createdAt">, {
|
|
31
|
+
by_hash: ["contactHash", "_creationTime"];
|
|
32
|
+
by_hash_channel: ["contactHash", "channel", "_creationTime"];
|
|
33
|
+
}, {}, {}>;
|
|
34
|
+
optInProofs: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
35
|
+
proof?: any;
|
|
36
|
+
contactHash: string;
|
|
37
|
+
listKey: string;
|
|
38
|
+
source: string;
|
|
39
|
+
confirmedAt: number;
|
|
40
|
+
}, {
|
|
41
|
+
contactHash: import("convex/values").VString<string, "required">;
|
|
42
|
+
listKey: import("convex/values").VString<string, "required">;
|
|
43
|
+
source: import("convex/values").VString<string, "required">;
|
|
44
|
+
proof: import("convex/values").VAny<any, "optional", string>;
|
|
45
|
+
confirmedAt: import("convex/values").VFloat64<number, "required">;
|
|
46
|
+
}, "required", "contactHash" | "listKey" | "source" | "proof" | "confirmedAt" | `proof.${string}`>, {
|
|
47
|
+
by_hash_list: ["contactHash", "listKey", "_creationTime"];
|
|
48
|
+
}, {}, {}>;
|
|
49
|
+
}, true>;
|
|
50
|
+
export default _default;
|
|
51
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/component/schema.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;GAiBG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AACH,wBAiBG"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { jsonValue, suppressionReason } from "./validators";
|
|
4
|
+
/**
|
|
5
|
+
* Two sandboxed tables — the do-not-contact gate's own concern.
|
|
6
|
+
*
|
|
7
|
+
* `suppressions` is the anti-membership: a `(contactHash, channel)` tombstone that
|
|
8
|
+
* says "never contact this hash on this channel". `contactHash` is the host's
|
|
9
|
+
* opaque `hash(normalize(contact))` — the component never stores a raw email or
|
|
10
|
+
* phone, so a suppression survives erasure of the underlying subject. `channel`
|
|
11
|
+
* holds the host channel (`"email"`/`"sms"`/…) or the `GLOBAL_CHANNEL` sentinel
|
|
12
|
+
* `"*"` for an all-channel suppression. `reason` is recorded for audit. Indexed
|
|
13
|
+
* `by_hash_channel` (the exact-channel and global lookups an `isSuppressed` check
|
|
14
|
+
* makes) and `by_hash` (every entry for a hash, for `unsuppress` and audit).
|
|
15
|
+
*
|
|
16
|
+
* `optInProofs` is the legal evidence (not an authz relation): a `(contactHash,
|
|
17
|
+
* listKey)` record of a confirmed opt-in. `listKey` scopes it to one list/purpose
|
|
18
|
+
* or holds `GLOBAL_CHANNEL` for a global opt-in; `source` tags the capture
|
|
19
|
+
* channel; `proof` is opaque host evidence narrowed by a host validator. Indexed
|
|
20
|
+
* `by_hash_list` (the exact lookup `getOptInProof`/`isEligible` make).
|
|
21
|
+
*/
|
|
22
|
+
export default defineSchema({
|
|
23
|
+
suppressions: defineTable({
|
|
24
|
+
contactHash: v.string(),
|
|
25
|
+
channel: v.string(),
|
|
26
|
+
reason: suppressionReason,
|
|
27
|
+
createdAt: v.number(),
|
|
28
|
+
})
|
|
29
|
+
.index("by_hash", ["contactHash"])
|
|
30
|
+
.index("by_hash_channel", ["contactHash", "channel"]),
|
|
31
|
+
optInProofs: defineTable({
|
|
32
|
+
contactHash: v.string(),
|
|
33
|
+
listKey: v.string(),
|
|
34
|
+
source: v.string(),
|
|
35
|
+
proof: v.optional(jsonValue),
|
|
36
|
+
confirmedAt: v.number(),
|
|
37
|
+
}).index("by_hash_list", ["contactHash", "listKey"]),
|
|
38
|
+
});
|
|
39
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/component/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,CAAC,EAAE,MAAM,eAAe,CAAC;AAClC,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAE5D;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAe,YAAY,CAAC;IAC1B,YAAY,EAAE,WAAW,CAAC;QACxB,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE;QACvB,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;QACnB,MAAM,EAAE,iBAAiB;QACzB,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;KACtB,CAAC;SACC,KAAK,CAAC,SAAS,EAAE,CAAC,aAAa,CAAC,CAAC;SACjC,KAAK,CAAC,iBAAiB,EAAE,CAAC,aAAa,EAAE,SAAS,CAAC,CAAC;IAEvD,WAAW,EAAE,WAAW,CAAC;QACvB,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE;QACvB,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;QACnB,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;QAClB,KAAK,EAAE,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAC5B,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE;KACxB,CAAC,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC,aAAa,EAAE,SAAS,CAAC,CAAC;CACrD,CAAC,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque host-owned legal evidence attached to an opt-in proof — e.g. the IP,
|
|
3
|
+
* user-agent, double-opt-in token ref, or form snapshot the host captured at
|
|
4
|
+
* confirmation. The component never inspects it; it is last-resort arbitrary data,
|
|
5
|
+
* aliased here rather than left bare in function signatures. The host narrows it at
|
|
6
|
+
* the {@link Suppression} client boundary via an optional `proofValidator` parser.
|
|
7
|
+
*
|
|
8
|
+
* This is the single documented `v.any()` escape hatch in the component; the lint
|
|
9
|
+
* rule `convex-rules/no-bare-v-any` is satisfied by routing the arbitrary host
|
|
10
|
+
* payload through this alias instead of a bare `v.any()`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const jsonValue: import("convex/values").VAny<any, "required", string>;
|
|
13
|
+
/** The five standard reasons a `(contactHash, channel)` is suppressed. */
|
|
14
|
+
export declare const suppressionReason: import("convex/values").VUnion<"unsubscribe" | "bounce" | "complaint" | "manual" | "global", [import("convex/values").VLiteral<"unsubscribe", "required">, import("convex/values").VLiteral<"bounce", "required">, import("convex/values").VLiteral<"complaint", "required">, import("convex/values").VLiteral<"manual", "required">, import("convex/values").VLiteral<"global", "required">], "required", never>;
|
|
15
|
+
/**
|
|
16
|
+
* Public projection of a suppression returned by {@link isSuppressed}. `channel`
|
|
17
|
+
* is the host-supplied channel the entry applies to, or `null` for a global
|
|
18
|
+
* (all-channel) tombstone. `contactHash` is the host's opaque contact hash — never
|
|
19
|
+
* a raw email/phone.
|
|
20
|
+
*/
|
|
21
|
+
export declare const suppressionView: import("convex/values").VObject<{
|
|
22
|
+
contactHash: string;
|
|
23
|
+
channel: string | null;
|
|
24
|
+
reason: "unsubscribe" | "bounce" | "complaint" | "manual" | "global";
|
|
25
|
+
createdAt: number;
|
|
26
|
+
}, {
|
|
27
|
+
contactHash: import("convex/values").VString<string, "required">;
|
|
28
|
+
channel: import("convex/values").VUnion<string | null, [import("convex/values").VString<string, "required">, import("convex/values").VNull<null, "required">], "required", never>;
|
|
29
|
+
reason: import("convex/values").VUnion<"unsubscribe" | "bounce" | "complaint" | "manual" | "global", [import("convex/values").VLiteral<"unsubscribe", "required">, import("convex/values").VLiteral<"bounce", "required">, import("convex/values").VLiteral<"complaint", "required">, import("convex/values").VLiteral<"manual", "required">, import("convex/values").VLiteral<"global", "required">], "required", never>;
|
|
30
|
+
createdAt: import("convex/values").VFloat64<number, "required">;
|
|
31
|
+
}, "required", "contactHash" | "channel" | "reason" | "createdAt">;
|
|
32
|
+
/**
|
|
33
|
+
* Public projection of an opt-in proof returned by {@link getOptInProof}.
|
|
34
|
+
* `listKey` scopes the proof to one list/purpose, or `null` for a global opt-in.
|
|
35
|
+
* `source` tags how the opt-in was captured; `proof` is opaque host evidence.
|
|
36
|
+
*/
|
|
37
|
+
export declare const optInProofView: import("convex/values").VObject<{
|
|
38
|
+
proof?: any;
|
|
39
|
+
contactHash: string;
|
|
40
|
+
listKey: string | null;
|
|
41
|
+
source: string;
|
|
42
|
+
confirmedAt: number;
|
|
43
|
+
}, {
|
|
44
|
+
contactHash: import("convex/values").VString<string, "required">;
|
|
45
|
+
listKey: import("convex/values").VUnion<string | null, [import("convex/values").VString<string, "required">, import("convex/values").VNull<null, "required">], "required", never>;
|
|
46
|
+
source: import("convex/values").VString<string, "required">;
|
|
47
|
+
proof: import("convex/values").VAny<any, "optional", string>;
|
|
48
|
+
confirmedAt: import("convex/values").VFloat64<number, "required">;
|
|
49
|
+
}, "required", "contactHash" | "listKey" | "source" | "proof" | "confirmedAt" | `proof.${string}`>;
|
|
50
|
+
//# sourceMappingURL=validators.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../src/component/validators.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,SAAS,uDAAU,CAAC;AAEjC,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,mZAM7B,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,eAAe;;;;;;;;;;kEAK1B,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;kGAMzB,CAAC"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
/**
|
|
3
|
+
* Opaque host-owned legal evidence attached to an opt-in proof — e.g. the IP,
|
|
4
|
+
* user-agent, double-opt-in token ref, or form snapshot the host captured at
|
|
5
|
+
* confirmation. The component never inspects it; it is last-resort arbitrary data,
|
|
6
|
+
* aliased here rather than left bare in function signatures. The host narrows it at
|
|
7
|
+
* the {@link Suppression} client boundary via an optional `proofValidator` parser.
|
|
8
|
+
*
|
|
9
|
+
* This is the single documented `v.any()` escape hatch in the component; the lint
|
|
10
|
+
* rule `convex-rules/no-bare-v-any` is satisfied by routing the arbitrary host
|
|
11
|
+
* payload through this alias instead of a bare `v.any()`.
|
|
12
|
+
*/
|
|
13
|
+
export const jsonValue = v.any();
|
|
14
|
+
/** The five standard reasons a `(contactHash, channel)` is suppressed. */
|
|
15
|
+
export const suppressionReason = v.union(v.literal("unsubscribe"), v.literal("bounce"), v.literal("complaint"), v.literal("manual"), v.literal("global"));
|
|
16
|
+
/**
|
|
17
|
+
* Public projection of a suppression returned by {@link isSuppressed}. `channel`
|
|
18
|
+
* is the host-supplied channel the entry applies to, or `null` for a global
|
|
19
|
+
* (all-channel) tombstone. `contactHash` is the host's opaque contact hash — never
|
|
20
|
+
* a raw email/phone.
|
|
21
|
+
*/
|
|
22
|
+
export const suppressionView = v.object({
|
|
23
|
+
contactHash: v.string(),
|
|
24
|
+
channel: v.union(v.string(), v.null()),
|
|
25
|
+
reason: suppressionReason,
|
|
26
|
+
createdAt: v.number(),
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Public projection of an opt-in proof returned by {@link getOptInProof}.
|
|
30
|
+
* `listKey` scopes the proof to one list/purpose, or `null` for a global opt-in.
|
|
31
|
+
* `source` tags how the opt-in was captured; `proof` is opaque host evidence.
|
|
32
|
+
*/
|
|
33
|
+
export const optInProofView = v.object({
|
|
34
|
+
contactHash: v.string(),
|
|
35
|
+
listKey: v.union(v.string(), v.null()),
|
|
36
|
+
source: v.string(),
|
|
37
|
+
proof: v.optional(jsonValue),
|
|
38
|
+
confirmedAt: v.number(),
|
|
39
|
+
});
|
|
40
|
+
//# sourceMappingURL=validators.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validators.js","sourceRoot":"","sources":["../../src/component/validators.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,eAAe,CAAC;AAElC;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;AAEjC,0EAA0E;AAC1E,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CACtC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC,EACxB,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,EACnB,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC,EACtB,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,EACnB,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CACpB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,CAAC,MAAM,CAAC;IACtC,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE;IACvB,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACtC,MAAM,EAAE,iBAAiB;IACzB,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;CACtB,CAAC,CAAC;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC;IACrC,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE;IACvB,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACtC,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;IAClB,KAAK,EAAE,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;IAC5B,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE;CACxB,CAAC,CAAC"}
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Shared constants used by both `client/` and `component/`. */
|
|
2
|
+
export declare const COMPONENT_NAME = "suppression";
|
|
3
|
+
/**
|
|
4
|
+
* The standard suppression reasons. `unsubscribe` is an explicit opt-out (a
|
|
5
|
+
* one-click unsubscribe or a list-removal request); `bounce` and `complaint` are
|
|
6
|
+
* deliverability signals fed from a mail/SMS provider's events; `manual` is an
|
|
7
|
+
* operator action; `global` marks a do-not-contact-anywhere tombstone. The reason
|
|
8
|
+
* is recorded for audit — it never changes the suppression's effect (a suppressed
|
|
9
|
+
* hash is suppressed regardless of why).
|
|
10
|
+
*/
|
|
11
|
+
export declare const SUPPRESSION_REASONS: readonly ["unsubscribe", "bounce", "complaint", "manual", "global"];
|
|
12
|
+
/** A single suppression reason. */
|
|
13
|
+
export type SuppressionReason = (typeof SUPPRESSION_REASONS)[number];
|
|
14
|
+
/**
|
|
15
|
+
* The sentinel stored in the `channel` index slot for a global (all-channel)
|
|
16
|
+
* suppression. A real channel is any other host-supplied string (`"email"`,
|
|
17
|
+
* `"sms"`, `"push"`, …). Storing a concrete value rather than `undefined` lets a
|
|
18
|
+
* channel-scoped check hit a single equality index that matches the global row and
|
|
19
|
+
* the channel row in two bounded reads, with no `undefined` index gap.
|
|
20
|
+
*/
|
|
21
|
+
export declare const GLOBAL_CHANNEL = "*";
|
|
22
|
+
//# sourceMappingURL=shared.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAEhE,eAAO,MAAM,cAAc,gBAAgB,CAAC;AAE5C;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,qEAMtB,CAAC;AAEX,mCAAmC;AACnC,MAAM,MAAM,iBAAiB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,CAAC,CAAC;AAErE;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,MAAM,CAAC"}
|
package/dist/shared.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Shared constants used by both `client/` and `component/`. */
|
|
2
|
+
export const COMPONENT_NAME = "suppression";
|
|
3
|
+
/**
|
|
4
|
+
* The standard suppression reasons. `unsubscribe` is an explicit opt-out (a
|
|
5
|
+
* one-click unsubscribe or a list-removal request); `bounce` and `complaint` are
|
|
6
|
+
* deliverability signals fed from a mail/SMS provider's events; `manual` is an
|
|
7
|
+
* operator action; `global` marks a do-not-contact-anywhere tombstone. The reason
|
|
8
|
+
* is recorded for audit — it never changes the suppression's effect (a suppressed
|
|
9
|
+
* hash is suppressed regardless of why).
|
|
10
|
+
*/
|
|
11
|
+
export const SUPPRESSION_REASONS = [
|
|
12
|
+
"unsubscribe",
|
|
13
|
+
"bounce",
|
|
14
|
+
"complaint",
|
|
15
|
+
"manual",
|
|
16
|
+
"global",
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* The sentinel stored in the `channel` index slot for a global (all-channel)
|
|
20
|
+
* suppression. A real channel is any other host-supplied string (`"email"`,
|
|
21
|
+
* `"sms"`, `"push"`, …). Storing a concrete value rather than `undefined` lets a
|
|
22
|
+
* channel-scoped check hit a single equality index that matches the global row and
|
|
23
|
+
* the channel row in two bounded reads, with no `undefined` index gap.
|
|
24
|
+
*/
|
|
25
|
+
export const GLOBAL_CHANNEL = "*";
|
|
26
|
+
//# sourceMappingURL=shared.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared.js","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAEhE,MAAM,CAAC,MAAM,cAAc,GAAG,aAAa,CAAC;AAE5C;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,aAAa;IACb,QAAQ;IACR,WAAW;IACX,QAAQ;IACR,QAAQ;CACA,CAAC;AAKX;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,GAAG,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vllnt/convex-suppression",
|
|
3
|
+
"version": "0.1.0-canary.261f634",
|
|
4
|
+
"description": "Do-not-contact suppression list + opt-in proof (GDPR/CAN-SPAM), hash-keyed and channel-aware, as a Convex component",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"packageManager": "pnpm@9.15.4",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
"./package.json": "./package.json",
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/client/index.d.ts",
|
|
16
|
+
"default": "./dist/client/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./test": "./src/test.ts",
|
|
19
|
+
"./_generated/component.js": {
|
|
20
|
+
"types": "./dist/component/_generated/component.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"./_generated/component": {
|
|
23
|
+
"types": "./dist/component/_generated/component.d.ts"
|
|
24
|
+
},
|
|
25
|
+
"./convex.config": {
|
|
26
|
+
"types": "./dist/component/convex.config.d.ts",
|
|
27
|
+
"default": "./dist/component/convex.config.js"
|
|
28
|
+
},
|
|
29
|
+
"./convex.config.js": {
|
|
30
|
+
"types": "./dist/component/convex.config.d.ts",
|
|
31
|
+
"default": "./dist/component/convex.config.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"types": "./dist/client/index.d.ts",
|
|
35
|
+
"module": "./dist/client/index.js",
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc --project ./tsconfig.build.json",
|
|
38
|
+
"build:codegen": "pnpm convex codegen --component-dir ./src/component && pnpm build",
|
|
39
|
+
"build:clean": "rm -rf dist *.tsbuildinfo && pnpm build:codegen",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"typecheck:ci": "tsc --noEmit --project tsconfig.ci.json",
|
|
42
|
+
"lint": "eslint .",
|
|
43
|
+
"test": "vitest run --passWithNoTests",
|
|
44
|
+
"test:watch": "vitest --typecheck --clearScreen false",
|
|
45
|
+
"test:coverage": "vitest run --coverage --coverage.reporter=text",
|
|
46
|
+
"generate:llms": "node scripts/generate-llms.mjs",
|
|
47
|
+
"preversion": "pnpm install --frozen-lockfile && pnpm build && pnpm test:coverage && pnpm typecheck && pnpm generate:llms",
|
|
48
|
+
"prepublishOnly": "npm whoami || npm login",
|
|
49
|
+
"alpha": "npm version prerelease --preid alpha && npm publish --tag alpha && git push --follow-tags",
|
|
50
|
+
"release": "npm version patch && npm publish && git push --follow-tags"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"convex": "^1.41.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@edge-runtime/vm": "^5.0.0",
|
|
58
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
59
|
+
"@vllnt/eslint-config": "^1.0.0",
|
|
60
|
+
"@vllnt/typescript": "^1.0.0",
|
|
61
|
+
"convex": "^1.41.0",
|
|
62
|
+
"convex-test": "^0.0.53",
|
|
63
|
+
"eslint": "^9.0.0",
|
|
64
|
+
"prettier": "^3.4.0",
|
|
65
|
+
"typescript": "^5.7.0",
|
|
66
|
+
"vitest": "^4.1.8"
|
|
67
|
+
},
|
|
68
|
+
"homepage": "https://github.com/vllnt/convex-suppression#readme",
|
|
69
|
+
"bugs": {
|
|
70
|
+
"url": "https://github.com/vllnt/convex-suppression/issues"
|
|
71
|
+
},
|
|
72
|
+
"author": {
|
|
73
|
+
"name": "bntvllnt",
|
|
74
|
+
"url": "https://bntvllnt.com"
|
|
75
|
+
},
|
|
76
|
+
"funding": {
|
|
77
|
+
"type": "github",
|
|
78
|
+
"url": "https://github.com/sponsors/bntvllnt"
|
|
79
|
+
},
|
|
80
|
+
"repository": {
|
|
81
|
+
"type": "git",
|
|
82
|
+
"url": "https://github.com/vllnt/convex-suppression"
|
|
83
|
+
},
|
|
84
|
+
"engines": {
|
|
85
|
+
"node": ">=18"
|
|
86
|
+
},
|
|
87
|
+
"publishConfig": {
|
|
88
|
+
"access": "public"
|
|
89
|
+
},
|
|
90
|
+
"sideEffects": false,
|
|
91
|
+
"keywords": [
|
|
92
|
+
"convex",
|
|
93
|
+
"convex-component",
|
|
94
|
+
"suppression",
|
|
95
|
+
"do-not-contact",
|
|
96
|
+
"unsubscribe",
|
|
97
|
+
"gdpr",
|
|
98
|
+
"can-spam",
|
|
99
|
+
"opt-in"
|
|
100
|
+
]
|
|
101
|
+
}
|