Tickets carry an optional scope (sync | provision; absent means sync); an unrecognized scope rejects the ticket rather than silently granting less than it claims. The scope carries into the sink record so the app knows a provision-capable sink without re-pasting. The lofi-node conformance fixtures are vendored and pinned by test, so format drift on either side fails loudly.
- commit
- a4783249cf96ec7124759fb917d1c39e59ad2828
- parent
- 9a8799a65d7c623b2f33545beb4862c004d8cd9e
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 4 | +++ |
| M | package/ | 22 | +++++++++++++- |
| M | package/ | 38 | +++++++++++++++++++++++++ |
| A | package/ | 45 | ++++++++++++++++++++++++++++++ |
@@ -74,6 +74,10 @@ Semantics to rely on:
7474 - **The ticket URL is a bearer credential.** It is used verbatim as the sync server (its secret path7575 is what authorizes transport), stored in a device-local record, and never exposed through the7676 session snapshot — `session.sink` carries only the source, host, and label.77+- **Tickets carry a scope.** A plain (or `scope: "sync"`) ticket is transport only; a78+ `scope: "provision"` ticket additionally administers the store through the node's gate — the basis79+ for opt-in store provisioning. A ticket with an unrecognized scope is rejected outright rather80+ than silently granted less than it claims.7781 - If the node revokes the ticket, requests fail with 401 and live sockets close; treat the stored7882 sink as dead and surface re-enrollment rather than retrying silently.7983 @@ -52,6 +52,12 @@ export type DataSinkDeclaration = {
5252 appId: string;5353 /** The http(s) sync server URL, used verbatim (a ticket URL keeps its secret path). */5454 serverUrl: string;55+ /**56+ * The enrolled ticket's capability: `sync` is transport only; `provision`57+ * additionally administers the store through the node's gate (the admin58+ * secret never transits the client). Absent means `sync`.59+ */60+ scope?: "sync" | "provision";5561 /** Optional user-facing label, e.g. the ticket's device label. */5662 label?: string;5763 /**@@ -97,6 +103,7 @@ function parseRecord(raw: string | null): DataSinkDeclaration | null {
97103 return {98104 appId: sink.appId,99105 serverUrl: sink.serverUrl,106+ ...(sink.scope === "provision" || sink.scope === "sync" ? { scope: sink.scope } : {}),100107 ...(typeof sink.label === "string" ? { label: sink.label } : {}),101108 ...(typeof sink.node === "string" ? { node: sink.node } : {}),102109 };@@ -189,13 +196,18 @@ export function clearDeclaredSink(): void {
189196 /**190197 * A parsed `lofisync1.` app-connect ticket — the credential a self-hosted191198 * node issues so an app syncs against it. The format contract lives with the192- * node (lofi-node `docs/app-ticket.md`); this parser mirrors its validation:193- * version 1, an http(s) URL whose path is `/t/<43-char base64url secret>`.199+ * node (lofi-node `docs/app-ticket.md`, conformance fixtures vendored at200+ * `package/testdata/app-ticket-fixtures.json`); this parser mirrors its201+ * validation: version 1, an http(s) URL whose path is202+ * `/t/<43-char base64url secret>`, and a `scope` that is absent (meaning203+ * `sync`), `sync`, or `provision` — an unknown scope rejects the ticket204+ * rather than silently granting less than it claims.194205 */195206 export type SyncTicket = {196207 v: 1;197208 appId: string;198209 url: string;210+ scope?: "sync" | "provision";199211 label?: string;200212 node?: string;201213 };@@ -219,6 +231,11 @@ export function parseSyncTicket(text: string): SyncTicket | null {
219231 if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {220232 return null;221233 }234+ if (235+ parsed.scope !== undefined && parsed.scope !== "sync" && parsed.scope !== "provision"236+ ) {237+ return null;238+ }222239 const url = new URL(parsed.url);223240 if (!/^https?:$/.test(url.protocol) || !TICKET_PATH.test(url.pathname)) return null;224241 return parsed;@@ -241,6 +258,7 @@ export function declareSinkFromTicket(text: string): DataSinkDeclaration {
241258 return declareDataSink({242259 appId: ticket.appId,243260 serverUrl: ticket.url,261+ ...(ticket.scope ? { scope: ticket.scope } : {}),244262 ...(ticket.label ? { label: ticket.label } : {}),245263 ...(ticket.node ? { node: ticket.node } : {}),246264 });@@ -139,6 +139,33 @@ test(
139139 }),140140 );141141 142+// The machine-readable format contract shared with lofi-node143+// (docs/fixtures/app-ticket-fixtures.json there; vendored copy here). Either144+// side changing the format updates the fixtures, and this test is what fails145+// on drift.146+test("the vendored lofi-node ticket fixtures parse exactly as the contract expects", async () => {147+ const fixtures = JSON.parse(148+ await Deno.readTextFile(149+ new URL("../testdata/app-ticket-fixtures.json", import.meta.url),150+ ),151+ ) as {152+ valid: { name: string; ticket: string; expect: Record<string, string> }[];153+ invalid: { name: string; ticket: string }[];154+ };155+ for (const { name, ticket, expect } of fixtures.valid) {156+ const parsed = parseSyncTicket(ticket);157+ assert(parsed !== null, `valid fixture rejected: ${name}`);158+ assert(159+ parsed.appId === expect.appId && parsed.url === expect.url &&160+ (parsed.scope ?? "sync") === expect.scope,161+ `fixture "${name}" parsed as ${JSON.stringify(parsed)}, expected ${JSON.stringify(expect)}`,162+ );163+ }164+ for (const { name, ticket } of fixtures.invalid) {165+ assert(parseSyncTicket(ticket) === null, `invalid fixture accepted: ${name}`);166+ }167+});168+142169 test("ticket parsing accepts the lofi-node format and rejects everything else", () => {143170 const parsed = parseSyncTicket(` ${validTicket} `);144171 assert(@@ -146,6 +173,15 @@ test("ticket parsing accepts the lofi-node format and rejects everything else",
146173 parsed.url.endsWith(`/t/${ticketSecret}`),147174 `valid ticket parsed as ${JSON.stringify(parsed)}`,148175 );176+ const provision = parseSyncTicket(177+ encodeTicket({178+ v: 1,179+ appId: ticketAppId,180+ url: `http://h/t/${ticketSecret}`,181+ scope: "provision",182+ }),183+ );184+ assert(provision?.scope === "provision", "provision scope must parse");149185 const rejected = [150186 "lofisync2.abc",151187 "endpointAAAA",@@ -154,6 +190,7 @@ test("ticket parsing accepts the lofi-node format and rejects everything else",
154190 encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/t/short" }),155191 encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/apps/x" }),156192 encodeTicket({ v: 1, url: `http://h/t/${ticketSecret}` }),193+ encodeTicket({ v: 1, appId: ticketAppId, url: `http://h/t/${ticketSecret}`, scope: "root" }),157194 "lofisync1.%%%not-base64%%%",158195 ];159196 for (const ticket of rejected) {@@ -171,6 +208,7 @@ test(
171208 );172209 const sink = readDeclaredSink();173210 assert(sink?.appId === ticketAppId && sink.label === "phone", "ticket fields must carry over");211+ assert(sink.scope === undefined, "a scopeless ticket declares no explicit scope");174212 try {175213 declareSinkFromTicket("lofisync1.nope");176214 throw new Error("a malformed ticket must not enroll");@@ -0,0 +1,45 @@
1+{2+ "comment": "Cross-repo contract fixtures for the lofisync1 app-ticket format. The lofi side's parser must accept `valid` entries with the listed expectations and reject every `invalid` entry. Secrets here are fixed placeholders, never issued by any node.",3+ "valid": [4+ {5+ "name": "sync ticket, no scope field (default sync)",6+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMSIsInVybCI6Imh0dHA6Ly8xOTIuMTY4LjEuMTA6NDgwMi90L1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1MiLCJsYWJlbCI6InBob25lIn0",7+ "expect": {8+ "appId": "11111111-1111-4111-8111-111111111111",9+ "scope": "sync",10+ "url": "http://192.168.1.10:4802/t/SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS"11+ }12+ },13+ {14+ "name": "provision ticket with node field",15+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInVybCI6Imh0dHBzOi8vbm9kZS5leGFtcGxlLmNvbS90L1BQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFAiLCJzY29wZSI6InByb3Zpc2lvbiIsImxhYmVsIjoibGFwdG9wLWFkbWluIiwibm9kZSI6ImVuZHBvaW50ZXhhbXBsZWV4YW1wbGVleGFtcGxlZXhhbXBsZWV4YW1wbGVleGFtcGxlIn0",16+ "expect": {17+ "appId": "22222222-2222-4222-8222-222222222222",18+ "scope": "provision",19+ "url": "https://node.example.com/t/PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP"20+ }21+ }22+ ],23+ "invalid": [24+ {25+ "name": "wrong prefix",26+ "ticket": "endpointabcdefabcdefabcdefabcdefabcdef"27+ },28+ {29+ "name": "empty payload",30+ "ticket": "lofisync1."31+ },32+ {33+ "name": "ws url",34+ "ticket": "lofisync1.eyJ2IjoxLCcorrupted"35+ },36+ {37+ "name": "bad secret path",38+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IngiLCJ1cmwiOiJodHRwOi8vaC93cm9uZyJ9"39+ },40+ {41+ "name": "unknown scope",42+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IngiLCJ1cmwiOiJodHRwOi8vaC90L1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1MiLCJzY29wZSI6InJvb3QifQ=="43+ }44+ ]45+}