feat(schema): opt-in store provisioning confined to an app's own namespaces (#109)
- commit
- 7db5c0f5620796b649991db6a61528686ebba114
- parent
- e22ba7b903d503f664ff5a2f1c2a61cded3db106
- parent
- 18ab3058001a45b3ffdfc12aeb67de18fe3c171e
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | deno.json | 3 | +- |
| M | docs/ | 1 | + |
| A | docs/ | 103 | +++++ |
| M | package/ | 5 | + |
| M | package/ | 1 | + |
| M | package/ | 5 | + |
| A | package/ | 609 | ++++++++++++++++++++++++++++++ |
| A | package/ | 270 | +++++++++++++ |
@@ -15,6 +15,7 @@
1515 "./preact": "./package/preact/mod.ts",1616 "./provision": "./package/commands/provision.ts",1717 "./schema": "./package/schema/mod.ts",18+ "./schema/store": "./package/schema/store.ts",1819 "./recipes/file-handler": "./package/recipes/file-handler.ts",1920 "./recipes/launch-handler": "./package/recipes/launch-handler.ts",2021 "./recipes/protocol-handler": "./package/recipes/protocol-handler.ts",@@ -56,7 +57,7 @@
5657 "docs:links": "deno run --allow-read tools/docs_links.ts",5758 "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/mod_test.ts package/schema/nested_test.ts package/testing package/tooling apps/reference/tests",5859 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",59- "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/sliceable_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/nested_conformance_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",60+ "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/sliceable_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/nested_conformance_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/store_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",6061 "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",6162 "test": "deno task test:unit",6263 "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",@@ -43,6 +43,7 @@ shortest product overview and command summary. AI agents can ingest these docs a
4343 - [Binary and structured payloads](examples/payloads.md)4444 - [Schema evolution](examples/schema-evolution.md)4545 - [Nested app namespaces](examples/nested-namespaces.md)46+- [Store provisioning](store-provisioning.md)4647 - [Optional installed-app recipes](recipes/README.md)4748 - [Advanced device-auth primitive](auth.md)4849 @@ -0,0 +1,103 @@
1+# Store provisioning2+3+A user-supplied sync store (a self-hosted node reached by ticket) is shared infrastructure: another4+app's tables may already live in it. Provisioning is how an app creates or updates **its own slice**5+of such a store — as an explicit user opt-in, never as part of normal sync.6+7+Two principles govern it:8+9+1. **Store changes are opt-in.** Enrolling a sync ticket attaches transport only. Creating or10+ 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.14+2. **An app may only touch its own namespaces.** A nested app (`s.defineNestedApp`) declares15+ namespaces, and every change provisioning generates is confined to tables under them. Sibling16+ tables and their policies carry through a merge byte-for-byte. This keeps apps honest: many apps17+ can share one store, each owning its slice, none able to clobber another through the framework18+ surface. A flat `s.defineApp` schema has no namespace and therefore may only provision a store it19+ wholly owns.20+21+Provisioning lives at `@nzip/lofi/schema/store`, deliberately separate from `@nzip/lofi/schema`: the22+schema facade is bundled and executed by the Jazz schema loader when deriving the deployed schema,23+so the authoring surface stays free of the provisioning client.24+25+## Classify before you connect26+27+With store administration (admin secret, or a provision-scoped ticket URL as `serverUrl` with28+`adminSecret` omitted):29+30+```ts31+import { readStoreStatus } from "@nzip/lofi/schema/store";32+33+const status = await readStoreStatus(root, { serverUrl, appId, adminSecret });34+```35+36+Without it — any valid sync ticket may call the node's metadata-only preflight, which is how a37+sync-only client learns `no_schema` before ever attaching sync:38+39+```ts40+import { readTicketStoreStatus } from "@nzip/lofi/schema/store";41+42+const preflight = await readTicketStoreStatus(ticketUrl);43+// → { state: "deployed", appId, headHash } | { state: "no_schema", appId }44+// | { state: "store_unavailable" } | { state: "ticket_rejected" } | { state: "unsupported" }45+```46+47+| State | Meaning | Remedy |48+| -------------------- | ---------------------------------------------------------------- | -------------------- |49+| `ok` | The enforced schema carries this app's slice exactly. | none |50+| `no_schema` | Nothing is deployed. **Writes against such a store hang.** | opt-in create |51+| `schema_out_of_date` | The store lacks some of this app's tables. | opt-in update |52+| `schema_drift` | The store's copy of this app's namespaces differs unexplainably. | surfaced, never auto |53+54+`no_schema` is why classification exists beyond provisioning: against an empty store the engine's55+writes hang rather than fail, so an app should reach this state — and prompt the user — before ever56+attaching sync to a fresh store.57+58+## Provision59+60+```ts61+import { provisionStore } from "@nzip/lofi/schema/store";62+import { permissions, root } from "./schema.ts";63+64+const result = await provisionStore({65+ app: root,66+ permissions, // the app's merged per-namespace bundle67+ target: { serverUrl, appId, adminSecret },68+});69+// result.status → "created" | "updated" | "unchanged"70+```71+72+What a merge actually does, in order:73+74+1. Fetches the store's head schema and permissions bundle verbatim.75+2. Appends this app's missing tables to the stored schema object — everything else keeps its exact76+ serialization, because the schema hash is serialization-sensitive.77+3. Publishes the union schema, a migration from the current head (`added`-table lenses only), and78+ the union permissions bundle — sibling policies recovered from the store and preserved unchanged,79+ this app's policies swapped in for its own tables — chained to the current head.80+4. Registers this app's own compiled schema and connects it to the new head. Clients declare their81+ compiled schema hash on the wire, and the server only serves hashes connected to the enforced82+ head — without this edge, the joining app's clients would hang on their own tables.83+84+Existing clients of other apps continue untouched across a merge: their hash stays connected through85+the migration chain, and their policies were never rewritten.86+87+Drift refuses loudly (`StoreProvisionError` with code `schema-drift` and the differing tables); so88+does a permissions bundle naming a table outside the app's namespaces (`outside-namespace`), before89+any request is made.90+91+## Current limits92+93+- Table **additions** are the supported merge shape. Evolving columns of already-deployed tables94+ (add/drop/rename) still goes through the app's own migration deploy flow; forwarding those through95+ provisioning is follow-on work on [#109](https://github.com/FelineStateMachine/lofi/issues/109).96+- `readStoreStatus` compares structurally; it does not verify migration connectivity. A97+ `provisionStore` run (even an `unchanged` one) establishes the connectivity edge for this app's98+ schema, so run it once per app version as part of the opt-in.99+- Store administration (the admin secret, or a provision ticket) grants full control; the namespace100+ confinement is a framework honesty boundary, not a defense against a hostile administrator.101+ Provision-scoped tickets and the store-status preflight are the node-side contract from102+ [lofi-node#2](https://github.com/FelineStateMachine/lofi-node/issues/2), shipped in lofi-node's103+ `docs/app-ticket.md`.@@ -66,6 +66,7 @@ export const accessFiles = [
6666 export const schemaFiles = [6767 "mod.ts",6868 "nested.ts",69+ "store.ts",6970 ] as const;7071 7172 /** Preact modules vendored into `.lofi/`; kept in lockstep by the manifest test. */@@ -104,6 +105,7 @@ function renderConfig(projectRoot: string): string {
104105 const accessEntry = join(projectRoot, ".lofi", "package", "access", "mod.ts");105106 const preactEntry = join(projectRoot, ".lofi", "package", "preact", "mod.ts");106107 const schemaEntry = join(projectRoot, ".lofi", "package", "schema", "mod.ts");108+ const schemaStoreEntry = join(projectRoot, ".lofi", "package", "schema", "store.ts");107109 const fileHandlerRecipe = join(projectRoot, ".lofi", "package", "recipes", "file-handler.ts");108110 const launchHandlerRecipe = join(109111 projectRoot,@@ -203,6 +205,9 @@ export default defineConfig({
203205 } },204206 { find: /^jsr:@nzip\\/lofi@[^/]+\\/preact$/, replacement: ${JSON.stringify(preactEntry)} },205207 { find: /^jsr:@nzip\\/lofi@[^/]+\\/access$/, replacement: ${JSON.stringify(accessEntry)} },208+ { find: /^jsr:@nzip\\/lofi@[^/]+\\/schema\\/store$/, replacement: ${209+ JSON.stringify(schemaStoreEntry)210+ } },206211 { find: /^jsr:@nzip\\/lofi@[^/]+\\/schema$/, replacement: ${JSON.stringify(schemaEntry)} },207212 { find: /^jsr:@nzip\\/lofi@[^/]+$/, replacement: ${JSON.stringify(runtimeEntry)} },208213 { find: /^npm:preact@[^/]+\\/hooks$/, replacement: "preact/hooks" },@@ -159,6 +159,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
159159 "./recipes/web-share",160160 "./recipes/window-controls-overlay",161161 "./schema",162+ "./schema/store",162163 "./test",163164 "./testing",164165 ]);@@ -101,6 +101,11 @@ export {
101101 type NestedSchemaDefinition,102102 } from "./nested.ts";103103 104+// Store provisioning (./store.ts) is deliberately NOT re-exported here: the105+// Jazz schema loader bundles the author's schema module graph — this facade106+// included — and executes it to derive the deployed schema, so this module107+// must stay authoring-only. Import provisioning from "@nzip/lofi/schema/store".108+104109 /**105110 * Schema-authoring types re-exported from the pinned Jazz 2 DSL for use in106111 * application signatures. Row types for UI code come from `@nzip/lofi`@@ -0,0 +1,609 @@
1+/**2+ * Opt-in store provisioning for nested apps: classify a sync store's deployed3+ * schema against this app's slice, and create or update **only this app's4+ * namespaces** in it.5+ *6+ * A user-supplied store is shared infrastructure — another app's tables may7+ * already live in it. Provisioning therefore works as a merge over the stored8+ * schema: the head schema is fetched verbatim, this app's tables are appended9+ * or verified, every other table (and its policies) is preserved byte-for-byte,10+ * and the change deploys as an ordinary migration advancing the store's one11+ * permissions head. The safety invariant is enforced here, before anything is12+ * published: an app may only create tables under its own declared namespaces,13+ * and any generated change naming a table outside them is a hard error.14+ *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.20+ *21+ * @module22+ */23+import type { CompiledPermissions } from "jazz-tools";24+import { NESTED_SEPARATOR, nestedAppDeployTarget, nestedAppTables } from "./nested.ts";25+26+/** The store to provision: where it is, which app it hosts, and the admin opt-in. */27+export type StoreTarget = {28+ /** The http(s) sync server URL (a ticket URL keeps its secret base path). */29+ serverUrl: string;30+ /** The Jazz app id of the store. */31+ appId: 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;40+};41+42+/** How a store's deployed schema relates to this app's slice. */43+export type StoreStatus =44+ /** Nothing is deployed; writes against the store would hang. Remedy: create. */45+ | { state: "no_schema" }46+ /** The enforced schema carries this app's slice exactly. */47+ | { state: "ok"; headHash: string }48+ /**49+ * The enforced schema lacks some of this app's tables (first enrollment of50+ * this app, or a newer app version adding tables). Remedy: update.51+ */52+ | { state: "schema_out_of_date"; headHash: string; missingTables: readonly string[] }53+ /**54+ * The store's copy of this app's own namespaces differs from what the app55+ * declares in a way its lineage does not explain. Never auto-repaired.56+ */57+ | { state: "schema_drift"; headHash: string; driftedTables: readonly string[] };58+59+/** The outcome of a provisioning run. */60+export type StoreProvisionResult = {61+ /** `created` deployed an initial schema; `updated` merged this app's slice. */62+ status: "created" | "updated" | "unchanged";63+ /** The schema hash now enforcing permissions. */64+ headHash: string;65+};66+67+/** Raised when provisioning is refused or the store rejects a request. */68+export class StoreProvisionError extends Error {69+ /** Stable error class name for diagnostics and error boundaries. */70+ override readonly name = "StoreProvisionError";71+ /** Stable category for provisioning flows. */72+ readonly code: "http" | "schema-drift" | "not-nested" | "outside-namespace";73+ /** Creates a provisioning rejection with a stable category. */74+ constructor(75+ code: "http" | "schema-drift" | "not-nested" | "outside-namespace",76+ message: string,77+ ) {78+ super(message);79+ this.code = code;80+ }81+}82+83+type WasmColumn = {84+ name: string;85+ column_type: unknown;86+ nullable?: boolean;87+ references?: string;88+ default?: unknown;89+ merge_strategy?: unknown;90+};91+type WasmTable = { columns: WasmColumn[] };92+type WasmSchemaMap = Record<string, WasmTable>;93+94+type PermissionsHead = {95+ schemaHash: string;96+ version: number;97+ parentBundleObjectId: string | null;98+ bundleObjectId: string;99+};100+101+// ---------------------------------------------------------------------------102+// Wire encoding of a freshly compiled permissions bundle.103+//104+// The store's write endpoints take policy literals as tagged values, but the105+// pinned DSL's compiled output carries raw JS values, and the normalization106+// that bridges them is not a public jazz-tools export (the module that could107+// provide it is not browser-safe). This section mirrors that normalization;108+// the store conformance suite deploys through it against the real server, so109+// an alpha bump that changes the wire contract fails there loudly. Policies110+// read back from the store are already wire-form and are never re-encoded.111+// ---------------------------------------------------------------------------112+113+function isRecord(value: unknown): value is Record<string, unknown> {114+ return typeof value === "object" && value !== null && !Array.isArray(value);115+}116+117+const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;118+119+function encodeLiteral(value: unknown): unknown {120+ if (value === null) return { type: "Null" };121+ if (value instanceof Date) return { type: "Timestamp", value: value.getTime() };122+ if (value instanceof Uint8Array) {123+ throw new StoreProvisionError(124+ "outside-namespace",125+ "bytes literals in policy conditions are not supported by store provisioning",126+ );127+ }128+ if (typeof value === "boolean") return { type: "Boolean", value };129+ if (typeof value === "number") {130+ if (!Number.isFinite(value)) {131+ throw new StoreProvisionError("http", "policy literals only support finite numbers");132+ }133+ if (!Number.isInteger(value)) return { type: "Double", value };134+ if (value >= -2147483648 && value <= 2147483647) return { type: "Integer", value };135+ if (Number.isSafeInteger(value)) return { type: "BigInt", value };136+ return { type: "Double", value };137+ }138+ if (typeof value === "string") {139+ return UUID_LIKE.test(value) ? { type: "Uuid", value } : { type: "Text", value };140+ }141+ if (Array.isArray(value)) return { type: "Array", value: value.map(encodeLiteral) };142+ if (isRecord(value) && typeof value.type === "string") return value;143+ throw new StoreProvisionError(144+ "http",145+ "policy literals must be scalars, arrays, Date, or tagged values",146+ );147+}148+149+function encodePolicyValue(value: unknown): unknown {150+ if (isRecord(value) && value.type === "SessionRef") return value;151+ const raw = isRecord(value) && "value" in value ? value.value : value;152+ return { type: "Literal", value: encodeLiteral(raw) };153+}154+155+function encodeRelationValueRef(value: unknown): unknown {156+ if (isRecord(value) && "Literal" in value) return { Literal: encodeLiteral(value.Literal) };157+ return value;158+}159+160+function encodeRelationPredicate(predicate: unknown): unknown {161+ if (!isRecord(predicate)) return predicate;162+ if ("Cmp" in predicate && isRecord(predicate.Cmp)) {163+ return { Cmp: { ...predicate.Cmp, right: encodeRelationValueRef(predicate.Cmp.right) } };164+ }165+ if ("Contains" in predicate && isRecord(predicate.Contains)) {166+ return {167+ Contains: { ...predicate.Contains, right: encodeRelationValueRef(predicate.Contains.right) },168+ };169+ }170+ if ("In" in predicate && isRecord(predicate.In)) {171+ return {172+ In: {173+ ...predicate.In,174+ values: (predicate.In.values as unknown[]).map(encodeRelationValueRef),175+ },176+ };177+ }178+ if ("And" in predicate) {179+ return { And: (predicate.And as unknown[]).map(encodeRelationPredicate) };180+ }181+ if ("Or" in predicate) return { Or: (predicate.Or as unknown[]).map(encodeRelationPredicate) };182+ if ("Not" in predicate) return { Not: encodeRelationPredicate(predicate.Not) };183+ return predicate;184+}185+186+function encodeRelationExpr(expr: unknown): unknown {187+ if (!isRecord(expr)) return expr;188+ if ("Filter" in expr && isRecord(expr.Filter)) {189+ return {190+ Filter: {191+ input: encodeRelationExpr(expr.Filter.input),192+ predicate: encodeRelationPredicate(expr.Filter.predicate),193+ },194+ };195+ }196+ if ("Union" in expr && isRecord(expr.Union)) {197+ return { Union: { inputs: (expr.Union.inputs as unknown[]).map(encodeRelationExpr) } };198+ }199+ if ("Join" in expr && isRecord(expr.Join)) {200+ return {201+ Join: {202+ ...expr.Join,203+ left: encodeRelationExpr(expr.Join.left),204+ right: encodeRelationExpr(expr.Join.right),205+ },206+ };207+ }208+ if ("Gather" in expr && isRecord(expr.Gather)) {209+ return {210+ Gather: {211+ ...expr.Gather,212+ seed: encodeRelationExpr(expr.Gather.seed),213+ step: encodeRelationExpr(expr.Gather.step),214+ },215+ };216+ }217+ for (const wrapper of ["Project", "Distinct", "OrderBy", "Offset", "Limit"]) {218+ const inner = expr[wrapper];219+ if (isRecord(inner)) {220+ return { [wrapper]: { ...inner, input: encodeRelationExpr(inner.input) } };221+ }222+ }223+ return expr;224+}225+226+function encodePolicyExpr(expr: unknown): unknown {227+ if (!isRecord(expr)) return expr;228+ switch (expr.type) {229+ case "Cmp":230+ case "Contains":231+ return { ...expr, value: encodePolicyValue(expr.value) };232+ case "SessionCmp":233+ case "SessionContains":234+ return {235+ ...expr,236+ value: encodeLiteral(isRecord(expr.value) ? expr.value.value : expr.value),237+ };238+ case "InList":239+ return { ...expr, values: (expr.values as unknown[]).map(encodePolicyValue) };240+ case "SessionInList":241+ return {242+ ...expr,243+ values: (expr.values as unknown[]).map((value) =>244+ encodeLiteral(isRecord(value) ? value.value : value)245+ ),246+ };247+ case "Exists":248+ return { ...expr, condition: encodePolicyExpr(expr.condition) };249+ case "ExistsRel":250+ return { ...expr, rel: encodeRelationExpr(expr.rel) };251+ case "And":252+ case "Or":253+ return { ...expr, exprs: (expr.exprs as unknown[]).map(encodePolicyExpr) };254+ case "Not":255+ return { ...expr, expr: encodePolicyExpr(expr.expr) };256+ default:257+ return expr;258+ }259+}260+261+function encodeOwnPermissions(permissions: CompiledPermissions): Record<string, unknown> {262+ const encoded: Record<string, unknown> = {};263+ for (const [table, policies] of Object.entries(permissions as Record<string, unknown>)) {264+ if (!isRecord(policies)) continue;265+ const table_policies: Record<string, unknown> = {};266+ for (const operation of ["select", "insert", "update", "delete"]) {267+ const policy = policies[operation];268+ if (!isRecord(policy)) continue;269+ const normalized: Record<string, unknown> = {};270+ if (policy.using !== undefined) normalized.using = encodePolicyExpr(policy.using);271+ if (policy.with_check !== undefined) {272+ normalized.with_check = encodePolicyExpr(policy.with_check);273+ }274+ table_policies[operation] = normalized;275+ }276+ encoded[table] = table_policies;277+ }278+ return encoded;279+}280+281+async function adminFetch(282+ target: StoreTarget,283+ path: string,284+ init?: { method: "POST"; body: unknown },285+): Promise<unknown> {286+ const base = target.serverUrl.replace(/\/+$/, "");287+ const response = await fetch(`${base}/apps/${target.appId}/${path}`, {288+ method: init?.method ?? "GET",289+ headers: {290+ ...(target.adminSecret ? { "X-Jazz-Admin-Secret": target.adminSecret } : {}),291+ ...(init ? { "Content-Type": "application/json" } : {}),292+ },293+ ...(init ? { body: JSON.stringify(init.body) } : {}),294+ });295+ const text = await response.text();296+ if (!response.ok) {297+ throw new StoreProvisionError(298+ "http",299+ `store request ${path} failed: HTTP ${response.status} ${text.slice(0, 200)}`,300+ );301+ }302+ try {303+ return text ? JSON.parse(text) : {};304+ } catch {305+ throw new StoreProvisionError("http", `store request ${path} returned non-JSON`);306+ }307+}308+309+// Structural table comparison in the spirit of the engine's own schema310+// equality: column order is irrelevant; every attribute that changes storage311+// or merge behavior is significant.312+function normalizeColumns(table: WasmTable): string {313+ const columns = [...table.columns].sort((a, b) => a.name.localeCompare(b.name)).map((c) => ({314+ name: c.name,315+ column_type: c.column_type,316+ nullable: c.nullable === true,317+ references: c.references ?? null,318+ default: c.default ?? null,319+ merge_strategy: c.merge_strategy ?? null,320+ }));321+ return JSON.stringify(columns);322+}323+324+function sameTable(a: WasmTable, b: WasmTable): boolean {325+ return normalizeColumns(a) === normalizeColumns(b);326+}327+328+function namespaceOf(tableName: string): string | null {329+ const index = tableName.indexOf(NESTED_SEPARATOR);330+ return index > 0 ? tableName.slice(0, index) : null;331+}332+333+type AppSlice = {334+ /** This app's tables in wire (wasm) form, keyed by mangled global name. */335+ tables: WasmSchemaMap;336+ /** This app's declared namespace prefixes. */337+ namespaces: ReadonlySet<string>;338+};339+340+function appSlice(app: unknown): AppSlice {341+ if (nestedAppTables(app) === null) {342+ throw new StoreProvisionError(343+ "not-nested",344+ "store provisioning requires a nested app: the namespace is what scopes the changes an " +345+ "app may make to a shared store",346+ );347+ }348+ const deployTarget = nestedAppDeployTarget(app) as { wasmSchema: WasmSchemaMap };349+ const tables: WasmSchemaMap = {};350+ const namespaces = new Set<string>();351+ for (const [name, table] of Object.entries(deployTarget.wasmSchema)) {352+ tables[name] = table;353+ const namespace = namespaceOf(name);354+ if (namespace) namespaces.add(namespace);355+ }356+ return { tables, namespaces };357+}358+359+type StoreArtifacts = {360+ head: PermissionsHead | null;361+ permissions: Record<string, unknown>;362+ schema: WasmSchemaMap;363+};364+365+async function fetchStore(target: StoreTarget): Promise<StoreArtifacts> {366+ const hashes = await adminFetch(target, "schemas") as { hashes?: string[] };367+ const bundle = await adminFetch(target, "admin/permissions") as {368+ head?: PermissionsHead | null;369+ permissions?: Record<string, unknown>;370+ };371+ const head = bundle.head ?? null;372+ if (!head || !hashes.hashes?.length) return { head: null, permissions: {}, schema: {} };373+ const stored = await adminFetch(target, `schema/${encodeURIComponent(head.schemaHash)}`) as {374+ schema?: WasmSchemaMap;375+ };376+ return { head, permissions: bundle.permissions ?? {}, schema: stored.schema ?? {} };377+}378+379+function classify(slice: AppSlice, artifacts: StoreArtifacts): StoreStatus {380+ if (!artifacts.head) return { state: "no_schema" };381+ const headHash = artifacts.head.schemaHash;382+ const missingTables: string[] = [];383+ const driftedTables: string[] = [];384+ for (const [name, expected] of Object.entries(slice.tables)) {385+ const stored = artifacts.schema[name];386+ if (!stored) missingTables.push(name);387+ else if (!sameTable(stored, expected)) driftedTables.push(name);388+ }389+ // A stored table under this app's namespaces that the app does not declare390+ // is drift too: either another writer reached into the slug, or the app is391+ // older than the store — both need a human, not an auto-merge.392+ for (const name of Object.keys(artifacts.schema)) {393+ const namespace = namespaceOf(name);394+ if (namespace && slice.namespaces.has(namespace) && !(name in slice.tables)) {395+ driftedTables.push(name);396+ }397+ }398+ if (driftedTables.length) return { state: "schema_drift", headHash, driftedTables };399+ if (missingTables.length) return { state: "schema_out_of_date", headHash, missingTables };400+ return { state: "ok", headHash };401+}402+403+/**404+ * Classifies the store's deployed schema against this app's slice without405+ * changing anything. `no_schema` matters beyond provisioning: against an406+ * empty store, client writes hang rather than fail, so callers should reach407+ * this state before ever attaching sync to a fresh store.408+ */409+export async function readStoreStatus(app: unknown, target: StoreTarget): Promise<StoreStatus> {410+ return classify(appSlice(app), await fetchStore(target));411+}412+413+function assertOwnNamespacePermissions(414+ slice: AppSlice,415+ permissions: CompiledPermissions,416+): void {417+ for (const table of Object.keys(permissions)) {418+ const namespace = namespaceOf(table);419+ if (!namespace || !slice.namespaces.has(namespace)) {420+ throw new StoreProvisionError(421+ "outside-namespace",422+ `permissions bundle names table "${table}" outside this app's namespaces ` +423+ `(${[...slice.namespaces].join(", ")}) — an app may only provision its own slice`,424+ );425+ }426+ }427+}428+429+// A client declares its own compiled schema hash on the wire, and the server430+// only serves hashes connected (via migrations) to the head enforcing431+// permissions — a disconnected client's writes to its own tables hang. So432+// provisioning also registers this app's own-slice schema and, when it is not433+// the head itself, publishes the connecting migration (sibling tables as434+// `added` lenses). Runs on every provision, so an `unchanged` pass self-heals435+// a store whose slice arrived without the edge.436+async function ensureSliceConnectivity(437+ target: StoreTarget,438+ slice: AppSlice,439+ headHash: string,440+ headSchema: WasmSchemaMap,441+): Promise<void> {442+ const own = await adminFetch(target, "admin/schemas", {443+ method: "POST",444+ body: { schema: slice.tables },445+ }) as { hash: string };446+ if (own.hash === headHash) return;447+ const connectivity = await adminFetch(448+ target,449+ `admin/schema-connectivity?fromHash=${encodeURIComponent(own.hash)}&toHash=${450+ encodeURIComponent(headHash)451+ }`,452+ ) as { connected?: boolean };453+ if (connectivity.connected) return;454+ const siblingTables = Object.keys(headSchema).filter((name) => !(name in slice.tables));455+ await adminFetch(target, "admin/migrations", {456+ method: "POST",457+ body: {458+ fromHash: own.hash,459+ toHash: headHash,460+ forward: siblingTables.map((table) => ({ table, added: true, operations: [] })),461+ },462+ });463+}464+465+/**466+ * Creates or updates this app's slice in the store. The stored head schema is467+ * fetched verbatim and only extended: missing tables of this app's namespaces468+ * are appended (key order of everything else preserved — the schema hash is469+ * serialization-sensitive), sibling tables and their policies carry through470+ * untouched, and the change publishes as schema + `added`-table migration +471+ * a permissions bundle chained to the current head. The app's own compiled472+ * schema is additionally registered and connected to the head, so this app's473+ * clients — which declare that schema on the wire — are never disconnected.474+ * Drift is never repaired: a store whose copy of this app's namespaces475+ * differs from the declaration throws with the differing tables.476+ */477+export async function provisionStore(options: {478+ app: unknown;479+ permissions: CompiledPermissions;480+ target: StoreTarget;481+}): Promise<StoreProvisionResult> {482+ const slice = appSlice(options.app);483+ assertOwnNamespacePermissions(slice, options.permissions);484+ const artifacts = await fetchStore(options.target);485+ const status = classify(slice, artifacts);486+487+ if (status.state === "schema_drift") {488+ throw new StoreProvisionError(489+ "schema-drift",490+ `store tables [${status.driftedTables.join(", ")}] differ from this app's declaration; ` +491+ "drift is surfaced, never auto-repaired",492+ );493+ }494+ if (status.state === "ok") {495+ await ensureSliceConnectivity(options.target, slice, status.headHash, artifacts.schema);496+ return { status: "unchanged", headHash: status.headHash };497+ }498+499+ const ownPermissions = encodeOwnPermissions(options.permissions);500+501+ if (status.state === "no_schema") {502+ const published = await adminFetch(options.target, "admin/schemas", {503+ method: "POST",504+ body: { schema: slice.tables },505+ }) as { hash: string };506+ await adminFetch(options.target, "admin/permissions", {507+ method: "POST",508+ body: {509+ schemaHash: published.hash,510+ permissions: ownPermissions,511+ expectedParentBundleObjectId: null,512+ },513+ });514+ return { status: "created", headHash: published.hash };515+ }516+517+ // schema_out_of_date: append this app's missing tables to the stored schema518+ // and swap in this app's policies, leaving every sibling entry untouched.519+ const union: WasmSchemaMap = { ...artifacts.schema };520+ for (const name of status.missingTables) union[name] = slice.tables[name];521+ const unionPermissions: Record<string, unknown> = {};522+ for (const [table, policies] of Object.entries(artifacts.permissions)) {523+ const namespace = namespaceOf(table);524+ if (!(namespace && slice.namespaces.has(namespace))) unionPermissions[table] = policies;525+ }526+ for (const [table, policies] of Object.entries(ownPermissions)) {527+ unionPermissions[table] = policies;528+ }529+530+ const head = artifacts.head as PermissionsHead;531+ const published = await adminFetch(options.target, "admin/schemas", {532+ method: "POST",533+ body: { schema: union },534+ }) as { hash: string };535+ await adminFetch(options.target, "admin/migrations", {536+ method: "POST",537+ body: {538+ fromHash: head.schemaHash,539+ toHash: published.hash,540+ forward: status.missingTables.map((table) => ({ table, added: true, operations: [] })),541+ },542+ });543+ await adminFetch(options.target, "admin/permissions", {544+ method: "POST",545+ body: {546+ schemaHash: published.hash,547+ permissions: unionPermissions,548+ expectedParentBundleObjectId: head.bundleObjectId,549+ },550+ });551+ await ensureSliceConnectivity(options.target, slice, published.hash, union);552+ return { status: "updated", headHash: published.hash };553+}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+}@@ -0,0 +1,270 @@
1+// Store-provisioning conformance over a real JazzServer: the classifier's2+// four states, the create and slice-merge paths through the raw admin3+// endpoints, old-hash client continuity across a merge, sibling-policy4+// preservation (the honesty proof), and the namespace-confinement guards.5+import { createDb } from "jazz-tools";6+import { startLocalJazzServer } from "jazz-tools/testing";7+import { assert } from "../runtime/test-assert.ts";8+import { nestedAppDeployTarget, s } from "./mod.ts";9+import { provisionStore, readStoreStatus, StoreProvisionError, type StoreTarget } from "./store.ts";10+11+// The first app on the store.12+const taskRoot = s.defineNestedApp({13+ taskapp: { tasks: s.table({ text: s.string(), secret: s.boolean() }) },14+});15+const taskPermissions = s.defineNestedPermissions(taskRoot.taskapp, ({ policy, session }) => {16+ policy.tasks.allowInsert.always();17+ // The gate the second app's merge must preserve verbatim.18+ policy.tasks.allowRead.where({ secret: false });19+ policy.tasks.allowRead.where({ $createdBy: session.user_id });20+ policy.tasks.allowUpdate.always();21+ policy.tasks.allowDelete.always();22+});23+24+// The second app, enrolling later against the occupied store.25+const noteRoot = s.defineNestedApp({26+ notesapp: { notes: s.table({ title: s.string() }) },27+});28+const notePermissions = s.defineNestedPermissions(noteRoot.notesapp, ({ policy }) => {29+ policy.notes.allowInsert.always();30+ policy.notes.allowRead.always();31+ policy.notes.allowUpdate.always();32+ policy.notes.allowDelete.always();33+});34+35+function secret(fill: number): string {36+ let binary = "";37+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);38+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");39+}40+41+async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {42+ let timer: ReturnType<typeof setTimeout> | undefined;43+ try {44+ return await Promise.race([45+ operation,46+ new Promise<never>((_resolve, reject) => {47+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);48+ }),49+ ]);50+ } finally {51+ if (timer !== undefined) clearTimeout(timer);52+ }53+}54+55+Deno.test("a store is classified and provisioned slice by slice, preserving its other tenants", async () => {56+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });57+ const target: StoreTarget = {58+ serverUrl: server.url,59+ appId: server.appId,60+ adminSecret: server.adminSecret,61+ };62+ const clients: Awaited<ReturnType<typeof createDb>>[] = [];63+ const client = async (fill: number) => {64+ const db = await createDb({65+ appId: server.appId,66+ serverUrl: server.url,67+ secret: secret(fill),68+ userBranch: "main",69+ driver: { type: "memory" },70+ });71+ clients.push(db);72+ return db;73+ };74+ try {75+ // A fresh store has nothing deployed — the state where writes would hang.76+ const empty = await readStoreStatus(taskRoot, target);77+ assert(empty.state === "no_schema", `fresh store classified as ${empty.state}`);78+79+ // Create: the first app's slice becomes the store schema.80+ const created = await provisionStore({81+ app: taskRoot,82+ permissions: taskPermissions,83+ target,84+ });85+ assert(created.status === "created", `initial provisioning reported ${created.status}`);86+ const afterCreate = await readStoreStatus(taskRoot, target);87+ assert(88+ afterCreate.state === "ok" && afterCreate.headHash === created.headHash,89+ `provisioned store classified as ${afterCreate.state}`,90+ );91+ const again = await provisionStore({ app: taskRoot, permissions: taskPermissions, target });92+ assert(again.status === "unchanged", "re-provisioning an ok store must be a no-op");93+94+ const alice = await client(1);95+ const task = await within(96+ alice.insert(taskRoot.taskapp.tasks, { text: "visible", secret: false })97+ .wait({ tier: "global" }),98+ "task insert after create",99+ );100+ await within(101+ alice.insert(taskRoot.taskapp.tasks, { text: "hidden", secret: true })102+ .wait({ tier: "global" }),103+ "gated task insert",104+ );105+106+ // The second app sees the occupied store as out of date for ITS slice.107+ const beforeMerge = await readStoreStatus(noteRoot, target);108+ assert(109+ beforeMerge.state === "schema_out_of_date" &&110+ beforeMerge.missingTables.join(",") === "notesapp__notes",111+ `second app classified the store as ${JSON.stringify(beforeMerge)}`,112+ );113+114+ // Merge: only notesapp tables are added; taskapp rides through verbatim.115+ const merged = await provisionStore({116+ app: noteRoot,117+ permissions: notePermissions,118+ target,119+ });120+ assert(merged.status === "updated", `slice merge reported ${merged.status}`);121+ const afterMerge = await readStoreStatus(noteRoot, target);122+ assert(afterMerge.state === "ok", `merged store classified as ${afterMerge.state} for notes`);123+ const taskView = await readStoreStatus(taskRoot, target);124+ assert(taskView.state === "ok", `merged store classified as ${taskView.state} for tasks`);125+126+ // Old-hash continuity: the first app's client keeps working untouched.127+ await within(128+ alice.insert(taskRoot.taskapp.tasks, { text: "after merge", secret: false })129+ .wait({ tier: "global" }),130+ "old-hash insert after merge",131+ );132+133+ // The new slice is live for the joining app's own clients — provisioning134+ // registered its compiled schema and connected it to the head, so its135+ // declared hash is never disconnected.136+ const bob = await client(2);137+ await within(138+ bob.insert(noteRoot.notesapp.notes, { title: "first note" }).wait({ tier: "global" }),139+ "notes insert after merge",140+ );141+142+ // The honesty proof: notesapp's merge preserved taskapp's read gate. A143+ // Db binds to one app's schema, so the non-author viewer is its own144+ // client speaking the first app's handles.145+ const carol = await client(3);146+ const viewerTasks = await within(147+ carol.all(taskRoot.taskapp.tasks.where({}), { tier: "global" }),148+ "viewer task read after merge",149+ );150+ assert(151+ viewerTasks.length === 2 && viewerTasks.every((row) => row.secret === false),152+ `sibling policy not preserved: viewer sees ${viewerTasks.length} rows`,153+ );154+ const authorTasks = await within(155+ alice.all(taskRoot.taskapp.tasks.where({ id: task.id }), { tier: "global" }),156+ "author task read after merge",157+ );158+ assert(authorTasks.length === 1, "author lost access to pre-merge rows");159+160+ // Drift: an app whose declaration disagrees with the store's copy of its161+ // own namespace is surfaced and refused, never repaired.162+ const driftedRoot = s.defineNestedApp({163+ taskapp: { tasks: s.table({ text: s.string(), secret: s.boolean(), extra: s.int() }) },164+ });165+ const drifted = await readStoreStatus(driftedRoot, target);166+ assert(167+ drifted.state === "schema_drift" &&168+ drifted.driftedTables.join(",") === "taskapp__tasks",169+ `drifted app classified the store as ${JSON.stringify(drifted)}`,170+ );171+ const driftedPermissions = s.defineNestedPermissions(172+ driftedRoot.taskapp,173+ ({ policy }) => policy.tasks.allowRead.always(),174+ );175+ let driftError = "";176+ try {177+ await provisionStore({ app: driftedRoot, permissions: driftedPermissions, target });178+ } catch (error) {179+ driftError = error instanceof StoreProvisionError ? error.code : String(error);180+ }181+ assert(driftError === "schema-drift", `drifted provisioning reported "${driftError}"`);182+ } finally {183+ await Promise.allSettled(184+ clients.map((db, index) => within(db.logout(), `client ${index} cleanup`, 3_000)),185+ );186+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);187+ }188+});189+190+Deno.test("provisioning is confined to the app's own namespaces", async () => {191+ const target: StoreTarget = {192+ serverUrl: "http://127.0.0.1:1",193+ appId: "00000000-0000-0000-0000-000000000000",194+ adminSecret: "unused",195+ };196+197+ // A flat app has no namespace and therefore no tenant boundary.198+ const flat = s.defineApp({ tasks: s.table({ text: s.string() }) });199+ let flatError = "";200+ try {201+ await readStoreStatus(flat, target);202+ } catch (error) {203+ flatError = error instanceof StoreProvisionError ? error.code : String(error);204+ }205+ assert(flatError === "not-nested", `flat app reported "${flatError}"`);206+207+ // A permissions bundle naming a table outside the app's namespaces is a208+ // hard error before any request is made (the target above is unreachable).209+ const outOfSlug = {210+ ...notePermissions,211+ taskapp__tasks: (taskPermissions as Record<string, unknown>).taskapp__tasks,212+ } as typeof notePermissions;213+ let slugError = "";214+ try {215+ await provisionStore({ app: noteRoot, permissions: outOfSlug, target });216+ } catch (error) {217+ slugError = error instanceof StoreProvisionError ? error.code : String(error);218+ }219+ assert(slugError === "outside-namespace", `out-of-slug bundle reported "${slugError}"`);220+});221+222+Deno.test("the app slice derives from the deploy target's compiled schema", () => {223+ const deployTarget = nestedAppDeployTarget(taskRoot) as { wasmSchema: Record<string, unknown> };224+ assert(225+ "taskapp__tasks" in deployTarget.wasmSchema,226+ "expected the mangled table in the compiled wasm schema — the classifier compares this " +227+ "form against the stored schema",228+ );229+});230+231+Deno.test("the ticket store-status preflight maps every documented gate response", async () => {232+ // A stub of the lofi-node gate's store-status endpoint (the contract in its233+ // docs/app-ticket.md): 200 with metadata, 502 store_unavailable, 401234+ // invalid_ticket, and 404 for nodes without the endpoint.235+ const responses: Record<string, () => Response> = {236+ "/t/deployed/store-status": () =>237+ Response.json({238+ v: 1,239+ appId: "a-app",240+ schema: { deployed: true, headHash: "ff85", permissionsHead: "0195" },241+ }),242+ "/t/fresh/store-status": () =>243+ Response.json({ v: 1, appId: "a-app", schema: { deployed: false } }),244+ "/t/down/store-status": () => Response.json({ error: "store_unavailable" }, { status: 502 }),245+ "/t/revoked/store-status": () => Response.json({ error: "invalid_ticket" }, { status: 401 }),246+ };247+ const server = Deno.serve(248+ { hostname: "127.0.0.1", port: 0, onListen: () => {} },249+ (request) => responses[new URL(request.url).pathname]?.() ?? new Response("", { status: 404 }),250+ );251+ const base = `http://127.0.0.1:${server.addr.port}`;252+ try {253+ const { readTicketStoreStatus } = await import("./store.ts");254+ const deployed = await readTicketStoreStatus(`${base}/t/deployed`);255+ assert(256+ deployed.state === "deployed" && deployed.headHash === "ff85" && deployed.appId === "a-app",257+ `deployed store mapped to ${JSON.stringify(deployed)}`,258+ );259+ const fresh = await readTicketStoreStatus(`${base}/t/fresh`);260+ assert(fresh.state === "no_schema", `fresh store mapped to ${JSON.stringify(fresh)}`);261+ const down = await readTicketStoreStatus(`${base}/t/down`);262+ assert(down.state === "store_unavailable", `502 mapped to ${JSON.stringify(down)}`);263+ const revoked = await readTicketStoreStatus(`${base}/t/revoked`);264+ assert(revoked.state === "ticket_rejected", `401 mapped to ${JSON.stringify(revoked)}`);265+ const open = await readTicketStoreStatus(`${base}/t/missing`);266+ assert(open.state === "unsupported", `404 mapped to ${JSON.stringify(open)}`);267+ } finally {268+ await server.shutdown();269+ }270+});