@tokenoftrust/storefront-runner 4.11.12 → 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.
- package/apps/storefront/migrations-apps/0023_principal_grant_contacts.sql +15 -0
- package/apps/storefront/public/js/dashboard-delegate.js +2 -2
- package/apps/storefront/src/lib/access/shared/grantContactLookup.ts +97 -0
- package/apps/storefront/src/scheduled/contactVaultSweep.ts +5 -1
- package/package.json +1 -1
|
@@ -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,8 +58,8 @@
|
|
|
58
58
|
for (const g of grants) {
|
|
59
59
|
const tr = document.createElement("tr");
|
|
60
60
|
const dev = document.createElement("td");
|
|
61
|
-
// tot20 names the grantee by an opaque id
|
|
62
|
-
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
63
|
if (!(g.principal && g.principal.email)) dev.className = "muted";
|
|
64
64
|
const cap = document.createElement("td");
|
|
65
65
|
cap.textContent = LABELS[g.capability] || g.capability || "";
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
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.
|
|
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",
|