@tokenoftrust/storefront-runner 6.5.1 → 6.5.2

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.
@@ -0,0 +1,70 @@
1
+ -- Owner consent for a Private App install to read order recipients for one purpose. Opaque ids and
2
+ -- times only; granted_by is sha256 of the owner actor id. Revoke sets revoked_at once; rows are never
3
+ -- deleted, and re-granting inserts a new row.
4
+ CREATE TABLE `app_contact_consents` (
5
+ `consent_id` text PRIMARY KEY NOT NULL,
6
+ `install_id` text NOT NULL,
7
+ `tenant_id` text NOT NULL,
8
+ `stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
9
+ `purpose` text NOT NULL CHECK (`purpose` = 'fulfillment-shipping'),
10
+ `granted_by` text NOT NULL CHECK (length(`granted_by`) = 64 AND `granted_by` NOT GLOB '*[^0-9a-f]*'),
11
+ `granted_at` text NOT NULL,
12
+ `expires_at` text NOT NULL,
13
+ `revoked_at` text,
14
+ CHECK (`expires_at` > `granted_at`),
15
+ CHECK (julianday(`expires_at`) IS NOT NULL AND julianday(`granted_at`) IS NOT NULL
16
+ AND julianday(`expires_at`) - julianday(`granted_at`) <= 365)
17
+ );
18
+ --> statement-breakpoint
19
+ CREATE UNIQUE INDEX `app_contact_consents_live`
20
+ ON `app_contact_consents` (`install_id`, `tenant_id`, `stage`, `purpose`)
21
+ WHERE `revoked_at` IS NULL;
22
+ --> statement-breakpoint
23
+ CREATE TRIGGER `app_contact_consents_no_delete`
24
+ BEFORE DELETE ON `app_contact_consents`
25
+ BEGIN
26
+ SELECT RAISE(ABORT, 'append-only');
27
+ END;
28
+ --> statement-breakpoint
29
+ CREATE TRIGGER `app_contact_consents_revoke_only`
30
+ BEFORE UPDATE ON `app_contact_consents`
31
+ WHEN OLD.`revoked_at` IS NOT NULL
32
+ OR NEW.`revoked_at` IS NULL
33
+ OR NEW.`consent_id` IS NOT OLD.`consent_id`
34
+ OR NEW.`install_id` IS NOT OLD.`install_id`
35
+ OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
36
+ OR NEW.`stage` IS NOT OLD.`stage`
37
+ OR NEW.`purpose` IS NOT OLD.`purpose`
38
+ OR NEW.`granted_by` IS NOT OLD.`granted_by`
39
+ OR NEW.`granted_at` IS NOT OLD.`granted_at`
40
+ OR NEW.`expires_at` IS NOT OLD.`expires_at`
41
+ BEGIN
42
+ SELECT RAISE(ABORT, 'revoke-only');
43
+ END;
44
+ --> statement-breakpoint
45
+ -- Every admitted app read of an order recipient, written before anything is decrypted. Opaque ids
46
+ -- only; append-only.
47
+ CREATE TABLE `app_contact_access_audit` (
48
+ `audit_id` text PRIMARY KEY NOT NULL,
49
+ `install_id` text NOT NULL,
50
+ `tenant_id` text NOT NULL,
51
+ `stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
52
+ `order_id` text NOT NULL,
53
+ `purpose` text NOT NULL CHECK (`purpose` = 'fulfillment-shipping'),
54
+ `at` text NOT NULL,
55
+ `outcome` text NOT NULL CHECK (`outcome` = 'admitted')
56
+ );
57
+ --> statement-breakpoint
58
+ CREATE INDEX `idx_app_contact_access_audit_quota` ON `app_contact_access_audit` (`install_id`, `tenant_id`, `at`);
59
+ --> statement-breakpoint
60
+ CREATE TRIGGER `app_contact_access_audit_no_update`
61
+ BEFORE UPDATE ON `app_contact_access_audit`
62
+ BEGIN
63
+ SELECT RAISE(ABORT, 'append-only');
64
+ END;
65
+ --> statement-breakpoint
66
+ CREATE TRIGGER `app_contact_access_audit_no_delete`
67
+ BEFORE DELETE ON `app_contact_access_audit`
68
+ BEGIN
69
+ SELECT RAISE(ABORT, 'append-only');
70
+ END;
@@ -18,7 +18,8 @@ export function auditRecordIds(ids: readonly string[]): string | null {
18
18
  if (ids.length > AUDIT_RECORD_IDS_MAX) throw new Error("contact audit: too many record ids");
19
19
  return ids.length === 0 ? null : ids.join(",");
20
20
  }
