Context
lofi#109 (FelineStateMachine/lofi#109) defines opt-in, slice-scoped store provisioning on top of the user-supplied sync location: a lofi app classifies the store as ok | no_schema | schema_out_of_date | schema_drift, and — only on explicit user opt-in — creates/updates its own slice via a merge deploy (fetch stored head schema verbatim, append/update only its own appslug__* tables, publish schema + createTables/dropTables/renameTables migration + union permissions). The safety invariant is client-side: an app may never touch tables outside its declared namespaces.
The whole flow was validated against a live JazzServer on jazz-tools@2.0.0-alpha.53 (slice A deployed → union deployed with a createTables migration → permissions head advances as a chained bundle → old-hash clients keep working, new-hash clients use both slices). lofi-node already proxies everything the flow needs (…/t/<secret>/apps/<appId>/admin/… reaches the loopback Jazz server; X-Jazz-Admin-Secret still required on top). This issue covers what the node should add so the lofi-side UX matches #109's opt-in framing.
1. Provision-scoped app tickets
Today the opt-in "create/update store" flow requires the user to paste the raw admin secret alongside their sync ticket. lofi#109 tracks this as an open question pointed here: a ticket kind that carries provisioning capability, so possession of one credential is the opt-in.
- Extend the
lofisync1. format (v1 fields are {v, appId, url, label?, node?} — optional-field additions are forward-compatible per docs/app-ticket.md) with an optional scope, e.g. scope: "sync" | "provision", default "sync" when absent so every existing ticket keeps meaning transport-only.
ticket issue --provision --label laptop-admin mints one; ticket list shows scope; revocation semantics unchanged (401 / WS 4001).
- The gate enforces scope by path: admin/catalogue-mutating routes (
admin/schemas, admin/migrations, admin/permissions, …) require a provision-scoped ticket. For a provision-scoped ticket the gate injects the X-Jazz-Admin-Secret header from config itself, so the secret never leaves the node. Sync-scoped tickets hitting admin paths get the same 401 shape as invalid tickets (nothing to enumerate).
- Decide and document whether a provision ticket also grants sync (probably yes — it is a superset), and recommend issuing it per provisioning context, not per device.
2. Ticket-gated store-status preflight
The worst failure surface found experimentally: against a store with no deployed schema, client writes hang indefinitely — no error. lofi's classifier therefore needs a preflight, but the Jazz catalogue read endpoints (/schemas, /schema/<hash>) require the admin secret, which sync-only clients rightly never hold.
Add a lofi-node-owned, ticket-gated (any valid ticket, sync scope included) endpoint, e.g. GET /t/<secret>/store-status, answered by the node itself (it holds the admin secret in config and can query its loopback Jazz server):
- Metadata only — never schema contents, never policies, never the admin secret.
- This is what lets a sync-only lofi client render
no_schema / hash-mismatch diagnostics instead of hanging, and prompt the user toward the provisioning opt-in.
- Document it in docs/app-ticket.md (or a new docs/store-provisioning.md) as part of the contract the lofi side implements against.
3. Gate and tunnel parity for the merge-deploy flow
- E2E coverage: run the full lofi#109 second-app-joins-an-occupied-store scenario through the gate (ticket transport, provision scope): deploy slice A, merge slice B with its
createTables migration, verify the old-hash client keeps syncing and the union client works — mirroring the validated direct-server experiment.
- Same scenario once through an iroh-paired leaf: provisioning against the leaf must land on the root via the existing HTTP proxy (deploys already do; pin it for the migration + permissions-head sequence).
docs/hosting-lofi-apps.md §2 ("deploy the app's schema") gets rewritten around this: runtime provisioning via provision ticket becomes the primary path; the deploy-schema.ts script and the proposed lofi-node deploy CLI verb become the operator-side fallback.
Non-goals
- Enforcing lofi#109's appslug invariant server-side. The node cannot know which namespaces an app legitimately owns; honesty is a lofi-framework property. A provision ticket holder is trusted with the store — same trust boundary as the admin secret today, minus secret exposure.
- Multiple Jazz apps per node. One node, one appId, one store whose schema grows slice-by-slice, remains the model.
Acceptance criteria
Context
lofi#109 (FelineStateMachine/lofi#109) defines opt-in, slice-scoped store provisioning on top of the user-supplied sync location: a lofi app classifies the store as
ok | no_schema | schema_out_of_date | schema_drift, and — only on explicit user opt-in — creates/updates its own slice via a merge deploy (fetch stored head schema verbatim, append/update only its ownappslug__*tables, publish schema +createTables/dropTables/renameTablesmigration + union permissions). The safety invariant is client-side: an app may never touch tables outside its declared namespaces.The whole flow was validated against a live JazzServer on
jazz-tools@2.0.0-alpha.53(slice A deployed → union deployed with acreateTablesmigration → permissions head advances as a chained bundle → old-hash clients keep working, new-hash clients use both slices). lofi-node already proxies everything the flow needs (…/t/<secret>/apps/<appId>/admin/…reaches the loopback Jazz server;X-Jazz-Admin-Secretstill required on top). This issue covers what the node should add so the lofi-side UX matches #109's opt-in framing.1. Provision-scoped app tickets
Today the opt-in "create/update store" flow requires the user to paste the raw admin secret alongside their sync ticket. lofi#109 tracks this as an open question pointed here: a ticket kind that carries provisioning capability, so possession of one credential is the opt-in.
lofisync1.format (v1 fields are{v, appId, url, label?, node?}— optional-field additions are forward-compatible per docs/app-ticket.md) with an optional scope, e.g.scope: "sync" | "provision", default"sync"when absent so every existing ticket keeps meaning transport-only.ticket issue --provision --label laptop-adminmints one;ticket listshows scope; revocation semantics unchanged (401 / WS 4001).admin/schemas,admin/migrations,admin/permissions, …) require a provision-scoped ticket. For a provision-scoped ticket the gate injects theX-Jazz-Admin-Secretheader from config itself, so the secret never leaves the node. Sync-scoped tickets hitting admin paths get the same 401 shape as invalid tickets (nothing to enumerate).2. Ticket-gated store-status preflight
The worst failure surface found experimentally: against a store with no deployed schema, client writes hang indefinitely — no error. lofi's classifier therefore needs a preflight, but the Jazz catalogue read endpoints (
/schemas,/schema/<hash>) require the admin secret, which sync-only clients rightly never hold.Add a lofi-node-owned, ticket-gated (any valid ticket, sync scope included) endpoint, e.g.
GET /t/<secret>/store-status, answered by the node itself (it holds the admin secret in config and can query its loopback Jazz server):{ "v": 1, "appId": "…", "schema": { "deployed": true, "headHash": "…", "permissionsVersion": 3 } // or { "deployed": false } }no_schema/ hash-mismatch diagnostics instead of hanging, and prompt the user toward the provisioning opt-in.3. Gate and tunnel parity for the merge-deploy flow
createTablesmigration, verify the old-hash client keeps syncing and the union client works — mirroring the validated direct-server experiment.docs/hosting-lofi-apps.md§2 ("deploy the app's schema") gets rewritten around this: runtime provisioning via provision ticket becomes the primary path; thedeploy-schema.tsscript and the proposedlofi-node deployCLI verb become the operator-side fallback.Non-goals
Acceptance criteria
scope; issuance/list/revoke CLI; existing tickets unchanged in meaning; fixture strings added for the cross-repo contract test.store-statusendpoint, ticket-gated, metadata-only, documented in the app-ticket contract.