@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.
- package/apps/storefront/migrations-apps/0027_app_contact_authority.sql +70 -0
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +56 -1
- package/apps/storefront/src/lib/integrations/apps/appContactAuthority.ts +27 -0
- package/apps/storefront/src/lib/integrations/apps/appContactConsents.ts +112 -0
- package/apps/storefront/src/lib/integrations/apps/registryService.ts +9 -0
- package/apps/storefront/src/lib/orders/vault/appRecipientRead.ts +217 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -3
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +57 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|