21
- const HOUR_MS = 3_600_000;
21
+ /** The quota window. A row counts while its `at` is strictly after `now - HOUR_MS`: one exactly an hour old no longer counts. */
22
+ export const HOUR_MS = 3_600_000;
22
23
 
23
24
  export interface ContactActor {
24
25
  actorId: string;
@@ -88,6 +89,60 @@ export async function admitAudit(db: AuditDb, statement: AuditStatement): Promis
88
89
  }
89
90
  }
90
91
 
92
+ /** Admitted app recipient reads per install and tenant per hour. */
93
+ export const APP_CONTACT_READS_PER_HOUR = 300;
94
+ export const APP_CONTACT_PURPOSE = "fulfillment-shipping";
95
+
96
+ /**
97
+ * The app-access audit insert (`app_contact_access_audit`): the recipient read's admission and its
98
+ * only write. It inserts one row only when, at that instant, the install is active on the tenant
99
+ * holding `scope`, the assignment is the install's active assignment of the order, the order still
100
+ * stands at `eventVersion` (the version its releasability was computed at), a live consent covers
101
+ * (install, tenant, stage, purpose), and the install has fewer than
102
+ * {@link APP_CONTACT_READS_PER_HOUR} admitted rows on the tenant in the last hour.
103
+ */
104
+ export function appAuditStatement(input: {
105
+ auditId: string;
106
+ installId: string;
107
+ tenantId: string;
108
+ stage: VaultStage;
109
+ orderId: string;
110
+ assignmentId: string;
111
+ eventVersion: number;
112
+ scope: string;
113
+ now: Date;
114
+ }): AuditStatement {
115
+ const { auditId, installId, tenantId, stage, orderId, assignmentId, eventVersion, scope, now } = input;
116
+ const at = now.toISOString();
117
+ const hourAgo = new Date(now.getTime() - HOUR_MS).toISOString();
118
+ return {
119
+ sql: `INSERT INTO app_contact_access_audit (audit_id, install_id, tenant_id, stage, order_id, purpose, at, outcome)
120
+ SELECT ?, ?, ?, ?, ?, '${APP_CONTACT_PURPOSE}', ?, 'admitted'
121
+ WHERE EXISTS (SELECT 1 FROM app_installs AS ai
122
+ WHERE ai.install_id = ? AND ai.tenant_id = ? AND ai.status = 'active'
123
+ AND EXISTS (SELECT 1 FROM json_each(ai.scopes) WHERE json_each.value = ?))
124
+ AND EXISTS (SELECT 1 FROM fulfillment_assignments AS fa
125
+ WHERE fa.assignment_id = ? AND fa.tenant_id = ? AND fa.stage = ? AND fa.order_id = ?
126
+ AND fa.install_id = ? AND fa.state = 'active')
127
+ AND EXISTS (SELECT 1 FROM scrappy_commerce_orders AS o
128
+ WHERE o.tenant_id = ? AND o.stage = ? AND o.order_id = ? AND o.event_version = ?)
129
+ AND EXISTS (SELECT 1 FROM app_contact_consents AS c
130
+ WHERE c.install_id = ? AND c.tenant_id = ? AND c.stage = ? AND c.purpose = '${APP_CONTACT_PURPOSE}'
131
+ AND c.revoked_at IS NULL AND c.expires_at > ?)
132
+ AND (SELECT COUNT(*) FROM app_contact_access_audit AS q
133
+ WHERE q.install_id = ? AND q.tenant_id = ? AND q.at > ?) < ?
134
+ RETURNING audit_id`,
135
+ params: [
136
+ auditId, installId, tenantId, stage, orderId, at,
137
+ installId, tenantId, scope,
138
+ assignmentId, tenantId, stage, orderId, installId,
139
+ tenantId, stage, orderId, eventVersion,
140
+ installId, tenantId, stage, at,
141
+ installId, tenantId, hourAgo, APP_CONTACT_READS_PER_HOUR,
142
+ ],
143
+ };
144
+ }
145
+
91
146
  /** The same insert without RETURNING, for use inside a batch whose later statements check the row. */
