Matches the lofi-node #2 contract: StoreTarget.adminSecret becomes optional (a provision-scoped ticket URL needs none — the gate injects the node's own secret and strips inbound headers), and readTicketStoreStatus is the metadata-only preflight any valid ticket may call, mapping the documented gate responses (deployed, no_schema, 502 store_unavailable, 401 ticket_rejected, absent endpoint).
- commit
- 6ac48908535a61ae01fac9321341682a80d9fc65
- parent
- c62c2e99b23fa7c1197a0b843a0ed7a21505c7f2
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 28 | +++++++++-- |
| M | package/ | 2 | + |
| M | package/ | 75 | ++++++++++++++++++++++++++++-- |
| M | package/ | 41 | ++++++++++++++++ |
@@ -7,7 +7,10 @@ of such a store — as an explicit user opt-in, never as part of normal sync.
77 Two principles govern it:88 99 1. **Store changes are opt-in.** Enrolling a sync ticket attaches transport only. Creating or10- updating the store's schema requires the store's **admin secret** — supplying it is the opt-in.10+ updating the store's schema requires store administration: either the store's **admin secret**,11+ or a **provision-scoped app-connect ticket** (`ticket issue --provision` on lofi-node), whose12+ gate injects the node's admin secret itself — the secret never transits the client, and13+ possession of the ticket is the opt-in.1114 2. **An app may only touch its own namespaces.** A nested app (`s.defineNestedApp`) declares1215 namespaces, and every change provisioning generates is confined to tables under them. Sibling1316 tables and their policies carry through a merge byte-for-byte. This keeps apps honest: many apps@@ -17,12 +20,26 @@ Two principles govern it:
1720 1821 ## Classify before you connect1922 23+With store administration (admin secret, or a provision-scoped ticket URL as `serverUrl` with24+`adminSecret` omitted):25+2026 ```ts2127 import { readStoreStatus } from "@nzip/lofi/schema";2228 2329 const status = await readStoreStatus(root, { serverUrl, appId, adminSecret });2430 ```2531 32+Without it — any valid sync ticket may call the node's metadata-only preflight, which is how a33+sync-only client learns `no_schema` before ever attaching sync:34+35+```ts36+import { readTicketStoreStatus } from "@nzip/lofi/schema";37+38+const preflight = await readTicketStoreStatus(ticketUrl);39+// → { state: "deployed", appId, headHash } | { state: "no_schema", appId }40+// | { state: "store_unavailable" } | { state: "ticket_rejected" } | { state: "unsupported" }41+```42+2643 | State | Meaning | Remedy |2744 | -------------------- | ---------------------------------------------------------------- | -------------------- |2845 | `ok` | The enforced schema carries this app's slice exactly. | none |@@ -75,7 +92,8 @@ any request is made.
7592 - `readStoreStatus` compares structurally; it does not verify migration connectivity. A7693 `provisionStore` run (even an `unchanged` one) establishes the connectivity edge for this app's7794 schema, so run it once per app version as part of the opt-in.78-- The admin secret grants full store administration; the namespace confinement is a framework79- honesty boundary, not a defense against a hostile secret holder. Ticket-scoped provisioning (no80- raw secret pasting) is tracked on the node side81- ([lofi-node#2](https://github.com/FelineStateMachine/lofi-node/issues/2)).95+- Store administration (the admin secret, or a provision ticket) grants full control; the namespace96+ confinement is a framework honesty boundary, not a defense against a hostile administrator.97+ Provision-scoped tickets and the store-status preflight are the node-side contract from98+ [lofi-node#2](https://github.com/FelineStateMachine/lofi-node/issues/2), shipped in lofi-node's99+ `docs/app-ticket.md`.@@ -12,8 +12,11 @@
1212 * published: an app may only create tables under its own declared namespaces,1313 * and any generated change naming a table outside them is a hard error.1414 *15- * Every operation needs the store's admin secret — supplying it is the user's16- * opt-in. Nothing in the normal sync path calls this module.15+ * Every operation needs store administration — supplying the admin secret,16+ * or holding a provision-scoped app-connect ticket whose gate injects it, is17+ * the user's opt-in. Nothing in the normal sync path calls this module; the18+ * one exception is {@link readTicketStoreStatus}, the metadata-only preflight19+ * any valid ticket may call.1720 *1821 * @module1922 */@@ -26,8 +29,14 @@ export type StoreTarget = {
2629 serverUrl: string;2730 /** The Jazz app id of the store. */2831 appId: string;29- /** The store's admin secret — administration rides on top of transport access. */30- adminSecret: string;32+ /**33+ * The store's admin secret — administration rides on top of transport34+ * access. Omit it when `serverUrl` is a provision-scoped app-connect35+ * ticket URL: the node's gate injects its own admin secret for such36+ * requests (and strips any inbound one), so the secret never transits the37+ * client.38+ */39+ adminSecret?: string;3140 };3241 3342 /** How a store's deployed schema relates to this app's slice. */@@ -278,7 +287,7 @@ async function adminFetch(
278287 const response = await fetch(`${base}/apps/${target.appId}/${path}`, {279288 method: init?.method ?? "GET",280289 headers: {281- "X-Jazz-Admin-Secret": target.adminSecret,290+ ...(target.adminSecret ? { "X-Jazz-Admin-Secret": target.adminSecret } : {}),282291 ...(init ? { "Content-Type": "application/json" } : {}),283292 },284293 ...(init ? { body: JSON.stringify(init.body) } : {}),@@ -542,3 +551,59 @@ export async function provisionStore(options: {
542551 await ensureSliceConnectivity(options.target, slice, published.hash, union);543552 return { status: "updated", headHash: published.hash };544553 }554+555+/** The result of the ticket-scoped store-status preflight. */556+export type TicketStoreStatus =557+ /** A schema is deployed; `headHash` is the newest stored schema hash. */558+ | { state: "deployed"; appId: string; headHash: string }559+ /** Nothing is deployed — writes would hang; surface the provisioning opt-in. */560+ | { state: "no_schema"; appId: string }561+ /** The node is reachable but its store is not (gate answered 502). */562+ | { state: "store_unavailable" }563+ /** The ticket was revoked or is unknown — treat the stored sink as dead. */564+ | { state: "ticket_rejected" }565+ /** The endpoint does not exist here (open-mode node or plain Jazz server). */566+ | { state: "unsupported" };567+568+/**569+ * The metadata-only store preflight any valid app-connect ticket may call —570+ * no admin capability required. A self-hosted node answers571+ * `GET <ticket.url>/store-status` from its own loopback store, which is what572+ * lets a sync-only client learn `no_schema` (where writes hang) before ever573+ * attaching sync, and prompt toward the provisioning opt-in instead.574+ */575+export async function readTicketStoreStatus(ticketUrl: string): Promise<TicketStoreStatus> {576+ let response: Response;577+ try {578+ response = await fetch(`${ticketUrl.replace(/\/+$/, "")}/store-status`);579+ } catch {580+ return { state: "store_unavailable" };581+ }582+ if (response.status === 401) {583+ await response.body?.cancel();584+ return { state: "ticket_rejected" };585+ }586+ if (response.status === 502) {587+ await response.body?.cancel();588+ return { state: "store_unavailable" };589+ }590+ if (!response.ok) {591+ await response.body?.cancel();592+ return { state: "unsupported" };593+ }594+ try {595+ const body = await response.json() as {596+ v?: number;597+ appId?: string;598+ schema?: { deployed?: boolean; headHash?: string };599+ };600+ if (body.v !== 1 || typeof body.appId !== "string" || !body.schema) {601+ return { state: "unsupported" };602+ }603+ return body.schema.deployed && typeof body.schema.headHash === "string"604+ ? { state: "deployed", appId: body.appId, headHash: body.schema.headHash }605+ : { state: "no_schema", appId: body.appId };606+ } catch {607+ return { state: "unsupported" };608+ }609+}@@ -233,3 +233,44 @@ Deno.test("the app slice derives from the deploy target's compiled schema", () =
233233 "form against the stored schema",234234 );235235 });236+237+Deno.test("the ticket store-status preflight maps every documented gate response", async () => {238+ // A stub of the lofi-node gate's store-status endpoint (the contract in its239+ // docs/app-ticket.md): 200 with metadata, 502 store_unavailable, 401240+ // invalid_ticket, and 404 for nodes without the endpoint.241+ const responses: Record<string, () => Response> = {242+ "/t/deployed/store-status": () =>243+ Response.json({244+ v: 1,245+ appId: "a-app",246+ schema: { deployed: true, headHash: "ff85", permissionsHead: "0195" },247+ }),248+ "/t/fresh/store-status": () =>249+ Response.json({ v: 1, appId: "a-app", schema: { deployed: false } }),250+ "/t/down/store-status": () => Response.json({ error: "store_unavailable" }, { status: 502 }),251+ "/t/revoked/store-status": () => Response.json({ error: "invalid_ticket" }, { status: 401 }),252+ };253+ const server = Deno.serve(254+ { hostname: "127.0.0.1", port: 0, onListen: () => {} },255+ (request) => responses[new URL(request.url).pathname]?.() ?? new Response("", { status: 404 }),256+ );257+ const base = `http://127.0.0.1:${server.addr.port}`;258+ try {259+ const { readTicketStoreStatus } = await import("./store.ts");260+ const deployed = await readTicketStoreStatus(`${base}/t/deployed`);261+ assert(262+ deployed.state === "deployed" && deployed.headHash === "ff85" && deployed.appId === "a-app",263+ `deployed store mapped to ${JSON.stringify(deployed)}`,264+ );265+ const fresh = await readTicketStoreStatus(`${base}/t/fresh`);266+ assert(fresh.state === "no_schema", `fresh store mapped to ${JSON.stringify(fresh)}`);267+ const down = await readTicketStoreStatus(`${base}/t/down`);268+ assert(down.state === "store_unavailable", `502 mapped to ${JSON.stringify(down)}`);269+ const revoked = await readTicketStoreStatus(`${base}/t/revoked`);270+ assert(revoked.state === "ticket_rejected", `401 mapped to ${JSON.stringify(revoked)}`);271+ const open = await readTicketStoreStatus(`${base}/t/missing`);272+ assert(open.state === "unsupported", `404 mapped to ${JSON.stringify(open)}`);273+ } finally {274+ await server.shutdown();275+ }276+});