@tokenoftrust/storefront-runner 4.11.11 → 4.11.13

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,15 @@
1
+ -- Grantee emails for principal grants made through storefront, shown only on the store owner's own
2
+ -- Team page. tot20's grant list names a grantee by an opaque principal id, so storefront records the
3
+ -- email beside the opaque grant ref when tot20 confirms a grant. Display only: never consulted to
4
+ -- decide access. Retention-bounded: expires_at is the grant's confirmed expiry; expired rows are
5
+ -- purged on writes, owner reads and the hourly sweep, and a row is deleted when its grant is revoked
6
+ -- through storefront or no longer appears in the store's active grant list.
7
+ CREATE TABLE principal_grant_contacts (
8
+ tenant_id TEXT NOT NULL,
9
+ grant_ref TEXT NOT NULL,
10
+ grantee_email TEXT NOT NULL,
11
+ recorded_at TEXT NOT NULL,
12
+ expires_at TEXT NOT NULL,
13
+ PRIMARY KEY (tenant_id, grant_ref)
14
+ );
15
+ CREATE INDEX idx_principal_grant_contacts_expires ON principal_grant_contacts(expires_at);
@@ -58,7 +58,9 @@
58
58
  for (const g of grants) {
59
59
  const tr = document.createElement("tr");
60
60
  const dev = document.createElement("td");
61
- dev.textContent = (g.principal && g.principal.email) || "";
61
+ // tot20 names the grantee by an opaque id; storefront adds the email for grants made here.
62
+ dev.textContent = (g.principal && g.principal.email) || "Granted outside this page (email not recorded)";
63
+ if (!(g.principal && g.principal.email)) dev.className = "muted";
62
64
  const cap = document.createElement("td");
63
65
  cap.textContent = LABELS[g.capability] || g.capability || "";
64
66
  const exp = document.createElement("td");
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Grantee emails for principal grants made through storefront, for the store owner's own Team page.
3
+ *
4
+ * tot20's principal-grant list names each grantee by an opaque principal id, never the email, so the
5
+ * owner could not tell who holds a grant. When tot20 CONFIRMS a grant, storefront records the email
6
+ * it was issued to beside the confirmed opaque ref (`principal_grant_contacts`, migration 0023).
7
+ *
8
+ * Display only: nothing here decides access. tot20's writer and the per-person grant reads stay the
9
+ * only authority. The stored value is the grantee's own address (a ToT staff member or a developer the
10
+ * owner chose), not a customer contact, so it is kept in plaintext and shown only to that store's
11
+ * owner. It is never logged and never returned to anyone else.
12
+ *
13
+ * Retention: `expires_at` is the grant's confirmed expiry. Expired rows are purged (bounded per call)
14
+ * on every write and every owner read, and by the hourly sweep (`purgeExpiredGranteeContacts`). After
15
+ * a SUCCESSFUL tot20 list the owner read also deletes that store's rows whose ref is no longer listed
16
+ * (revoked outside storefront, or replaced). Every statement is keyed by tenant except the global
17
+ * expiry purge, which deletes only rows already past their expiry.
18
+ */
19
+
20
+ /** The query seam this module needs; D1 (via `fromD1`) and the node:sqlite test shim satisfy it. */
21
+ export interface LookupDb {
22
+ all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]>;
23
+ run(sql: string, ...params: unknown[]): Promise<void>;
24
+ }
25
+
26
+ /** Rows deleted per purge call, so one request never does unbounded work. */
27
+ export const PURGE_BATCH = 200;
28
+
29
+ async function purgeExpired(db: LookupDb, now: Date, limit = PURGE_BATCH): Promise<void> {
30
+ await db.run(
31
+ `DELETE FROM principal_grant_contacts WHERE rowid IN (
32
+ SELECT rowid FROM principal_grant_contacts WHERE expires_at <= ? ORDER BY expires_at LIMIT ?)`,
33
+ now.toISOString(),
34
+ limit,
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Record the email a confirmed grant was issued to. `expiresAt` is tot20's confirmed expiry
40
+ * (epoch-ms). Overwrites only this tenant's row for the same ref.
41
+ */
42
+ export async function recordGranteeContact(
43
+ db: LookupDb,
44
+ input: { tenantId: string; grantRef: string; email: string; expiresAt: number; now: Date },
45
+ ): Promise<void> {
46
+ await purgeExpired(db, input.now);
47
+ if (!input.grantRef || !input.email || !Number.isFinite(input.expiresAt)) return;
48
+ await db.run(
49
+ `INSERT INTO principal_grant_contacts (tenant_id, grant_ref, grantee_email, recorded_at, expires_at)
50
+ VALUES (?, ?, ?, ?, ?)
51
+ ON CONFLICT (tenant_id, grant_ref) DO UPDATE SET
52
+ grantee_email = excluded.grantee_email, recorded_at = excluded.recorded_at, expires_at = excluded.expires_at`,
53
+ input.tenantId,
54
+ input.grantRef,
55
+ input.email,
56
+ input.now.toISOString(),
57
+ new Date(input.expiresAt).toISOString(),
58
+ );
59
+ }
60
+
61
+ /** Forget the email for a grant revoked through storefront. */
62
+ export async function forgetGranteeContact(db: LookupDb, tenantId: string, grantRef: string): Promise<void> {
63
+ await db.run(`DELETE FROM principal_grant_contacts WHERE tenant_id = ? AND grant_ref = ?`, tenantId, grantRef);
64
+ }
65
+
66
+ /**
67
+ * The emails for this store's grants, by ref, for the owner's list. Purges expired rows first.
68
+ * `activeRefs` is the ref set from a SUCCESSFUL tot20 list: rows for refs not in it are deleted. Pass
69
+ * null when the list failed, so nothing is reconciled.
70
+ */
71
+ export async function granteeContactsFor(
72
+ db: LookupDb,
73
+ tenantId: string,
74
+ activeRefs: ReadonlySet<string> | null,
75
+ now: Date,
76
+ ): Promise<Map<string, string>> {
77
+ await purgeExpired(db, now);
78
+ const rows = await db.all<{ grant_ref: string; grantee_email: string }>(
79
+ `SELECT grant_ref, grantee_email FROM principal_grant_contacts WHERE tenant_id = ? AND expires_at > ?`,
80
+ tenantId,
81
+ now.toISOString(),
82
+ );
83
+ const out = new Map<string, string>();
84
+ for (const row of rows) {
85
+ if (activeRefs && !activeRefs.has(row.grant_ref)) {
86
+ await forgetGranteeContact(db, tenantId, row.grant_ref);
87
+ continue;
88
+ }
89
+ out.set(row.grant_ref, row.grantee_email);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /** The hourly sweep's purge: rows already past their grant's expiry, bounded per call. */
95
+ export async function purgeExpiredGranteeContacts(db: LookupDb, now: Date, limit = PURGE_BATCH): Promise<void> {
96
+ await purgeExpired(db, now, limit);
97
+ }
@@ -82,6 +82,25 @@
82
82
  */
83
83
  import { readEnv } from "@/lib/publication/shared/env";
84
84
  import { BROKER_ASSERTION_HEADER, BROKER_PRIVATE_KEY_ENV } from "@/lib/access/auth/brokerAssertion";
85
+ import { resolveContactReadGrant } from "@/lib/access/auth/contactReaderAuthority";
86
+ import { resolveViewerShipCapability } from "@/lib/access/shared/viewerGrant";
87
+
88
+ /** The live principal grant a person holds on a store, as far as the grant reads can tell. */
89
+ export type HeldPrincipalGrant = "customer-contact-read" | "ship-on-behalf" | null;
90
+
91
+ /**
92
+ * The live principal grant `email` already holds on `tenant`, read through the per-person
93
+ * subject-capability seam (keyed by email). tot20 keeps ONE principal grant per person per store
94
+ * (`addPrincipalGrant` writes `principalGrants[principalKey]`, capability included), so a new grant
95
+ * to someone who holds one REPLACES it; callers refuse instead. tot20's grant LIST cannot answer
96
+ * this: its rows name the principal by an opaque id, never the email. Both reads fail closed to
97
+ * "none" on a seam fault, so a fault lets the grant through rather than blocking it.
98
+ */
99
+ export async function heldPrincipalGrant(email: string, tenant: string): Promise<HeldPrincipalGrant> {
100
+ if (await resolveContactReadGrant(email, tenant)) return "customer-contact-read";
101
+ if ((await resolveViewerShipCapability(email, tenant)) === "ship-on-behalf") return "ship-on-behalf";
102
+ return null;
103
+ }
85
104
 
86
105
  /** A human grantee — the only principal type this HTTP surface accepts. */
87
106
  export interface UserPrincipal {
@@ -2,7 +2,8 @@
2
2
  * The hourly contact vault sweep — the Worker's `scheduled` handler body. One `now` per firing,
3
3
  * taken from its scheduled time: clear expired order-contact field groups and contact messages,
4
4
  * shorten terminal orders' retention, seal any contact message still held as legacy plaintext,
5
- * rotate data keys older than a year, and recompute blind indexes still under a retired kid. Expiry predicates only; it never touches an order or audit row.
5
+ * rotate data keys older than a year, recompute blind indexes still under a retired kid, and purge
6
+ * grantee emails whose grant has expired. Expiry predicates only; it never touches an order or audit row.
6
7
  *
7
8
  * Purge never needs a key, so it always runs; sealing and rotation need KMS and are skipped (with
8
9
  * `kmsAvailable: false`) when it refuses. It logs ONE structured line of counts on success and ONE
@@ -20,6 +21,7 @@ import {
20
21
  } from "@/lib/engagement/contact/contactMessages.js";
21
22
  import { loadVaultKms } from "@/lib/orders/vault/kmsEnv.js";
22
23
  import { purgeOrderContacts, rotateContactVaultKeys, type PurgeCounts } from "@/lib/orders/vault/orderContactVault.js";
24
+ import { purgeExpiredGranteeContacts } from "@/lib/access/shared/grantContactLookup.js";
23
25
  import { readD1 } from "@/lib/publication/shared/env";
24
26
 
25
27
  export const SWEEP_LEGACY_SEAL_LIMIT = 500;
@@ -51,6 +53,8 @@ export async function runContactVaultSweep(db: Queryable, kms: KmsClient, now: D
51
53
  if (Number.isNaN(now.getTime())) throw new Error("contact vault sweep: unreadable clock");
52
54
  const orders = await purgeOrderContacts(db, now);
53
55
  const messages = await purgeContactMessages(db, now);
56
+ // Grantee emails recorded for the owner's Team page, once their grant has expired.
57
+ await purgeExpiredGranteeContacts(db, now);
54
58
  const legacy = await withKms(() => sealLegacyContactMessages(db, kms, now, SWEEP_LEGACY_SEAL_LIMIT));
55
59
  const rotation = await withKms(() => rotateContactVaultKeys(db, kms, now, { recompute: [recomputeMessageBlindIndexes] }));
56
60
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.11.11",
3
+ "version": "4.11.13",
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",