92
147
  export function batchAuditStatement(statement: AuditStatement): AuditStatement {
93
148
  return { sql: statement.sql.replace(/\s*RETURNING audit_id$/, ""), params: statement.params };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Whether a verified Private App request may ask for order contact at all: the only source of the
3
+ * `app` contact-reader kind. It is separate from `contactReaderAuthority`, whose outcomes stay
4
+ * owner, staff or none, so no human contact route can ever see an app.
5
+ *
6
+ * Every predicate is required: an install record, active, on the host tenant, the token's subject
7
+ * naming it, and the scope in both the install grant and the token. Consent, assignment and quota
8
+ * are decided by the audit insert, not here.
9
+ */
10
+ export type AppContactAuthority = { kind: "app"; installId: string; tenantId: string } | { kind: "none" };
11
+
12
+ export interface AppContactAuthorityInput {
13
+ install: { installId: string; tenantId: string; status: string; scopes: readonly string[] } | null | undefined;
14
+ claims: { sub: string; scopes: readonly string[] } | null | undefined;
15
+ hostResource: string;
16
+ scope: string;
17
+ }
18
+
19
+ export function appContactAuthority(input: AppContactAuthorityInput): AppContactAuthority {
20
+ const { install, claims, scope } = input;
21
+ const host = input.hostResource.trim();
22
+ if (!install || !claims || !host || !scope) return { kind: "none" };
23
+ if (install.status !== "active" || install.tenantId !== host) return { kind: "none" };
24
+ if (!install.installId || claims.sub !== install.installId) return { kind: "none" };
25
+ if (!install.scopes.includes(scope) || !claims.scopes.includes(scope)) return { kind: "none" };
26
+ return { kind: "app", installId: install.installId, tenantId: host };
27
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Owner consent for a Private App install to read order recipients (`app_contact_consents`). One
3
+ * purpose, `fulfillment-shipping`. Rows hold opaque ids, times and `granted_by` (sha256 hex of the
4
+ * owner actor id); never contact data or an email. Grant inserts a row only when the install is
5
+ * active on the tenant; revoke sets `revoked_at` once; uninstall revokes every live row in its own
6
+ * batch. The table's triggers refuse DELETE and every other UPDATE.
7
+ */
8
+ import type { Queryable } from "../../catalog/d1/catalog.js";
9
+
10
+ export const CONSENT_PURPOSE = "fulfillment-shipping";
11
+ export const CONSENT_MAX_MS = 365 * 24 * 3_600_000;
12
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
13
+ const CONSENT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
14
+
15
+ export type ConsentStage = "preview" | "publication";
16
+
17
+ export interface ConsentView {
18
+ consentId: string;
19
+ installId: string;
20
+ stage: ConsentStage;
21
+ purpose: typeof CONSENT_PURPOSE;
22
+ grantedAt: string;
23
+ expiresAt: string;
24
+ revokedAt: string | null;
25
+ }
26
+
27
+ export function isConsentId(value: string): boolean {
28
+ return CONSENT_ID.test(value) && !value.includes("@");
29
+ }
30
+
31
+ /**
32
+ * The closed grant body `{installId, purpose, expiresAt}`: purpose exactly `fulfillment-shipping`,
33
+ * expiresAt an ISO instant after `now` and at most 365 days later. Null otherwise.
34
+ */
35
+ export function readConsentGrant(body: unknown, now: Date): { installId: string; expiresAt: string } | null {
36
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
37
+ const record = body as Record<string, unknown>;
38
+ const keys = Object.keys(record).sort();
39
+ if (keys.length !== 3 || keys.join(",") !== "expiresAt,installId,purpose") return null;
40
+ const { installId, purpose, expiresAt } = record;
41
+ if (purpose !== CONSENT_PURPOSE) return null;
42
+ if (typeof installId !== "string" || !isConsentId(installId)) return null;
43
+ if (typeof expiresAt !== "string" || !ISO_INSTANT.test(expiresAt)) return null;
44
+ const expires = Date.parse(expiresAt);
45
+ if (!Number.isFinite(expires) || new Date(expires).toISOString() !== expiresAt) return null;
46
+ if (expires <= now.getTime() || expires - now.getTime() > CONSENT_MAX_MS) return null;
47
+ return { installId, expiresAt };
48
+ }
49
+
50
+ /** sha256 hex of the owner actor id: the only owner reference a consent row keeps. */
51
+ export async function grantedByHash(actorId: string): Promise<string> {
52
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(actorId));
53
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
54
+ }
55
+
56
+ function isUniqueViolation(error: unknown): boolean {
57
+ return error instanceof Error && /UNIQUE constraint failed/i.test(error.message);
58
+ }
59
+
60
+ export type GrantOutcome =
61
+ | { ok: true; consent: ConsentView }
62
+ | { ok: false; code: "install-not-found" | "conflict" };
63
+
64
+ export async function grantConsent(
65
+ db: Queryable,
66
+ input: { tenantId: string; stage: ConsentStage; installId: string; expiresAt: string; grantedBy: string; now: Date; consentId?: string },
67
+ ): Promise<GrantOutcome> {
68
+ const consentId = input.consentId ?? crypto.randomUUID();
69
+ const grantedAt = input.now.toISOString();
70
+ try {
71
+ const rows = await db.all<{ consent_id: string }>(
72
+ `INSERT INTO app_contact_consents (consent_id, install_id, tenant_id, stage, purpose, granted_by, granted_at, expires_at, revoked_at)
73
+ SELECT ?, ?, ?, ?, '${CONSENT_PURPOSE}', ?, ?, ?, NULL
74
+ WHERE EXISTS (SELECT 1 FROM app_installs WHERE install_id = ? AND tenant_id = ? AND status = 'active')
75
+ RETURNING consent_id`,
76
+ consentId, input.installId, input.tenantId, input.stage, input.grantedBy, grantedAt, input.expiresAt,
77
+ input.installId, input.tenantId,
78
+ );
79
+ if (rows.length !== 1) return { ok: false, code: "install-not-found" };
80
+ } catch (error) {
81
+ if (isUniqueViolation(error)) return { ok: false, code: "conflict" };
82
+ throw error;
83
+ }
84
+ return {
85
+ ok: true,
86
+ consent: {
87
+ consentId, installId: input.installId, stage: input.stage, purpose: CONSENT_PURPOSE,
88
+ grantedAt, expiresAt: input.expiresAt, revokedAt: null,
89
+ },
90
+ };
91
+ }
92
+
93
+ /** Sets `revoked_at` on one live consent of (tenant, stage); null when there is none to revoke. */
94
+ export async function revokeConsent(
95
+ db: Queryable,
96
+ input: { tenantId: string; stage: ConsentStage; consentId: string; now: Date },
97
+ ): Promise<ConsentView | null> {
98
+ const rows = await db.all<{
99
+ consent_id: string; install_id: string; stage: ConsentStage; granted_at: string; expires_at: string; revoked_at: string;
100
+ }>(
101
+ `UPDATE app_contact_consents SET revoked_at = ?
102
+ WHERE consent_id = ? AND tenant_id = ? AND stage = ? AND revoked_at IS NULL
103
+ RETURNING consent_id, install_id, stage, granted_at, expires_at, revoked_at`,
104
+ input.now.toISOString(), input.consentId, input.tenantId, input.stage,
105
+ );
106
+ const row = rows[0];
107
+ if (!row) return null;
108
+ return {
109
+ consentId: row.consent_id, installId: row.install_id, stage: row.stage, purpose: CONSENT_PURPOSE,
110
+ grantedAt: row.granted_at, expiresAt: row.expires_at, revokedAt: row.revoked_at,
111
+ };
112
+ }
@@ -709,6 +709,7 @@ export class AppRegistryService {
709
709
  params: [this.now(), installId],
710
710
  },
711
711
  ...this.detach(row.tenant_id, installId, actor),
712
+ this.revokeConsentsStatement(installId, row.tenant_id),
712
713
  this.auditStatement({ installId, tenantId: row.tenant_id, event: "uninstalled", actor }),
713
714
  ]);
