@tokenoftrust/storefront-runner 4.9.2 → 4.10.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/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
- package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
- package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
- package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
- package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
- package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
- package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
- package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
- package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
- package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
- package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
- package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
- package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
- package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
- package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
- package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
- package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
- package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
- package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
- package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
- package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
- package/apps/storefront/src/pages/api/contact.ts +16 -4
- package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
- package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
- package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
- package/package.json +1 -1
- package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
- package/packages/public-runtime/src/integration/events.ts +15 -19
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
- package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
- package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
- package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
- package/packages/public-runtime/src/integration/plan.ts +6 -4
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
- package/packages/public-runtime/src/integration/render-phases.ts +10 -18
- package/packages/public-runtime/src/integration/resolve.ts +3 -3
- package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
- package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
- package/packages/public-runtime/src/tenant.ts +3 -3
- package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
- package/scripts/dev/ga4-gs2-canary.mjs +36 -10
- package/scripts/tenant/validate.mjs +9 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The tenant `analytics.json` contract, for the surfaces that cannot load the platform's
|
|
4
|
+
* TypeScript: `tot validate`, the kit's validate-site, `scripts/tenant/validate.mjs` and
|
|
5
|
+
* materialize-tenant.
|
|
6
|
+
*
|
|
7
|
+
* The platform materializes the file with `materializeTenantAnalyticsDeclarations`
|
|
8
|
+
* (`packages/public-runtime/src/integration/tenant-analytics.ts`), which builds each destination
|
|
9
|
+
* through the reviewed adapter builder. This module reports the same `{ path, code }` set for any
|
|
10
|
+
* input; `agency-kit/tools/lib/fixtures/analytics-declarations.json` is run through both, so a
|
|
11
|
+
* drift on either side fails a test.
|
|
12
|
+
*
|
|
13
|
+
* Messages carry no value from the file — never an id, never a key's value — only the field path
|
|
14
|
+
* and a fixed explanation, so a finding is safe to print, log or forward.
|
|
15
|
+
*
|
|
16
|
+
* Pure: callers pass the file's text. `packages/cli/src/analytics-declaration.mjs` is a
|
|
17
|
+
* byte-identical copy (a test pins it).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The tenant-owned file, at the tenant tree's root. */
|
|
21
|
+
export const ANALYTICS_DECLARATION_FILE = "analytics.json";
|
|
22
|
+
|
|
23
|
+
/** The current `analytics.json` contract version. */
|
|
24
|
+
export const ANALYTICS_DECLARATION_SCHEMA_VERSION = 1;
|
|
25
|
+
|
|
26
|
+
/** The stages a destination id may be declared for, in the platform's environment order. */
|
|
27
|
+
export const ANALYTICS_STAGES = Object.freeze(["local", "candidate", "staged", "live"]);
|
|
28
|
+
|
|
29
|
+
/** The `euUkVisitors` policies; `ask` is the default. */
|
|
30
|
+
export const ANALYTICS_EU_UK_POLICIES = Object.freeze(["ask", "count-only", "full", "none"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The first-class platform adapters a tenant may declare. Pinned against the platform's
|
|
34
|
+
* `PLATFORM_ADAPTERS` by `packages/public-runtime/tests/integration/tenant-analytics.test.ts`.
|
|
35
|
+
*/
|
|
36
|
+
export const ANALYTICS_ADAPTERS = Object.freeze([
|
|
37
|
+
Object.freeze({
|
|
38
|
+
adapterId: "platform.google-measurement",
|
|
39
|
+
maxDestinationsPerStage: 4,
|
|
40
|
+
idPattern: /^G-[A-Z0-9]{4,20}$/,
|
|
41
|
+
idLabel: "GA4 measurement id (e.g. G-XXXXXXX)",
|
|
42
|
+
}),
|
|
43
|
+
Object.freeze({
|
|
44
|
+
adapterId: "platform.meta-pixel",
|
|
45
|
+
maxDestinationsPerStage: 1,
|
|
46
|
+
idPattern: /^\d{10,20}$/,
|
|
47
|
+
idLabel: "Meta pixel id (10–20 digits)",
|
|
48
|
+
}),
|
|
49
|
+
Object.freeze({
|
|
50
|
+
adapterId: "platform.amplitude",
|
|
51
|
+
maxDestinationsPerStage: 1,
|
|
52
|
+
idPattern: /^(?=[a-f0-9]{32}$)(?=.*[a-f])[a-f0-9]+$/i,
|
|
53
|
+
idLabel: "Amplitude project API key (32 hexadecimal characters)",
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const ANNOTATION_KEYS = ["$schema", "$comment", "$id"];
|
|
58
|
+
const ROOT_KEYS = ["schemaVersion", "destinations", "euUkVisitors"];
|
|
59
|
+
const DESTINATION_KEYS = ["adapter", "ids"];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {Object} AnalyticsViolation
|
|
63
|
+
* @property {string} path the offending field, e.g. `destinations[0].ids.live[1]`
|
|
64
|
+
* @property {string} code a stable machine code, e.g. `format`, `unknown_field`
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {Object} AnalyticsFinding
|
|
69
|
+
* @property {string} path
|
|
70
|
+
* @property {string} code
|
|
71
|
+
* @property {string} message value-free: the path, the code and a fixed explanation
|
|
72
|
+
* @property {string} remedy
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
76
|
+
function isPlainObject(value) {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {Record<string, unknown>} obj
|
|
82
|
+
* @param {readonly string[]} allowed
|
|
83
|
+
* @param {string} base
|
|
84
|
+
* @param {AnalyticsViolation[]} out
|
|
85
|
+
*/
|
|
86
|
+
function unknownKeys(obj, allowed, base, out) {
|
|
87
|
+
for (const key of Object.keys(obj)) {
|
|
88
|
+
if (allowed.includes(key) || ANNOTATION_KEYS.includes(key)) continue;
|
|
89
|
+
out.push({ path: base ? `${base}.${key}` : key, code: "unknown_field" });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The adapter builder's per-stage id rules, with its paths re-anchored under the destination.
|
|
95
|
+
* @param {Record<string, unknown>} ids
|
|
96
|
+
* @param {(typeof ANALYTICS_ADAPTERS)[number]} adapter
|
|
97
|
+
* @param {string} base `destinations[i]`
|
|
98
|
+
* @param {AnalyticsViolation[]} out
|
|
99
|
+
*/
|
|
100
|
+
function stageIdViolations(ids, adapter, base, out) {
|
|
101
|
+
/** @type {Map<string, string[]>} */
|
|
102
|
+
const valid = new Map();
|
|
103
|
+
for (const [stage, raw] of Object.entries(ids)) {
|
|
104
|
+
const at = `${base}.ids.${stage}`;
|
|
105
|
+
if (!ANALYTICS_STAGES.includes(stage)) {
|
|
106
|
+
out.push({ path: at, code: "unknown_enum" });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!Array.isArray(raw)) {
|
|
110
|
+
out.push({ path: at, code: "type" });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (raw.length === 0) {
|
|
114
|
+
out.push({ path: at, code: "empty" });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (raw.length > adapter.maxDestinationsPerStage) {
|
|
118
|
+
out.push({ path: at, code: "too_many_destinations" });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
let stageOk = true;
|
|
122
|
+
raw.forEach((id, index) => {
|
|
123
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
124
|
+
out.push({ path: `${at}[${index}]`, code: "type" });
|
|
125
|
+
stageOk = false;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!adapter.idPattern.test(id)) {
|
|
129
|
+
out.push({ path: `${at}[${index}]`, code: "format" });
|
|
130
|
+
stageOk = false;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
if (!stageOk) continue;
|
|
134
|
+
if (new Set(raw).size !== raw.length) {
|
|
135
|
+
out.push({ path: at, code: "duplicate" });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
valid.set(stage, /** @type {string[]} */ (raw));
|
|
139
|
+
}
|
|
140
|
+
const live = valid.get("live");
|
|
141
|
+
if (live === undefined) {
|
|
142
|
+
out.push({ path: `${base}.ids.live`, code: "required" });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
for (const stage of ANALYTICS_STAGES) {
|
|
146
|
+
if (stage === "live") continue;
|
|
147
|
+
(valid.get(stage) ?? []).forEach((id, index) => {
|
|
148
|
+
if (live.includes(id)) out.push({ path: `${base}.ids.${stage}[${index}]`, code: "non_live_reuses_live_id" });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Every violation in an already-parsed `analytics.json` value. `undefined`/`null` (no file) is
|
|
155
|
+
* the undeclared norm and has none.
|
|
156
|
+
* @param {unknown} value
|
|
157
|
+
* @returns {AnalyticsViolation[]}
|
|
158
|
+
*/
|
|
159
|
+
export function analyticsDeclarationViolations(value) {
|
|
160
|
+
/** @type {AnalyticsViolation[]} */
|
|
161
|
+
const out = [];
|
|
162
|
+
if (value === undefined || value === null) return out;
|
|
163
|
+
if (!isPlainObject(value)) {
|
|
164
|
+
out.push({ path: "(root)", code: "type" });
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
unknownKeys(value, ROOT_KEYS, "", out);
|
|
168
|
+
if ("euUkVisitors" in value && !ANALYTICS_EU_UK_POLICIES.includes(/** @type {string} */ (value.euUkVisitors))) {
|
|
169
|
+
out.push({ path: "euUkVisitors", code: "unknown_enum" });
|
|
170
|
+
}
|
|
171
|
+
if ("schemaVersion" in value && value.schemaVersion !== ANALYTICS_DECLARATION_SCHEMA_VERSION) {
|
|
172
|
+
out.push({ path: "schemaVersion", code: "unsupported_version" });
|
|
173
|
+
}
|
|
174
|
+
const destinations = value.destinations;
|
|
175
|
+
if (!Array.isArray(destinations)) {
|
|
176
|
+
out.push({ path: "destinations", code: "type" });
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/** @type {Set<string>} */
|
|
180
|
+
const seenAdapters = new Set();
|
|
181
|
+
destinations.forEach((destination, index) => {
|
|
182
|
+
const base = `destinations[${index}]`;
|
|
183
|
+
if (!isPlainObject(destination)) {
|
|
184
|
+
out.push({ path: base, code: "type" });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
unknownKeys(destination, DESTINATION_KEYS, base, out);
|
|
188
|
+
const adapterId = destination.adapter;
|
|
189
|
+
if (typeof adapterId !== "string" || adapterId.length === 0) {
|
|
190
|
+
out.push({ path: `${base}.adapter`, code: "type" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const ids = destination.ids;
|
|
194
|
+
if (!isPlainObject(ids)) {
|
|
195
|
+
out.push({ path: `${base}.ids`, code: "type" });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (seenAdapters.has(adapterId)) {
|
|
199
|
+
out.push({ path: `${base}.adapter`, code: "duplicate" });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
seenAdapters.add(adapterId);
|
|
203
|
+
const adapter = ANALYTICS_ADAPTERS.find((a) => a.adapterId === adapterId);
|
|
204
|
+
if (!adapter) {
|
|
205
|
+
out.push({ path: `${base}.adapter`, code: "unknown_adapter" });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
stageIdViolations(ids, adapter, base, out);
|
|
209
|
+
});
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Parse `analytics.json` text. Unparseable text is a `(root)` `invalid_json` violation, never an
|
|
215
|
+
* absent file: a declaration that cannot be read must refuse, not publish with no analytics.
|
|
216
|
+
* @param {string} text
|
|
217
|
+
* @returns {{ ok: true, value: unknown } | { ok: false, violations: AnalyticsViolation[] }}
|
|
218
|
+
*/
|
|
219
|
+
export function parseAnalyticsDeclaration(text) {
|
|
220
|
+
try {
|
|
221
|
+
return { ok: true, value: JSON.parse(text) };
|
|
222
|
+
} catch {
|
|
223
|
+
return { ok: false, violations: [{ path: "(root)", code: "invalid_json" }] };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const ADAPTER_IDS = ANALYTICS_ADAPTERS.map((a) => a.adapterId).join(", ");
|
|
228
|
+
const ID_FORMATS = ANALYTICS_ADAPTERS.map((a) => `${a.adapterId}: ${a.idLabel}`).join("; ");
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A fixed, value-free explanation and remedy for a violation.
|
|
232
|
+
* @param {AnalyticsViolation} violation
|
|
233
|
+
* @returns {{ message: string, remedy: string }}
|
|
234
|
+
*/
|
|
235
|
+
export function analyticsViolationText({ path, code }) {
|
|
236
|
+
switch (code) {
|
|
237
|
+
case "invalid_json":
|
|
238
|
+
return { message: "analytics.json is not valid JSON", remedy: "fix the JSON syntax" };
|
|
239
|
+
case "type":
|
|
240
|
+
return {
|
|
241
|
+
message: "has the wrong type",
|
|
242
|
+
remedy: 'use { "schemaVersion": 1, "destinations": [{ "adapter": "<adapter id>", "ids": { "live": ["<id>"] } }] }',
|
|
243
|
+
};
|
|
244
|
+
case "unknown_field":
|
|
245
|
+
return { message: "is not an analytics.json field", remedy: "remove it; analytics.json is a closed contract" };
|
|
246
|
+
case "unknown_enum":
|
|
247
|
+
return path === "euUkVisitors"
|
|
248
|
+
? { message: "is not an EU/UK visitor policy", remedy: `use one of: ${ANALYTICS_EU_UK_POLICIES.join(", ")}` }
|
|
249
|
+
: { message: "is not a stage", remedy: `use one of: ${ANALYTICS_STAGES.join(", ")}` };
|
|
250
|
+
case "unsupported_version":
|
|
251
|
+
return { message: "is not a supported schemaVersion", remedy: `set it to ${ANALYTICS_DECLARATION_SCHEMA_VERSION}` };
|
|
252
|
+
case "unknown_adapter":
|
|
253
|
+
return { message: "is not a first-class analytics adapter", remedy: `use one of: ${ADAPTER_IDS}` };
|
|
254
|
+
case "empty":
|
|
255
|
+
return { message: "names no destination id", remedy: "name at least one id, or remove the stage" };
|
|
256
|
+
case "too_many_destinations":
|
|
257
|
+
return { message: "names more destination ids than this adapter accepts per stage", remedy: "remove the extra ids" };
|
|
258
|
+
case "format":
|
|
259
|
+
return { message: "is not a valid destination id for this adapter", remedy: `use the adapter's id format (${ID_FORMATS})` };
|
|
260
|
+
case "duplicate":
|
|
261
|
+
return path.endsWith(".adapter")
|
|
262
|
+
? { message: "names an adapter an earlier destination already names", remedy: "declare each adapter once, with all of its ids" }
|
|
263
|
+
: { message: "names the same destination id twice", remedy: "list each id once" };
|
|
264
|
+
case "required":
|
|
265
|
+
return { message: "has no valid live destination id", remedy: "declare a valid live id for every destination" };
|
|
266
|
+
case "non_live_reuses_live_id":
|
|
267
|
+
return {
|
|
268
|
+
message: "reuses a live destination id, so non-live traffic would reach the live property",
|
|
269
|
+
remedy: "use a separate test id, or leave the stage unset",
|
|
270
|
+
};
|
|
271
|
+
default:
|
|
272
|
+
return { message: "is invalid", remedy: "see docs/architecture/analytics-activation.md" };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Every violation in an `analytics.json` file's text. A file that is present but holds `null` is
|
|
278
|
+
* a `(root)` `type` violation: only an absent file declares nothing.
|
|
279
|
+
* @param {string} text
|
|
280
|
+
* @returns {AnalyticsViolation[]}
|
|
281
|
+
*/
|
|
282
|
+
export function analyticsDeclarationTextViolations(text) {
|
|
283
|
+
const parsed = parseAnalyticsDeclaration(text);
|
|
284
|
+
if (!parsed.ok) return [...parsed.violations];
|
|
285
|
+
if (parsed.value === null) return [{ path: "(root)", code: "type" }];
|
|
286
|
+
return analyticsDeclarationViolations(parsed.value);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Every finding in an `analytics.json` file's text, each value-free.
|
|
291
|
+
* @param {string} text
|
|
292
|
+
* @returns {AnalyticsFinding[]}
|
|
293
|
+
*/
|
|
294
|
+
export function analyticsDeclarationFindings(text) {
|
|
295
|
+
return analyticsDeclarationTextViolations(text).map((violation) => {
|
|
296
|
+
const { message, remedy } = analyticsViolationText(violation);
|
|
297
|
+
return { ...violation, message: `${violation.path}: [${violation.code}] ${message}`, remedy };
|
|
298
|
+
});
|
|
299
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
-- Contact vault: order customer contact and contact-form messages are stored only as AES-256-GCM
|
|
2
|
+
-- envelopes (src/lib/orders/vault/envelope.ts) in expiring rows. One transaction per file.
|
|
3
|
+
--
|
|
4
|
+
-- The plaintext scrappy_order_addresses table is dropped only when it is empty: the guard row's
|
|
5
|
+
-- CHECK fails on any other count and aborts the whole file.
|
|
6
|
+
CREATE TABLE `_gv_vault_guard` (`n` integer NOT NULL CHECK (`n` = 0));
|
|
7
|
+
--> statement-breakpoint
|
|
8
|
+
INSERT INTO `_gv_vault_guard` (`n`) SELECT COUNT(*) FROM `scrappy_order_addresses`;
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
DROP TABLE `_gv_vault_guard`;
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
DROP TABLE `scrappy_order_addresses`;
|
|
13
|
+
--> statement-breakpoint
|
|
14
|
+
CREATE TABLE `order_contact_vault` (
|
|
15
|
+
`tenant_id` text NOT NULL,
|
|
16
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
17
|
+
`order_id` text NOT NULL,
|
|
18
|
+
`key_id` text NOT NULL,
|
|
19
|
+
`nonce` text NOT NULL,
|
|
20
|
+
`ciphertext` text NOT NULL,
|
|
21
|
+
`not_after` text NOT NULL,
|
|
22
|
+
`expires_at` text NOT NULL CHECK (`expires_at` <= `not_after`),
|
|
23
|
+
`sealed_at` text NOT NULL,
|
|
24
|
+
`sealed_jti` text NOT NULL,
|
|
25
|
+
PRIMARY KEY (`tenant_id`, `stage`, `order_id`),
|
|
26
|
+
FOREIGN KEY (`tenant_id`, `stage`, `order_id`) REFERENCES `scrappy_commerce_orders` (`tenant_id`, `stage`, `order_id`)
|
|
27
|
+
);
|
|
28
|
+
--> statement-breakpoint
|
|
29
|
+
CREATE INDEX `idx_order_contact_vault_expires` ON `order_contact_vault` (`expires_at`);
|
|
30
|
+
--> statement-breakpoint
|
|
31
|
+
CREATE INDEX `idx_order_contact_vault_key` ON `order_contact_vault` (`key_id`);
|
|
32
|
+
--> statement-breakpoint
|
|
33
|
+
CREATE TRIGGER `order_contact_vault_retention_only_shrinks`
|
|
34
|
+
BEFORE UPDATE ON `order_contact_vault`
|
|
35
|
+
WHEN NEW.`expires_at` > OLD.`expires_at`
|
|
36
|
+
OR NEW.`not_after` IS NOT OLD.`not_after`
|
|
37
|
+
OR NEW.`key_id` IS NOT OLD.`key_id`
|
|
38
|
+
OR NEW.`nonce` IS NOT OLD.`nonce`
|
|
39
|
+
OR NEW.`ciphertext` IS NOT OLD.`ciphertext`
|
|
40
|
+
OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
|
|
41
|
+
OR NEW.`stage` IS NOT OLD.`stage`
|
|
42
|
+
OR NEW.`order_id` IS NOT OLD.`order_id`
|
|
43
|
+
BEGIN
|
|
44
|
+
SELECT RAISE(ABORT, 'vault-immutable');
|
|
45
|
+
END;
|
|
46
|
+
--> statement-breakpoint
|
|
47
|
+
CREATE TABLE `order_contact_tombstones` (
|
|
48
|
+
`tenant_id` text NOT NULL,
|
|
49
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
50
|
+
`order_id` text NOT NULL,
|
|
51
|
+
`reason` text NOT NULL CHECK (`reason` IN ('purged', 'erased')),
|
|
52
|
+
`at` text NOT NULL,
|
|
53
|
+
`not_after` text NOT NULL,
|
|
54
|
+
PRIMARY KEY (`tenant_id`, `stage`, `order_id`)
|
|
55
|
+
);
|
|
56
|
+
--> statement-breakpoint
|
|
57
|
+
CREATE INDEX `idx_order_contact_tombstones_not_after` ON `order_contact_tombstones` (`not_after`);
|
|
58
|
+
--> statement-breakpoint
|
|
59
|
+
CREATE TABLE `contact_access_audit` (
|
|
60
|
+
`audit_id` text NOT NULL PRIMARY KEY,
|
|
61
|
+
`tenant_id` text NOT NULL,
|
|
62
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
63
|
+
`record_kind` text NOT NULL CHECK (`record_kind` IN ('order-contact', 'contact-message')),
|
|
64
|
+
`record_id` text NOT NULL,
|
|
65
|
+
`at` text NOT NULL,
|
|
66
|
+
`actor_id` text NOT NULL,
|
|
67
|
+
`actor_role` text NOT NULL CHECK (`actor_role` IN ('owner', 'staff')),
|
|
68
|
+
`reason_code` text,
|
|
69
|
+
`ticket_ref` text,
|
|
70
|
+
`action` text NOT NULL CHECK (`action` IN ('read', 'list', 'erase')),
|
|
71
|
+
`outcome` text NOT NULL CHECK (`outcome` = 'admitted'),
|
|
72
|
+
CHECK (`actor_role` = 'owner' OR (`reason_code` IS NOT NULL AND `ticket_ref` IS NOT NULL))
|
|
73
|
+
);
|
|
74
|
+
--> statement-breakpoint
|
|
75
|
+
CREATE INDEX `idx_contact_access_audit_quota` ON `contact_access_audit` (`tenant_id`, `actor_id`, `actor_role`, `at`);
|
|
76
|
+
--> statement-breakpoint
|
|
77
|
+
CREATE TRIGGER `contact_access_audit_no_update`
|
|
78
|
+
BEFORE UPDATE ON `contact_access_audit`
|
|
79
|
+
BEGIN
|
|
80
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
81
|
+
END;
|
|
82
|
+
--> statement-breakpoint
|
|
83
|
+
CREATE TRIGGER `contact_access_audit_no_delete`
|
|
84
|
+
BEFORE DELETE ON `contact_access_audit`
|
|
85
|
+
BEGIN
|
|
86
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
87
|
+
END;
|
|
88
|
+
--> statement-breakpoint
|
|
89
|
+
-- contact_messages: every pre-existing row is kept as a legacy row (plaintext in legacy_* until
|
|
90
|
+
-- the rollout seals it) under stage publication; new rows are written sealed only.
|
|
91
|
+
CREATE TABLE `contact_messages_sealed` (
|
|
92
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
93
|
+
`tenant_id` text NOT NULL,
|
|
94
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
95
|
+
`key_id` text,
|
|
96
|
+
`nonce` text,
|
|
97
|
+
`ciphertext` text,
|
|
98
|
+
`not_after` text NOT NULL,
|
|
99
|
+
`expires_at` text NOT NULL CHECK (`expires_at` <= `not_after`),
|
|
100
|
+
`page_path` text,
|
|
101
|
+
`status` text NOT NULL DEFAULT 'unread' CHECK (`status` IN ('unread', 'read')),
|
|
102
|
+
`created_at` text NOT NULL,
|
|
103
|
+
`read_at` text,
|
|
104
|
+
`notify_status` text NOT NULL DEFAULT 'pending' CHECK (`notify_status` IN ('pending', 'sent', 'failed')),
|
|
105
|
+
`notify_attempts` integer NOT NULL DEFAULT 0,
|
|
106
|
+
`legacy_name` text,
|
|
107
|
+
`legacy_email` text,
|
|
108
|
+
`legacy_phone` text,
|
|
109
|
+
`legacy_message` text,
|
|
110
|
+
CHECK (
|
|
111
|
+
(`key_id` IS NOT NULL AND `nonce` IS NOT NULL AND `ciphertext` IS NOT NULL
|
|
112
|
+
AND `legacy_name` IS NULL AND `legacy_email` IS NULL AND `legacy_phone` IS NULL AND `legacy_message` IS NULL)
|
|
113
|
+
OR (`key_id` IS NULL AND `nonce` IS NULL AND `ciphertext` IS NULL
|
|
114
|
+
AND `legacy_email` IS NOT NULL AND `legacy_message` IS NOT NULL)
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
--> statement-breakpoint
|
|
118
|
+
INSERT INTO `contact_messages_sealed`
|
|
119
|
+
(`id`, `tenant_id`, `stage`, `not_after`, `expires_at`, `page_path`, `status`, `created_at`, `read_at`,
|
|
120
|
+
`notify_status`, `notify_attempts`, `legacy_name`, `legacy_email`, `legacy_phone`, `legacy_message`)
|
|
121
|
+
SELECT `id`, `tenant_id`, 'publication',
|
|
122
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', `created_at`, '+365 days'),
|
|
123
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', `created_at`, '+365 days'),
|
|
124
|
+
`page_path`, `status`, `created_at`, `read_at`, `notify_status`, `notify_attempts`,
|
|
125
|
+
`name`, `email`, `phone`, `message`
|
|
126
|
+
FROM `contact_messages`;
|
|
127
|
+
--> statement-breakpoint
|
|
128
|
+
DROP TABLE `contact_messages`;
|
|
129
|
+
--> statement-breakpoint
|
|
130
|
+
ALTER TABLE `contact_messages_sealed` RENAME TO `contact_messages`;
|
|
131
|
+
--> statement-breakpoint
|
|
132
|
+
CREATE INDEX `idx_contact_messages_tenant_stage_created` ON `contact_messages` (`tenant_id`, `stage`, `created_at`);
|
|
133
|
+
--> statement-breakpoint
|
|
134
|
+
CREATE INDEX `idx_contact_messages_tenant_stage_status` ON `contact_messages` (`tenant_id`, `stage`, `status`);
|
|
135
|
+
--> statement-breakpoint
|
|
136
|
+
CREATE INDEX `idx_contact_messages_expires` ON `contact_messages` (`expires_at`);
|
|
137
|
+
--> statement-breakpoint
|
|
138
|
+
CREATE INDEX `idx_contact_messages_legacy` ON `contact_messages` (`id`) WHERE `ciphertext` IS NULL;
|
|
139
|
+
--> statement-breakpoint
|
|
140
|
+
CREATE TRIGGER `contact_messages_no_plaintext_insert`
|
|
141
|
+
BEFORE INSERT ON `contact_messages`
|
|
142
|
+
WHEN NEW.`legacy_name` IS NOT NULL OR NEW.`legacy_email` IS NOT NULL
|
|
143
|
+
OR NEW.`legacy_phone` IS NOT NULL OR NEW.`legacy_message` IS NOT NULL
|
|
144
|
+
BEGIN
|
|
145
|
+
SELECT RAISE(ABORT, 'plaintext-refused');
|
|
146
|
+
END;
|
|
147
|
+
--> statement-breakpoint
|
|
148
|
+
CREATE TRIGGER `contact_messages_no_plaintext_update`
|
|
149
|
+
BEFORE UPDATE ON `contact_messages`
|
|
150
|
+
WHEN (NEW.`legacy_name` IS NOT NULL AND NEW.`legacy_name` IS NOT OLD.`legacy_name`)
|
|
151
|
+
OR (NEW.`legacy_email` IS NOT NULL AND NEW.`legacy_email` IS NOT OLD.`legacy_email`)
|
|
152
|
+
OR (NEW.`legacy_phone` IS NOT NULL AND NEW.`legacy_phone` IS NOT OLD.`legacy_phone`)
|
|
153
|
+
OR (NEW.`legacy_message` IS NOT NULL AND NEW.`legacy_message` IS NOT OLD.`legacy_message`)
|
|
154
|
+
OR NEW.`expires_at` > OLD.`expires_at`
|
|
155
|
+
OR NEW.`not_after` IS NOT OLD.`not_after`
|
|
156
|
+
OR (OLD.`ciphertext` IS NOT NULL AND (NEW.`ciphertext` IS NOT OLD.`ciphertext`
|
|
157
|
+
OR NEW.`key_id` IS NOT OLD.`key_id` OR NEW.`nonce` IS NOT OLD.`nonce`))
|
|
158
|
+
OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
|
|
159
|
+
OR NEW.`stage` IS NOT OLD.`stage`
|
|
160
|
+
BEGIN
|
|
161
|
+
SELECT RAISE(ABORT, 'vault-immutable');
|
|
162
|
+
END;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may read customer contact data on THIS tenant, and on what authority.
|
|
3
|
+
*
|
|
4
|
+
* `isOwnerViewer` is not membership: a ToT-staff selection carries a capability, and a selection
|
|
5
|
+
* whose capability is `owner` makes `isOwnerViewer` true (loginGate `sessionHostCapability`). Contact
|
|
6
|
+
* reads must tell the two apart, because reading order and customer PII is grant-required for staff
|
|
7
|
+
* (guidance://access-authority) while an owner reads their own store's data. So:
|
|
8
|
+
*
|
|
9
|
+
* owner — a `tenants[]` membership for this host, or a session scoped to this host
|
|
10
|
+
* (`record.resource === host`), whose role is owner or admin. A staff selection never
|
|
11
|
+
* counts, whatever its capability.
|
|
12
|
+
* staff — no owner standing, but a live staff selection for this host. Staff still need a live
|
|
13
|
+
* `customer-contact-read` grant ({@link resolveContactReadGrant}) and a reason.
|
|
14
|
+
* none — anything else.
|
|
15
|
+
*/
|
|
16
|
+
import type { SessionRecord } from "./session.js";
|
|
17
|
+
import { normalizeMemberships, staffAdmits } from "@/lib/access/dashboard/tenantSelection";
|
|
18
|
+
import { isOwnerCapability } from "@/lib/access/shared/capability";
|
|
19
|
+
import { resolveNamedSubjectGrant, type ViewerShipGrantDeps } from "@/lib/access/shared/viewerGrant";
|
|
20
|
+
|
|
21
|
+
export type ContactReaderAuthority =
|
|
22
|
+
| { kind: "owner"; actorId: string }
|
|
23
|
+
| { kind: "staff"; actorId: string; email: string }
|
|
24
|
+
| { kind: "none" };
|
|
25
|
+
|
|
26
|
+
const ACTOR_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
27
|
+
|
|
28
|
+
export function contactReaderAuthority(
|
|
29
|
+
record: SessionRecord | null | undefined,
|
|
30
|
+
hostResource: string,
|
|
31
|
+
nowSeconds: number,
|
|
32
|
+
): ContactReaderAuthority {
|
|
33
|
+
const host = (hostResource ?? "").trim();
|
|
34
|
+
if (!record || !host) return { kind: "none" };
|
|
35
|
+
const actorId = typeof record.subject === "string" && ACTOR_ID.test(record.subject) ? record.subject : null;
|
|
36
|
+
if (!actorId) return { kind: "none" };
|
|
37
|
+
|
|
38
|
+
const membership = normalizeMemberships(record.tenants).find((t) => t.resource === host);
|
|
39
|
+
const scoped = record.resource === host ? (record.capability ?? "").trim() : "";
|
|
40
|
+
if (isOwnerCapability(membership?.role) || isOwnerCapability(scoped)) return { kind: "owner", actorId };
|
|
41
|
+
|
|
42
|
+
const staffScope = Array.isArray(record.staff) && record.staff.length > 0;
|
|
43
|
+
if (staffScope && staffAdmits({ selection: record.staffSelection, nowSeconds }, host)) {
|
|
44
|
+
return { kind: "staff", actorId, email: record.email };
|
|
45
|
+
}
|
|
46
|
+
return { kind: "none" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const CONTACT_READ_CAPABILITY = "customer-contact-read";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The staff principal's LIVE owner-consented `customer-contact-read` grant over `tenant`, read from
|
|
53
|
+
* the tot-mcp subject-capability seam (contract: decision gv-contact-vault-ownership). True only on
|
|
54
|
+
* an explicit `200 { capability: "customer-contact-read" }`. Unconfigured, any non-2xx, a timeout,
|
|
55
|
+
* an unparseable body or any other capability is false. No cache: a revoke takes effect on the next
|
|
56
|
+
* read. Never throws.
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveContactReadGrant(
|
|
59
|
+
email: string | null | undefined,
|
|
60
|
+
tenant: string | null | undefined,
|
|
61
|
+
deps: ViewerShipGrantDeps = {},
|
|
62
|
+
): Promise<boolean> {
|
|
63
|
+
return resolveNamedSubjectGrant(email, tenant, CONTACT_READ_CAPABILITY, deps);
|
|
64
|
+
}
|
|
@@ -19,7 +19,7 @@ export const STATIC_SCRIPT_HASHES: readonly string[] = [
|
|
|
19
19
|
"'sha256-YicfxUFBkKrQapZ/tzZKlaKTdNHgwVfv7y2Wa0NC6/k='",
|
|
20
20
|
"'sha256-eIXWvAmxkr251LJZkjniEK5LcPF3NkapbJepohwYRIc='",
|
|
21
21
|
"'sha256-elMLh/6Oubj8u9FRLwZLyRa6TqKikGs75mf0jfWcoxI='",
|
|
22
|
-
"'sha256-
|
|
22
|
+
"'sha256-MG9XnFVbN8UmLcNws9A8ckOhQJI+MnAKxpCI7oBMS3Q='",
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
export const STATIC_STYLE_HASHES: readonly string[] = [
|
|
@@ -108,6 +108,36 @@ async function resolveRawViewerCapability(
|
|
|
108
108
|
return "preview-access";
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Whether `email` holds a LIVE owner-consented grant of exactly `capability` over `appDomain`,
|
|
113
|
+
* asked of the same subject-capability seam with the capability named. True only on an explicit
|
|
114
|
+
* `200 { capability: <capability> }`; unconfigured, any non-2xx, a timeout, an unparseable body or
|
|
115
|
+
* any other answer is false. No cache: a revoke takes effect on the next call. Never throws.
|
|
116
|
+
*/
|
|
117
|
+
export async function resolveNamedSubjectGrant(
|
|
118
|
+
email: string | null | undefined,
|
|
119
|
+
appDomain: string | null | undefined,
|
|
120
|
+
capability: "customer-contact-read",
|
|
121
|
+
deps: ViewerShipGrantDeps = {},
|
|
122
|
+
): Promise<boolean> {
|
|
123
|
+
const subject = (email ?? "").trim().toLowerCase();
|
|
124
|
+
const tenant = (appDomain ?? "").trim();
|
|
125
|
+
if (!subject || !tenant) return false;
|
|
126
|
+
try {
|
|
127
|
+
const outcome = await callMcpService({
|
|
128
|
+
method: "GET",
|
|
129
|
+
path: "/internal/dev-access/subject-capability",
|
|
130
|
+
query: { subject, tenant, app: "storefront", capability },
|
|
131
|
+
transport: deps.transport ?? new FetchShipGrantTransport(),
|
|
132
|
+
});
|
|
133
|
+
if (outcome.kind !== "ok") return false;
|
|
134
|
+
const payload = (await outcome.json()) as Record<string, unknown> | null;
|
|
135
|
+
return payload?.capability === capability;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
111
141
|
/**
|
|
112
142
|
* Resolve the viewer's LIVE ship capability over `appDomain`. Returns the granted
|
|
113
143
|
* `Capability` — `"ship-on-behalf"` ONLY on an explicit `200 { capability:
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - a CloudFront-forwarded Tier 3 request (its `cf` describes CloudFront, not the
|
|
13
13
|
* viewer);
|
|
14
14
|
* - no `request.cf` at all.
|
|
15
|
-
* The page then keeps whatever class it already holds, or none (
|
|
15
|
+
* The page then keeps whatever class it already holds, or none (the EU/UK policy).
|
|
16
16
|
*
|
|
17
17
|
* The local dev loop's `request.cf` comes from the dev runtime's cached `cf.json`, which
|
|
18
18
|
* describes the developer's own location. `?tot_region=eu|x|u` overrides it so both
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contact access audit (`contact_access_audit`, append-only): every read, list and erase of
|
|
3
|
+
* sealed customer contact writes its row BEFORE anything is decrypted. Staff rows carry the
|
|
4
|
+
* 30-per-hour quota in the same statement, so concurrent reads can never pass it together.
|
|
5
|
+
* Rows hold opaque ids, codes and times only — never a contact field.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type VaultStage = "preview" | "publication";
|
|
9
|
+
|
|
10
|
+
export const STAFF_READS_PER_HOUR = 30;
|
|
11
|
+
const HOUR_MS = 3_600_000;
|
|
12
|
+
|
|
13
|
+
export interface ContactActor {
|
|
14
|
+
actorId: string;
|
|
15
|
+
role: "owner" | "staff";
|
|
16
|
+
reasonCode?: string;
|
|
17
|
+
ticketRef?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AuditStatement {
|
|
21
|
+
sql: string;
|
|
22
|
+
params: readonly unknown[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The SQL surface the audit needs; D1's `fromD1` and the sqlite test harness both satisfy it. */
|
|
26
|
+
export interface AuditDb {
|
|
27
|
+
all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The audit insert. RETURNING tells admitted from refused (quota, or `requireRow` absent); an
|
|
32
|
+
* error means the audit could not be written.
|
|
33
|
+
*/
|
|
34
|
+
export function auditStatement(input: {
|
|
35
|
+
auditId: string;
|
|
36
|
+
tenantId: string;
|
|
37
|
+
stage: VaultStage;
|
|
38
|
+
recordKind: "order-contact" | "contact-message";
|
|
39
|
+
recordId: string;
|
|
40
|
+
actor: ContactActor;
|
|
41
|
+
action: "read" | "list" | "erase";
|
|
42
|
+
now: Date;
|
|
43
|
+
requireRow?: AuditStatement;
|
|
44
|
+
}): AuditStatement {
|
|
45
|
+
const { auditId, tenantId, stage, recordKind, recordId, actor, action, now } = input;
|
|
46
|
+
const hourAgo = new Date(now.getTime() - HOUR_MS).toISOString();
|
|
47
|
+
const params: unknown[] = [
|
|
48
|
+
auditId, tenantId, stage, recordKind, recordId, now.toISOString(), actor.actorId, actor.role,
|
|
49
|
+
actor.role === "staff" ? actor.reasonCode ?? null : null,
|
|
50
|
+
actor.role === "staff" ? actor.ticketRef ?? null : null,
|
|
51
|
+
action,
|
|
52
|
+
actor.role, tenantId, actor.actorId, hourAgo, STAFF_READS_PER_HOUR,
|
|
53
|
+
];
|
|
54
|
+
const requireRow = input.requireRow ? ` AND EXISTS (${input.requireRow.sql})` : "";
|
|
55
|
+
if (input.requireRow) params.push(...input.requireRow.params);
|
|
56
|
+
return {
|
|
57
|
+
sql: `INSERT INTO contact_access_audit
|
|
58
|
+
(audit_id, tenant_id, stage, record_kind, record_id, at, actor_id, actor_role, reason_code, ticket_ref, action, outcome)
|
|
59
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'admitted'
|
|
60
|
+
WHERE (? = 'owner' OR (SELECT COUNT(*) FROM contact_access_audit
|
|
61
|
+
WHERE tenant_id = ? AND actor_id = ? AND actor_role = 'staff' AND at > ?) < ?)${requireRow}
|
|
62
|
+
RETURNING audit_id`,
|
|
63
|
+
params,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Run the audit insert alone: "admitted", "quota" (no row inserted) or "unavailable" (error). */
|
|
68
|
+
export async function admitAudit(db: AuditDb, statement: AuditStatement): Promise<"admitted" | "quota" | "unavailable"> {
|
|
69
|
+
try {
|
|
70
|
+
const rows = await db.all(statement.sql, ...statement.params);
|
|
71
|
+
return rows.length === 1 ? "admitted" : "quota";
|
|
72
|
+
} catch {
|
|
73
|
+
return "unavailable";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The same insert without RETURNING, for use inside a batch whose later statements check the row. */
|
|
78
|
+
export function batchAuditStatement(statement: AuditStatement): AuditStatement {
|
|
79
|
+
return { sql: statement.sql.replace(/\s*RETURNING audit_id$/, ""), params: statement.params };
|
|
80
|
+
}
|