@tokenoftrust/storefront-runner 4.13.0 → 4.13.1
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/src/config/kvTenantResolver.ts +2 -1
- package/apps/storefront/src/config/storedTenantRecord.ts +27 -0
- package/apps/storefront/src/lib/assets/addons/addonsClient.ts +59 -0
- package/apps/storefront/src/lib/assets/addons/catalog.ts +47 -0
- package/apps/storefront/src/lib/assets/addons/tenantAddOns.ts +89 -0
- package/package.json +1 -1
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { TenantConfig, TenantResolver } from "@tot/public-runtime";
|
|
19
19
|
import { withPlatformPolicy } from "@/config/tenants";
|
|
20
|
+
import { TENANT_RECORD_PREFIX } from "@/config/storedTenantRecord";
|
|
20
21
|
|
|
21
22
|
const DOMAIN_PREFIX = "domain:";
|
|
22
|
-
const TENANT_PREFIX =
|
|
23
|
+
const TENANT_PREFIX = TENANT_RECORD_PREFIX;
|
|
23
24
|
const DEFAULT_KEY = "config:default_tenant";
|
|
24
25
|
// Edge-cache KV reads briefly; tenant config changes are rare and propagate
|
|
25
26
|
// within this window. Bump down if you need faster propagation after a write.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `tenant:<id>` KV record exactly as stored, for an in-place edit of one field (the admin
|
|
3
|
+
* add-ons switch). Separate from kvTenantResolver so lib/pages can use it without importing the
|
|
4
|
+
* concrete resolver (depcruise `tenancy-resolver-fence`); the resolver takes its key prefix from here.
|
|
5
|
+
*/
|
|
6
|
+
export const TENANT_RECORD_PREFIX = "tenant:";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read with no edge cache (`cacheTtl`) and no platform-policy overlay. A read-modify-write must
|
|
10
|
+
* start here, never from `resolveStrict`, which can return a copy up to its cache TTL old and
|
|
11
|
+
* carries platform fields the record itself does not hold.
|
|
12
|
+
*/
|
|
13
|
+
export async function getStoredTenantRecord(kv: KVNamespace, tenantId: string): Promise<string | null> {
|
|
14
|
+
return kv.get(TENANT_RECORD_PREFIX + tenantId);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Write ONLY the `tenant:<id>` record key. Unlike putTenant it never touches the domain index or
|
|
19
|
+
* the default-tenant key, so an in-place edit of one field cannot re-point a hostname.
|
|
20
|
+
*/
|
|
21
|
+
export async function putStoredTenantRecord(
|
|
22
|
+
kv: KVNamespace,
|
|
23
|
+
tenantId: string,
|
|
24
|
+
record: Record<string, unknown>,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
await kv.put(TENANT_RECORD_PREFIX + tenantId, JSON.stringify(record));
|
|
27
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client island for `/admin/addons` (ADR 0013): each add-on card's button POSTs the mode it names to
|
|
3
|
+
* `/api/admin/addons` and redraws the card from the confirmed state the API returns. A no-op when
|
|
4
|
+
* the page rendered no cards (unauthorized viewer).
|
|
5
|
+
*/
|
|
6
|
+
export function initAddOnsPage(apiPath: string): void {
|
|
7
|
+
for (const button of document.querySelectorAll<HTMLButtonElement>("[data-addon-toggle]")) {
|
|
8
|
+
button.addEventListener("click", () => void toggle(apiPath, button));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function toggle(apiPath: string, button: HTMLButtonElement): Promise<void> {
|
|
13
|
+
const card = button.closest<HTMLElement>("[data-addon-id]");
|
|
14
|
+
const id = card?.dataset.addonId;
|
|
15
|
+
const mode = button.dataset.addonNextMode;
|
|
16
|
+
const status = card?.querySelector<HTMLElement>("[data-addon-status]");
|
|
17
|
+
if (!card || !id || !mode) return;
|
|
18
|
+
|
|
19
|
+
button.disabled = true;
|
|
20
|
+
if (status) {
|
|
21
|
+
status.textContent = "Saving…";
|
|
22
|
+
status.dataset.tone = "";
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(apiPath, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "content-type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ id, mode }),
|
|
29
|
+
});
|
|
30
|
+
let body: { ok?: boolean; state?: { mode?: string }; status?: string };
|
|
31
|
+
try {
|
|
32
|
+
body = (await res.json()) as typeof body;
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(`HTTP ${res.status}`);
|
|
35
|
+
}
|
|
36
|
+
if (!res.ok || !body.ok || !body.state?.mode) throw new Error(body.status || `HTTP ${res.status}`);
|
|
37
|
+
render(card, button, body.state.mode);
|
|
38
|
+
if (status) {
|
|
39
|
+
status.textContent = body.state.mode === "enabled" ? "Enabled." : "Disabled.";
|
|
40
|
+
status.dataset.tone = "ok";
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (status) {
|
|
44
|
+
status.textContent = `Could not save (${error instanceof Error ? error.message : "error"}).`;
|
|
45
|
+
status.dataset.tone = "error";
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
button.disabled = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function render(card: HTMLElement, button: HTMLButtonElement, mode: string): void {
|
|
53
|
+
const enabled = mode === "enabled";
|
|
54
|
+
card.dataset.addonMode = mode;
|
|
55
|
+
const chip = card.querySelector<HTMLElement>("[data-addon-chip]");
|
|
56
|
+
if (chip) chip.textContent = enabled ? "Enabled" : "Off";
|
|
57
|
+
button.dataset.addonNextMode = enabled ? "disabled" : "enabled";
|
|
58
|
+
button.textContent = enabled ? "Turn off" : "Turn on";
|
|
59
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The add-ons a store owner can turn on or off from `/admin/addons`. Static on purpose: an add-on
|
|
3
|
+
* id or mode a request names is valid only if it appears here, so the API refuses anything else.
|
|
4
|
+
*
|
|
5
|
+
* State lives only in the tenant record (`addOns.<id>.mode`); nothing here reads the environment.
|
|
6
|
+
* High Fidelity / Large Images is read back by `resolveImageTier`, which treats every value but
|
|
7
|
+
* `"enabled"` as the standard tier.
|
|
8
|
+
*/
|
|
9
|
+
import { IMAGE_TIERS, LARGE_IMAGES_ADDON_NAME } from "@tot/public-runtime/image-limits";
|
|
10
|
+
|
|
11
|
+
export const ADDON_MODES = ["enabled", "disabled"] as const;
|
|
12
|
+
export type AddOnMode = (typeof ADDON_MODES)[number];
|
|
13
|
+
|
|
14
|
+
export const ADDON_IDS = ["largeImages"] as const;
|
|
15
|
+
export type AddOnId = (typeof ADDON_IDS)[number];
|
|
16
|
+
|
|
17
|
+
export interface AddOnDefinition {
|
|
18
|
+
id: AddOnId;
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
modes: readonly AddOnMode[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MiB = 1024 * 1024;
|
|
25
|
+
const mb = (bytes: number) => `${Math.round((bytes / MiB) * 10) / 10} MB`;
|
|
26
|
+
const mp = (pixels: number) => `${Math.round((pixels / 1_000_000) * 10) / 10} MP`;
|
|
27
|
+
const limits = (tier: { maxEncodedBytes: number; maxPixels: number; maxDimension: number }) =>
|
|
28
|
+
`${mb(tier.maxEncodedBytes)}, ${mp(tier.maxPixels)}, ${tier.maxDimension}px on a side`;
|
|
29
|
+
|
|
30
|
+
export const ADDONS: readonly AddOnDefinition[] = Object.freeze([
|
|
31
|
+
{
|
|
32
|
+
id: "largeImages",
|
|
33
|
+
name: LARGE_IMAGES_ADDON_NAME,
|
|
34
|
+
description:
|
|
35
|
+
`Raises this store's image limit from the standard ${limits(IMAGE_TIERS.standard)} ` +
|
|
36
|
+
`to ${limits(IMAGE_TIERS.large)}. Takes effect on the next capture or upload.`,
|
|
37
|
+
modes: ADDON_MODES,
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function isAddOnId(value: unknown): value is AddOnId {
|
|
42
|
+
return typeof value === "string" && (ADDON_IDS as readonly string[]).includes(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isAddOnMode(value: unknown): value is AddOnMode {
|
|
46
|
+
return typeof value === "string" && (ADDON_MODES as readonly string[]).includes(value);
|
|
47
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read and write a tenant's add-on state (`addOns.<id>` on its KV record).
|
|
3
|
+
*
|
|
4
|
+
* The write is a read-modify-write of the RAW stored record: read `tenant:<id>` with no edge cache
|
|
5
|
+
* and no platform-policy overlay, change only `addOns.<id>`, and write back only that key. A cached
|
|
6
|
+
* read (`resolveStrict` caches 300 s) would clobber a field written since, and an overlaid read would
|
|
7
|
+
* persist platform policy into the record. A missing record is refused, never created: a tenant
|
|
8
|
+
* served from the static fallback has no record to edit.
|
|
9
|
+
*
|
|
10
|
+
* KV read-modify-write is not atomic; two simultaneous toggles can lose one. Acceptable for an
|
|
11
|
+
* owner switch, and no other field is at risk because both writers spread the same fresh read.
|
|
12
|
+
*/
|
|
13
|
+
import { getStoredTenantRecord, putStoredTenantRecord } from "@/config/storedTenantRecord";
|
|
14
|
+
import { ADDONS, isAddOnId, isAddOnMode, type AddOnId, type AddOnMode } from "./catalog";
|
|
15
|
+
|
|
16
|
+
export interface AddOnState {
|
|
17
|
+
mode: AddOnMode;
|
|
18
|
+
updatedAt?: string;
|
|
19
|
+
updatedBy?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type AddOnsState = Record<AddOnId, AddOnState>;
|
|
23
|
+
|
|
24
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every catalog add-on's state on `record`. Anything but `mode: "enabled"` reads as disabled. */
|
|
29
|
+
export function readAddOns(record: unknown): AddOnsState {
|
|
30
|
+
const stored = isRecord(record) && isRecord(record.addOns) ? record.addOns : {};
|
|
31
|
+
const state = {} as AddOnsState;
|
|
32
|
+
for (const addOn of ADDONS) {
|
|
33
|
+
const entry = stored[addOn.id];
|
|
34
|
+
const out: AddOnState = { mode: isRecord(entry) && entry.mode === "enabled" ? "enabled" : "disabled" };
|
|
35
|
+
if (isRecord(entry) && typeof entry.updatedAt === "string") out.updatedAt = entry.updatedAt;
|
|
36
|
+
if (isRecord(entry) && typeof entry.updatedBy === "string") out.updatedBy = entry.updatedBy;
|
|
37
|
+
state[addOn.id] = out;
|
|
38
|
+
}
|
|
39
|
+
return state;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseStored(raw: string | null): Record<string, unknown> | null | "unreadable" {
|
|
43
|
+
if (raw === null) return null;
|
|
44
|
+
try {
|
|
45
|
+
const parsed: unknown = JSON.parse(raw);
|
|
46
|
+
return isRecord(parsed) ? parsed : "unreadable";
|
|
47
|
+
} catch {
|
|
48
|
+
return "unreadable";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The stored record's add-on state, or null when the tenant has no readable KV record. */
|
|
53
|
+
export async function readStoredAddOns(kv: KVNamespace, tenantId: string): Promise<AddOnsState | null> {
|
|
54
|
+
const record = parseStored(await getStoredTenantRecord(kv, tenantId));
|
|
55
|
+
return record && record !== "unreadable" ? readAddOns(record) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type SetAddOnResult =
|
|
59
|
+
| { ok: true; state: AddOnState }
|
|
60
|
+
| { ok: false; status: 400; reason: "unknown_addon" | "unknown_mode" }
|
|
61
|
+
| { ok: false; status: 404; reason: "no_tenant_record" }
|
|
62
|
+
| { ok: false; status: 409; reason: "tenant_record_unreadable" }
|
|
63
|
+
| { ok: false; status: 503; reason: "write_not_confirmed" };
|
|
64
|
+
|
|
65
|
+
export async function setAddOn(
|
|
66
|
+
kv: KVNamespace,
|
|
67
|
+
tenantId: string,
|
|
68
|
+
id: unknown,
|
|
69
|
+
mode: unknown,
|
|
70
|
+
actor: string,
|
|
71
|
+
now: Date,
|
|
72
|
+
): Promise<SetAddOnResult> {
|
|
73
|
+
if (!isAddOnId(id)) return { ok: false, status: 400, reason: "unknown_addon" };
|
|
74
|
+
if (!isAddOnMode(mode)) return { ok: false, status: 400, reason: "unknown_mode" };
|
|
75
|
+
|
|
76
|
+
const record = parseStored(await getStoredTenantRecord(kv, tenantId));
|
|
77
|
+
if (record === null) return { ok: false, status: 404, reason: "no_tenant_record" };
|
|
78
|
+
if (record === "unreadable") return { ok: false, status: 409, reason: "tenant_record_unreadable" };
|
|
79
|
+
|
|
80
|
+
const addOns = isRecord(record.addOns) ? record.addOns : {};
|
|
81
|
+
const entry: AddOnState = { mode, updatedAt: now.toISOString(), updatedBy: actor };
|
|
82
|
+
await putStoredTenantRecord(kv, tenantId, { ...record, addOns: { ...addOns, [id]: entry } });
|
|
83
|
+
|
|
84
|
+
const confirmed = parseStored(await getStoredTenantRecord(kv, tenantId));
|
|
85
|
+
if (!confirmed || confirmed === "unreadable") return { ok: false, status: 503, reason: "write_not_confirmed" };
|
|
86
|
+
const state = readAddOns(confirmed)[id];
|
|
87
|
+
if (state.mode !== mode) return { ok: false, status: 503, reason: "write_not_confirmed" };
|
|
88
|
+
return { ok: true, state };
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.13.
|
|
3
|
+
"version": "4.13.1",
|
|
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",
|