714
715
  await this.credentials.clear(installId);
@@ -785,6 +786,14 @@ export class AppRegistryService {
785
786
  };
786
787
  }
787
788
 
789
+ /** Revokes every live contact-read consent of the install on its tenant (`app_contact_consents`). */
790
+ private revokeConsentsStatement(installId: string, tenantId: string): { sql: string; params: unknown[] } {
791
+ return {
792
+ sql: "UPDATE app_contact_consents SET revoked_at = ? WHERE install_id = ? AND tenant_id = ? AND revoked_at IS NULL",
793
+ params: [this.now(), installId, tenantId],
794
+ };
795
+ }
796
+
788
797
  private detach(tenantId: string, installId: string, actor: string): { sql: string; params: unknown[] }[] {
789
798
  return detachInstallStatements({ tenantId, installId, actor, now: this.now() }).map((statement) => ({
790
799
  sql: statement.sql,
@@ -0,0 +1,217 @@
1
+ /**
2
+ * A Private App's read of one order's ship-to recipient (`getFulfillmentRecipient`). The caller has
3
+ * already verified the token and scope, refused query overrides, and resolved one stage that equals
4
+ * both the routed stage and the order's stage.
5
+ *
6
+ * Order of work:
7
+ * 1. The install must be active on the tenant (else the generic 401 `invalid_token`) and still hold
8
+ * the recipient scope (else 403 `scope_denied`). Has any assignment of this order named the
9
+ * install? If not, `not-assigned`, and no contact table is touched.
10
+ * 2. The order's current assignment must be this install's `active` assignment and the order
11
+ * releasable at its current event version, else `assignment-held` / `assignment-ended`.
12
+ * 3. The app-access audit insert (`appAuditStatement`) re-checks install, scope, assignment, order
13
+ * version, consent and quota in one statement and is the only write. Nothing is decrypted, and
14
+ * the ship group's state is not read, unless it committed its row.
15
+ * 4. Zero rows are classified from current state: install, scope, assignment, consent, then quota.
16
+ *
17
+ * Every failure is a code. A storage read that throws, before or after admission, answers 503
18
+ * `unavailable`; the audit insert's own error 503 `audit-unavailable`; the data key 503
19
+ * `vault-key-unavailable`. Responses carry codes and opaque ids only; the recipient is returned only
20
+ * on success.
21
+ */
22
+ import type { Queryable } from "@/lib/catalog/d1/catalog.js";
23
+ import type { KmsClient } from "@/lib/customers/contactVault/kms.js";
24
+ import {
25
+ APP_CONTACT_PURPOSE,
26
+ APP_CONTACT_READS_PER_HOUR,
27
+ appAuditStatement,
28
+ HOUR_MS,
29
+ type VaultStage,
30
+ } from "@/lib/customers/contactVault/contactAudit.js";
31
+ import { isOpaqueId, type FulfillmentService } from "@/lib/fulfillment/fulfillmentService";
32
+ import { readOrderShipRecipient } from "./orderContactVault.js";
33
+ import type { ContactAddress } from "./orderContact.js";
34
+
35
+ export const RECIPIENT_SCOPE = "orders:read:recipient";
36
+
37
+ export interface AppRecipient {
38
+ orderId: string;
39
+ name?: string;
40
+ company?: string;
41
+ line1: string;
42
+ line2?: string;
43
+ city: string;
44
+ region?: string;
45
+ postalCode?: string;
46
+ country: string;
47
+ }
48
+
49
+ export type AppRecipientCode =
50
+ | "invalid_token"
51
+ | "scope_denied"
52
+ | "not-assigned"
53
+ | "assignment-held"
54
+ | "assignment-ended"
55
+ | "consent-missing"
56
+ | "consent-revoked"
57
+ | "consent-expired"
58
+ | "quota"
59
+ | "contact-erased"
60
+ | "contact-expired"
61
+ | "contact-absent"
62
+ | "contact-unreadable"
63
+ | "audit-unavailable"
64
+ | "vault-key-unavailable"
65
+ | "unavailable";
66
+
67
+ export type AppRecipientOutcome =
68
+ | { ok: true; recipient: AppRecipient }
69
+ | { ok: false; status: number; code: AppRecipientCode };
70
+
71
+ export interface AppRecipientInput {
72
+ tenantId: string;
73
+ stage: VaultStage;
74
+ installId: string;
75
+ orderId: string;
76
+ now: Date;
77
+ }
78
+
79
+ export interface AppRecipientDeps {
80
+ /** The apps database: audit insert, vault and authority reads. */
81
+ db: Queryable;
82
+ /** Reads the order's fulfillment view (assignment, holds, version); never decrypts. */
83
+ service: Pick<FulfillmentService, "get">;
84
+ kms: KmsClient;
85
+ auditId?: () => string;
86
+ }
87
+
88
+ type Eligibility =
89
+ | { kind: "eligible"; assignmentId: string; eventVersion: number }
90
+ | { kind: "refused"; status: number; code: AppRecipientCode };
91
+
92
+ const refused = (status: number, code: AppRecipientCode) => ({ kind: "refused", status, code }) as const;
93
+
94
+ async function installRefusal(db: Queryable, input: AppRecipientInput): Promise<Eligibility | null> {
95
+ const install = await db.first<{ tenant_id: string; status: string; scopes: string }>(
96
+ "SELECT tenant_id, status, scopes FROM app_installs WHERE install_id = ?",
97
+ input.installId,
98
+ );
99
+ if (!install || install.tenant_id !== input.tenantId || install.status !== "active") return refused(401, "invalid_token");
100
+ let scopes: unknown;
101
+ try {
102
+ scopes = JSON.parse(install.scopes);
103
+ } catch {
104
+ scopes = [];
105
+ }
106
+ if (!Array.isArray(scopes) || !scopes.includes(RECIPIENT_SCOPE)) return refused(403, "scope_denied");
107
+ return null;
108
+ }
109
+
110
+ async function eligibility(deps: AppRecipientDeps, input: AppRecipientInput): Promise<Eligibility> {
111
+ const { tenantId, stage, installId, orderId } = input;
112
+ const installRefused = await installRefusal(deps.db, input);
113
+ if (installRefused) return installRefused;
114
+ const named = await deps.db.first(
115
+ `SELECT 1 AS named FROM fulfillment_assignments
116
+ WHERE tenant_id = ? AND stage = ? AND order_id = ? AND install_id = ? LIMIT 1`,
117
+ tenantId, stage, orderId, installId,
118
+ );
119
+ if (!named) return refused(404, "not-assigned");
120
+ const view = await deps.service.get({ role: "caller", actorId: installId, tenantId, stage }, orderId);
121
+ const assignment = view.ok ? view.value.assignment : null;
122
+ if (!view.ok || !assignment || assignment.installId !== installId) return refused(409, "assignment-ended");
123
+ if (assignment.state !== "active") return refused(409, "assignment-held");
124
+ const releasable = view.value.fulfillment === "ready" && view.value.holds.length === 0 && view.value.lines.length > 0;
125
+ if (!releasable) return refused(409, "assignment-held");
126
+ return { kind: "eligible", assignmentId: assignment.assignmentId, eventVersion: view.value.eventVersion };
127
+ }
128
+
129
+ /** Why the audit insert admitted nothing, read from current state; null when nothing explains it. */
130
+ async function classifyRefusal(
131
+ deps: AppRecipientDeps,
132
+ input: AppRecipientInput,
133
+ admitted: { eventVersion: number },
134
+ ): Promise<{ status: number; code: AppRecipientCode } | null> {
135
+ const { db } = deps;
136
+ const { tenantId, stage, installId, now } = input;
137
+ const current = await eligibility(deps, input);
138
+ if (current.kind === "refused") return { status: current.status, code: current.code };
139
+ if (current.eventVersion !== admitted.eventVersion) return null;
140
+ const at = now.toISOString();
141
+ const live = await db.first(
142
+ `SELECT 1 AS live FROM app_contact_consents
143
+ WHERE install_id = ? AND tenant_id = ? AND stage = ? AND purpose = ? AND revoked_at IS NULL AND expires_at > ?`,
144
+ installId, tenantId, stage, APP_CONTACT_PURPOSE, at,
145
+ );
146
+ if (!live) {
147
+ const latest = await db.first<{ revoked_at: string | null }>(
148
+ `SELECT revoked_at FROM app_contact_consents
149
+ WHERE install_id = ? AND tenant_id = ? AND stage = ? AND purpose = ?
150
+ ORDER BY granted_at DESC, rowid DESC LIMIT 1`,
151
+ installId, tenantId, stage, APP_CONTACT_PURPOSE,
152
+ );
153
+ if (!latest) return { status: 403, code: "consent-missing" };
154
+ return { status: 403, code: latest.revoked_at !== null ? "consent-revoked" : "consent-expired" };
155
+ }
156
+ const hourAgo = new Date(now.getTime() - HOUR_MS).toISOString();
157
+ const used = await db.first<{ n: number }>(
158
+ "SELECT COUNT(*) AS n FROM app_contact_access_audit WHERE install_id = ? AND tenant_id = ? AND at > ?",
159
+ installId, tenantId, hourAgo,
160
+ );
161
+ if (Number(used?.n ?? 0) >= APP_CONTACT_READS_PER_HOUR) return { status: 429, code: "quota" };
162
+ return null;
163
+ }
164
+
165
+ function exported(orderId: string, shipTo: ContactAddress): AppRecipient {
166
+ const recipient: AppRecipient = { orderId, line1: shipTo.line1, city: shipTo.city, country: shipTo.country };
167
+ for (const field of ["name", "company", "line2", "region", "postalCode"] as const) {
168
+ const value = shipTo[field];
169
+ if (typeof value === "string" && value.length > 0) recipient[field] = value;
170
+ }
171
+ return recipient;
172
+ }
173
+
174
+ const ATTEMPTS = 2;
175
+ const UNAVAILABLE = { ok: false, status: 503, code: "unavailable" } as const;
176
+
177
+ export async function readAppRecipient(deps: AppRecipientDeps, input: AppRecipientInput): Promise<AppRecipientOutcome> {
178
+ if (!isOpaqueId(input.orderId)) return { ok: false, status: 404, code: "not-assigned" };
179
+ for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
180
+ let eligible: Eligibility;
181
+ try {
182
+ eligible = await eligibility(deps, input);
183
+ } catch {
184
+ return UNAVAILABLE;
185
+ }
186
+ if (eligible.kind === "refused") return { ok: false, status: eligible.status, code: eligible.code };
187
+ const admission = appAuditStatement({
188
+ auditId: deps.auditId?.() ?? crypto.randomUUID(),
189
+ installId: input.installId,
190
+ tenantId: input.tenantId,
191
+ stage: input.stage,
192
+ orderId: input.orderId,
193
+ assignmentId: eligible.assignmentId,
194
+ eventVersion: eligible.eventVersion,
195
+ scope: RECIPIENT_SCOPE,
196
+ now: input.now,
197
+ });
198
+ let read: Awaited<ReturnType<typeof readOrderShipRecipient>>;
199
+ try {
200
+ read = await readOrderShipRecipient(deps.db, deps.kms, {
201
+ tenantId: input.tenantId, stage: input.stage, orderId: input.orderId, admission, now: input.now,
202
+ });
203
+ } catch {
204
+ return UNAVAILABLE;
205
+ }
206
+ if (read.ok) return { ok: true, recipient: exported(input.orderId, read.shipTo) };
207
+ if (read.reason !== "not-admitted") return { ok: false, status: read.status, code: read.reason };
208
+ let why: { status: number; code: AppRecipientCode } | null;
209
+ try {
210
+ why = await classifyRefusal(deps, input, eligible);
211
+ } catch {
212
+ return UNAVAILABLE;
213
+ }
214
+ if (why) return { ok: false, ...why };
215
+ }
216
+ return { ok: false, status: 409, code: "assignment-held" };
217
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The admission every order-contact admin route runs before it touches the vault: the owner gate,
3
- * then the owner-or-staff split (contactReaderAuthority), then — for staff — a reason, a ticket and a
3
+ * then the owner-or-staff split (contactReaderAuthority; an owner route requires kind owner and a
4
+ * staff route kind staff, so any other kind is refused), then — for staff — a reason, a ticket and a
4
5
  * LIVE owner-consented customer-contact-read grant. The tenant comes from the routed host, never
5
6
  * from the request. An order contact's stage is the one its order is recorded under
6
7
  * (`resolveOrderStage`, shared with the Orders tab, with the shared-store admission its route
@@ -73,10 +74,10 @@ export async function openContactRoute(
73
74
 
74
75
  let actor: ContactActor;
75
76
  if (expect === "owner") {
76
- if (authority.kind === "staff") return refuse("support-read-required", 403);
77
+ if (authority.kind !== "owner") return refuse("support-read-required", 403);
77
78
  actor = { actorId: authority.actorId, role: "owner" };
78
79
  } else {
79
- if (authority.kind === "owner") return refuse("use-owner-route", 403);
80
+ if (authority.kind !== "staff") return refuse("use-owner-route", 403);
80
81
  const reason = readSupportReason(body);
81
82
  if (!reason) return refuse("malformed", 400);
82
83
  if (!(await resolveContactReadGrant(authority.email, host))) return refuse("grant-required", 403);
@@ -9,6 +9,8 @@
9
9
  * - seal: [new data-key rows] + conditional vault insert + marker + replay (both conditioned on the
10
10
  * vault row this command sealed) + key cleanup. Every refused or racing outcome commits nothing.
11
11
  * - read / list: the audit row commits (staff quota in the same statement) before any decrypt.
12
+ * - app recipient read: the app-access audit row commits before contact state is read or anything
13
+ * is decrypted, and only the ship group is opened.
12
14
  * - erase: audit, group clearing and detach commit together.
13
15
  * - purge: one clock reading; each page is one batch.
14
16
  *
@@ -46,6 +48,7 @@ import {
46
48
  auditRecordIds,
47
49
  auditStatement,
48
50
  batchAuditStatement,
51
+ type AuditStatement,
49
52
  type ContactActor,
50
53
  type VaultStage,
51
54
  } from "@/lib/customers/contactVault/contactAudit.js";
@@ -768,6 +771,60 @@ export async function readOrderContact(
768
771
  }
769
772
  }
770
773
 
774
+ export type ShipRecipientOutcome =
775
+ | { ok: true; shipTo: ContactAddress }
776
+ | { ok: false; status: 0; reason: "not-admitted" }
777
+ | {
778
+ ok: false;
779
+ status: 404 | 409 | 410 | 503;
780
+ reason:
781
+ | "contact-erased" | "contact-expired" | "contact-absent" | "contact-unreadable"
782
+ | "audit-unavailable" | "vault-key-unavailable" | "unavailable";
783
+ };
784
+
785
+ const SHIP_SELECT = `SELECT fact_id, order_kid, ship_ceiling, ship_city, ship_postal_code, ship_region, ship_country,
786
+ ${LAYOUT.ship.sealed.join(", ")}
787
+ FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?`;
788
+
789
+ /**
790
+ * An app's recipient read of one order: the ship group only. `admission` is the app-access audit
791
+ * insert; it runs first and nothing here reads contact state, loads a key or decrypts unless it
792
+ * returned its row. Zero rows answer `not-admitted` (the caller classifies from authority state);
793
+ * an error answers 503 audit-unavailable. After admission the ship group's state classifies the
794
+ * order (erased, expired, absent, unreadable) and only a present group is opened. Every failure
795
+ * after admission is a code: the data key 503 vault-key-unavailable, any other storage error 503
796
+ * unavailable. No thrown message leaves this function.
797
+ */
798
+ export async function readOrderShipRecipient(
799
+ db: Queryable,
800
+ kms: KmsClient,
801
+ input: { tenantId: string; stage: VaultStage; orderId: string; admission: AuditStatement; now: Date; policy?: RetentionPolicy },
802
+ ): Promise<ShipRecipientOutcome> {
803
+ const { tenantId, stage, orderId, now } = input;
804
+ const admitted = await admitAudit(db, input.admission);
805
+ if (admitted === "quota") return { ok: false, status: 0, reason: "not-admitted" };
806
+ if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
807
+ const unreadable = { ok: false, status: 409, reason: "contact-unreadable" } as const;
808
+ try {
809
+ const availability = await readOrderContactAvailability(db, { tenantId, stage, orderIds: [orderId], now, policy: input.policy });
810
+ const state = availability.get(orderId)?.shipTo ?? "unreadable";
811
+ if (state === "erased") return { ok: false, status: 410, reason: "contact-erased" };
812
+ if (state === "expired") return { ok: false, status: 410, reason: "contact-expired" };
813
+ if (state === "absent") return { ok: false, status: 404, reason: "contact-absent" };
814
+ if (state !== "present") return unreadable;
815
+ const row = await db.first<VaultRow>(SHIP_SELECT, tenantId, stage, orderId);
816
+ const kid = row ? text(row, "order_kid") : null;
817
+ if (!row || !kid) return unreadable;
818
+ const key = await keyForKid(db, kms, keyContext(tenantId, stage, ORDER_KEY), kid);
819
+ if (!key) return unreadable;
820
+ const opened = await openGroups(key, row, tenantId, stage, ["ship"]);
821
+ return opened?.shipTo ? { ok: true, shipTo: opened.shipTo } : unreadable;
822
+ } catch (error) {
823
+ if (error instanceof VaultKeyUnavailableError) return { ok: false, status: 503, reason: "vault-key-unavailable" };
824
+ return { ok: false, status: 503, reason: "unavailable" };
825
+ }
826
+ }
827
+
771
828
  export interface ListedContact {
772
829
  orderId: string;
773
830
  contact: ReadableContact;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "6.5.1",
3
+ "version": "6.5.2",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",