From 1a38c140ad048f34d9e31526a61767bf388ace1d Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Thu, 17 Sep 2026 12:39:19 -0700 Subject: [PATCH] Accept environment-qualified resource IDs throughout CLI workflows --- assets/playlist-write.schema.json | 2 +- scripts/sync-contract-snapshots.mjs | 4 ++++ src/agent-identity.test.ts | 10 ++++----- src/agent-identity.ts | 5 +++-- src/cli.test.ts | 27 ++++++++++++++++++++++++ src/commands.ts | 19 +++++++++-------- src/generated/resource-ids.ts | 18 ++++++++++++++++ src/media-upload-batch.ts | 3 ++- src/playlist-bundle.ts | 9 ++++---- src/playlist-workflows.test.ts | 32 ++++++++++++++--------------- src/playlist-write-validation.ts | 3 ++- src/resource-ids.test.ts | 21 +++++++++++++++++++ src/screen-publish.ts | 17 +++++++-------- vendor/manifest.json | 16 ++++++++++----- vendor/openapi.yaml | 16 +++++++-------- vendor/protocol/openapi.gen.ts | 6 +++--- 16 files changed, 145 insertions(+), 63 deletions(-) create mode 100644 src/generated/resource-ids.ts create mode 100644 src/resource-ids.test.ts diff --git a/assets/playlist-write.schema.json b/assets/playlist-write.schema.json index 90057414..db111c6d 100644 --- a/assets/playlist-write.schema.json +++ b/assets/playlist-write.schema.json @@ -1,5 +1,5 @@ { - "$comment": "Generated by screenrig-protocol-gen from api/openapi.yaml; sha256=0b3d50daa0155690822dedb58db295982e3b33e84389f22ffa463093b48b1e60. Shape validation only; run canonical playlist-write-semantics.ts next. Reference resolution and DNS remain server checks.", + "$comment": "Generated by screenrig-protocol-gen from api/openapi.yaml; sha256=771381b39950f56df6b6f220d4787ccac26271ca56cdb9d9e9a164ad97515a1c. Shape validation only; run canonical playlist-write-semantics.ts next. Reference resolution and DNS remain server checks.", "$defs": { "CanvasBackground": { "description": "Solid canonical uppercase #RRGGBBAA, or a top-to-bottom linear gradient.", diff --git a/scripts/sync-contract-snapshots.mjs b/scripts/sync-contract-snapshots.mjs index 6c3af0a7..69504e32 100644 --- a/scripts/sync-contract-snapshots.mjs +++ b/scripts/sync-contract-snapshots.mjs @@ -31,6 +31,10 @@ const sourceRoot = sourceRootGiven : (siblingRoot ?? path.resolve(cliRoot, '../..')); const snapshots = [ + { + path: 'src/generated/resource-ids.ts', + source: 'packages/protocol/src/resource-ids.ts', + }, { path: 'assets/playlist-write.schema.json', source: 'packages/protocol/schema/playlist-write.schema.json', diff --git a/src/agent-identity.test.ts b/src/agent-identity.test.ts index 3e3f4ae4..d9cc4874 100644 --- a/src/agent-identity.test.ts +++ b/src/agent-identity.test.ts @@ -51,24 +51,24 @@ function sealForTest(recipient: X25519PublicJWK, connectionId: string, agentId: }; } -test("agent credential envelope decrypts only for the persisted recipient key and exact binding", () => { +for (const prefix of ["", "development_", "qa_", "stage_"]) test(`agent credential envelope ${prefix || "production"} preserves exact ID binding`, () => { const privateJwk = generateAgentConnectionKey(); const connection = { private_jwk: privateJwk, - connection_id: "acn_AAAAAAAAAAAAAAAAAAAAAAAA", + connection_id: `${prefix}acn_AAAAAAAAAAAAAAAAAAAAAAAA`, }; const token = `sr_live_test_${"T".repeat(43)}`; - const collection = sealForTest(publicAgentConnectionKey(privateJwk), connection.connection_id, "agt_AAAAAAAAAAAAAAAAAAAAAAAA", token); + const collection = sealForTest(publicAgentConnectionKey(privateJwk), connection.connection_id, `${prefix}agt_AAAAAAAAAAAAAAAAAAAAAAAA`, token); assert.deepEqual(decryptAgentCredential(collection, connection), { token, - agentId: "agt_AAAAAAAAAAAAAAAAAAAAAAAA", + agentId: `${prefix}agt_AAAAAAAAAAAAAAAAAAAAAAAA`, }); assert.throws( () => decryptAgentCredential(collection, { ...connection, private_jwk: generateAgentConnectionKey() }), /could not be authenticated/, ); assert.throws( - () => decryptAgentCredential(collection, { ...connection, connection_id: "acn_BBBBBBBBBBBBBBBBBBBBBBBB" }), + () => decryptAgentCredential(collection, { ...connection, connection_id: `${prefix}acn_BBBBBBBBBBBBBBBBBBBBBBBB` }), /could not be authenticated/, ); }); diff --git a/src/agent-identity.ts b/src/agent-identity.ts index eba1b014..5ef0f016 100644 --- a/src/agent-identity.ts +++ b/src/agent-identity.ts @@ -1,3 +1,4 @@ +import { RESOURCE_ID_PATTERNS } from "./generated/resource-ids.js"; import { createDecipheriv, createHash, @@ -19,8 +20,8 @@ import type { import type { ScreenRigConfig } from "./config.js"; import { configError, usageError } from "./problems.js"; -const CONNECTION_ID = /^acn_[A-Za-z0-9_-]+$/; -const AGENT_ID = /^agt_[A-Za-z0-9_-]+$/; +const CONNECTION_ID = RESOURCE_ID_PATTERNS.connection; +const AGENT_ID = RESOURCE_ID_PATTERNS.agent; const CONNECTION_TOKEN = /^sac_[A-Za-z0-9_-]{43}$/; const BASE64URL_32 = /^[A-Za-z0-9_-]{43}$/; const CONNECTION_FIELDS = new Set([ diff --git a/src/cli.test.ts b/src/cli.test.ts index fb90dae5..c78d19c1 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -5777,3 +5777,30 @@ for (const argv of [ assert.equal(transport.calls.filter(call => call.method === "GET").length, 0, "no revision lookup"); } finally { await rm(configDir, {recursive: true, force: true}); } }); + +for (const prefix of ["development_", "qa_", "stage_"]) test(`enrollment and pairing preserve ${prefix} resource IDs`, async () => { + const transport = memoryBackend(); + const request = transport.request.bind(transport); + transport.request = async (req) => { + const response = await request(req); + // Model Backend-issued identities without altering credentials or routing. + const qualify = (value: unknown): unknown => { + if (typeof value === "string" && /^(acc|agt|scr)_/.test(value)) return prefix + value; + if (Array.isArray(value)) return value.map(qualify); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, qualify(item)])); + return value; + }; + return { ...response, body: qualify(response.body) }; + }; + const configDir = await testTemp("environment-enrollment-"); + const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; + try { + const enrolled = await withRuntime(["agent", "enroll", "--email", "owner@example.com"], transport, { fs: fsLike }); + assert.equal(enrolled.code, 0, enrolled.stdout); + assert.equal(JSON.parse(enrolled.stdout).data.agent.id, prefix + TEST_AGENT.id); + const paired = await withRuntime(["screen", "pair", "abc234", "--label", "Lobby"], transport, { fs: fsLike }); + assert.equal(paired.code, 0, paired.stdout); + assert.ok(JSON.parse(paired.stdout).data.screen.id.startsWith(prefix + "scr_")); + assert.doesNotMatch(enrolled.stdout + paired.stdout, /sr_live_/); + } finally { await rm(configDir, { recursive: true, force: true }); } +}); diff --git a/src/commands.ts b/src/commands.ts index 51b1ec98..4c3c5f2f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,3 +1,4 @@ +import { RESOURCE_ID_PATTERNS, isResourceID } from "./generated/resource-ids.js"; import { replacePlaylistRelease } from "./playlist-release.js"; import { publishScreen } from "./screen-publish.js"; import { readAuthoringJson, readAuthoringText, writeAuthoringJson } from "./authoring-input.js"; @@ -1882,7 +1883,7 @@ function mediaGenerationFromBody(body: unknown): MediaGeneration { throw usageError("Media generation response does not match the MediaGeneration contract."); } const id = (media as { id?: unknown }).id; - if (typeof id !== "string" || !id.startsWith("med_")) { + if (typeof id !== "string" || !isResourceID(id, "media")) { throw usageError("Media generation response is missing a med_… media id."); } if (!usage || typeof usage !== "object" || Array.isArray(usage)) { @@ -2064,7 +2065,7 @@ async function mediaUpdate(args: ParsedArgs, client: ApiClient): Promise = { @@ -2207,10 +2208,10 @@ async function playbackList( const screenId = flagString(args.flags, "screen-id"); const mediaId = flagString(args.flags, "media-id"); const day = flagString(args.flags, "day"); - if (screenId !== undefined && !screenId.startsWith("scr_")) { - throw usageError("--screen-id must start with scr_."); + if (screenId !== undefined && !isResourceID(screenId, "screen")) { + throw usageError("--screen-id must be a screen identifier."); } - if (mediaId !== undefined && !mediaId.startsWith("med_")) { + if (mediaId !== undefined && !isResourceID(mediaId, "media")) { throw usageError("--media-id must start with med_."); } if (day !== undefined && !PLAYBACK_DAY_PATTERN.test(day)) { @@ -2408,7 +2409,7 @@ const TOAST_MAX_LINES = 3; const TOAST_DURATION_MIN = 2000; const TOAST_DURATION_MAX = 60000; -const SCREEN_ID_PATTERN = /^scr_[A-Za-z0-9_-]+$/; +const SCREEN_ID_PATTERN = RESOURCE_ID_PATTERNS.screen; const PLAYLIST_PAGE_ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; const SCREENSHOT_DEFAULT_WAIT_MS = 35_000; const SCREENSHOT_DEFAULT_POLL_MS = 500; @@ -2616,7 +2617,7 @@ async function playlistPreviewCommand( fromFile = false; } if (!fromFile) { - if (!/^pl_[A-Za-z0-9_-]+$/.test(target)) { + if (!RESOURCE_ID_PATTERNS.playlist.test(target)) { throw usageError("Cannot read playlist JSON."); } const token = requireToken(resolved.token); @@ -4289,11 +4290,11 @@ export const handlePlaylistInit = commandHandler(async (args, runtime, resolved) const files = new Map(); const warnings: Warning[] = []; for (const input of args.positionals.slice(2)) { - if (/^med_[A-Za-z0-9_-]+$/.test(input)) { + if (RESOURCE_ID_PATTERNS.media.test(input)) { const record = (await remote().call({ method: "GET", path: `/api/v1/media/${input}` })).body as Record; if (record?.id !== input) throw usageError("Media response identity did not match."); content.push(record); - } else if (/^rel_[A-Za-z0-9_-]+$/.test(input)) { + } else if (RESOURCE_ID_PATTERNS.release.test(input)) { content.push({ primitive: "application", release_id: input }); } else if (/^[a-z][a-z0-9+.-]*:/i.test(input)) { let url: URL; diff --git a/src/generated/resource-ids.ts b/src/generated/resource-ids.ts new file mode 100644 index 00000000..2c644b26 --- /dev/null +++ b/src/generated/resource-ids.ts @@ -0,0 +1,18 @@ +/** Backend-owned resource ID recognition. Preserve values verbatim; markers are not authority. + * Random IDs may carry an environment marker. Content-derived media IDs do not. + * Legacy URL-safe suffixes remain accepted alongside current random suffixes. + */ +const environment = "(?:(?:stage|qa|development)_)?"; +export const RESOURCE_ID_PATTERNS = { + account: new RegExp(`^${environment}acc_[A-Za-z0-9_-]+$`), + agent: new RegExp(`^${environment}agt_[A-Za-z0-9_-]+$`), + connection: new RegExp(`^${environment}acn_[A-Za-z0-9_-]+$`), + screen: new RegExp(`^${environment}scr_[A-Za-z0-9_-]+$`), + playlist: new RegExp(`^${environment}pl_[A-Za-z0-9_-]+$`), + release: new RegExp(`^${environment}rel_[A-Za-z0-9_-]+$`), + media: /^med_[A-Za-z0-9_-]+$/, +}; +export type ResourceIDKind = keyof typeof RESOURCE_ID_PATTERNS; +export function isResourceID(value: unknown, kind: ResourceIDKind): value is string { + return typeof value === "string" && RESOURCE_ID_PATTERNS[kind].test(value); +} diff --git a/src/media-upload-batch.ts b/src/media-upload-batch.ts index fcb9e4ef..6aab7362 100644 --- a/src/media-upload-batch.ts +++ b/src/media-upload-batch.ts @@ -1,3 +1,4 @@ +import { isResourceID } from "./generated/resource-ids.js"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -489,7 +490,7 @@ function mediaMatchFromList(body: unknown, sha256: string): { mediaId: string; r } for (const item of rec.items) { const row = asRecord(item); - if (!row || row.sha256 !== sha256 || typeof row.id !== "string" || !row.id.startsWith("med_")) { + if (!row || row.sha256 !== sha256 || typeof row.id !== "string" || !isResourceID(row.id, "media")) { continue; } const revision = typeof row.revision === "number" && Number.isInteger(row.revision) ? row.revision : undefined; diff --git a/src/playlist-bundle.ts b/src/playlist-bundle.ts index 236530e7..63c1a232 100644 --- a/src/playlist-bundle.ts +++ b/src/playlist-bundle.ts @@ -1,3 +1,4 @@ +import { RESOURCE_ID_PATTERNS, isResourceID } from "./generated/resource-ids.js"; import { createHash } from "node:crypto"; import { constants } from "node:fs"; import type { FileHandle } from "node:fs/promises"; @@ -26,7 +27,7 @@ const MEDIA_UPLOAD_ADMISSION_QUOTA = 20; const MEDIA_UPLOAD_ADMISSION_WINDOW_MS = 60_000; const MEDIA_UPLOAD_RATE_LIMIT_RETRIES = 1; const SHA256_PATTERN = /^[a-f0-9]{64}$/; -const MEDIA_ID_PATTERN = /^med_[A-Za-z0-9_-]+$/; +const MEDIA_ID_PATTERN = RESOURCE_ID_PATTERNS.media; const CONTROL_PATTERN = /[\u0000-\u001F\u007F]/u; const EXTENSION_BY_TYPE: Readonly> = { @@ -276,7 +277,7 @@ function parseManifest(input: unknown): PlaylistBundleManifest { const playlist = record(root.playlist, `${PLAYLIST_BUNDLE_MANIFEST}.playlist`); exactKeys(playlist, ["source_id", "source_revision", "path"], `${PLAYLIST_BUNDLE_MANIFEST}.playlist`); const sourceId = stringField(playlist, "source_id", `${PLAYLIST_BUNDLE_MANIFEST}.playlist`); - if (!sourceId.startsWith("pl_")) throw usageError("Bundle source playlist id must start with pl_."); + if (!isResourceID(sourceId, "playlist")) throw usageError("Bundle source playlist id must be a playlist identifier."); const sourceRevision = integerField(playlist, "source_revision", `${PLAYLIST_BUNDLE_MANIFEST}.playlist`); if (sourceRevision < 1) throw usageError("Bundle source playlist revision must be positive."); if (playlist.path !== PLAYLIST_BUNDLE_PLAYLIST) { @@ -560,7 +561,7 @@ export async function exportPlaylistBundle(options: { outputDirectory: string; client: ApiClient; }): Promise { - if (!options.playlistId.startsWith("pl_")) throw usageError("playlist export requires a playlist id starting with pl_."); + if (!isResourceID(options.playlistId, "playlist")) throw usageError("playlist export requires a playlist identifier."); const destination = path.resolve(options.outputDirectory); await assertNoSymlinkAncestors(destination, true); await destinationAbsent(destination); @@ -847,7 +848,7 @@ export async function importPlaylistBundle(options: { if (trimmed.length === 0 || trimmed.length > 120) throw usageError("playlist import --name must be 1 to 120 characters."); } if (!options.updateId && options.ifMatch) throw usageError("playlist import --expect-rev requires --update PLAYLIST_ID."); - if (options.updateId && !options.updateId.startsWith("pl_")) throw usageError("playlist import --update requires a playlist id starting with pl_."); + if (options.updateId && !isResourceID(options.updateId, "playlist")) throw usageError("playlist import --update requires a playlist identifier."); const ifMatch = options.ifMatch ? quotedRevision(options.ifMatch) : undefined; // This completes every local structure, path, type, size, and digest check before a network mutation. diff --git a/src/playlist-workflows.test.ts b/src/playlist-workflows.test.ts index 5654b9d9..17de3820 100644 --- a/src/playlist-workflows.test.ts +++ b/src/playlist-workflows.test.ts @@ -84,27 +84,27 @@ test("stdin validation and editable output need no jq or metadata stripping", as assert.throws(()=>parseArgv(['media','generate','--prompt','a','--prompt-file','b'])); }); -for (const revision of [undefined, '7']) for (const failure of ['create','assign','verify']) test(`publish revision ${revision} resumes an ambiguous ${failure} without duplicating resources`, async t => { +for (const prefix of ["", "development_", "qa_", "stage_"]) for (const revision of [undefined, '7']) for (const failure of ['create','assign','verify']) test(`publish ${prefix || "production"} revision ${revision} resumes an ambiguous ${failure} without duplicating resources`, async t => { const dir=await mkdtemp('/tmp/publish-workflows-'); t.after(()=>rm(dir,{recursive:true,force:true})); let created=false, assigned=false, failed=false, reads=0; const createKeys:string[]=[],assignKeys:string[]=[]; const transport=new FakeTransport() - .on('GET','/api/v1/account',()=>response({id:'acc_TEST'})) - .on('GET','/api/v1/screens/scr_TEST',()=>{ + .on('GET','/api/v1/account',()=>response({id:`${prefix}acc_TEST`})) + .on('GET',`/api/v1/screens/${prefix}scr_TEST`,()=>{ reads++; if(failure==='verify'&&assigned&&!failed){failed=true;throw networkError('Test connection failure');} - return response({id:'scr_TEST',revision:assigned?8:7,playlist_id:assigned?'pl_TEST':undefined}); + return response({id:`${prefix}scr_TEST`,revision:assigned?8:7,playlist_id:assigned?`${prefix}pl_TEST`:undefined}); }) .on('POST','/api/v1/playlists',req=>{ createKeys.push(req.headers!['idempotency-key']!); created=true; if(failure==='create'&&!failed){failed=true;throw networkError('Test connection failure');} - return response({id:'pl_TEST',revision:1}); + return response({id:`${prefix}pl_TEST`,revision:1}); }) - .on('PATCH','/api/v1/screens/scr_TEST',req=>{ + .on('PATCH',`/api/v1/screens/${prefix}scr_TEST`,req=>{ assert.equal(req.headers!['if-match'],revision ? '"7"' : undefined);assignKeys.push(req.headers!['idempotency-key']!);assigned=true; if(failure==='assign'&&!failed){failed=true;throw networkError('Test connection failure');} - return response({id:'scr_TEST',revision:8,playlist_id:'pl_TEST'}); + return response({id:`${prefix}scr_TEST`,revision:8,playlist_id:`${prefix}pl_TEST`}); }); - const options={client:new ApiClient({transport,token:'test-token'}),runtime:processRuntime(),configPath:dir+'/config.json',apiUrl:'https://api.screenrig.ai',screenId:'scr_TEST',revision,document:document()}; + const options={client:new ApiClient({transport,token:'test-token'}),runtime:processRuntime(),configPath:dir+'/config.json',apiUrl:'https://api.screenrig.ai',screenId:`${prefix}scr_TEST`,revision,document:document()}; await assert.rejects(()=>publishScreen(options)); const result=await publishScreen(options); assert.equal(result.assignment_verified,true); assert.equal(result.playback_verified,false); @@ -192,26 +192,26 @@ test("publish recovery arguments preserve paths with spaces without exposing URL }); }); -test("mixed preparation carries target revision without publishing and guards uploads", async t => { +for (const prefix of ["", "development_", "qa_", "stage_"]) test(`mixed preparation ${prefix || "production"} carries IDs unchanged`, async t => { const dir = await mkdtemp('/tmp/prepare mixed-'); t.after(() => rm(dir, {recursive:true,force:true})); const config = dir + '/config.json'; await writeFile(config, JSON.stringify({api_url:'https://api.screenrig.ai',token:'test-token'}), {mode:0o600}); - let mediaState = "ready", targetId = "scr_TEST"; + let mediaState = "ready", targetId = `${prefix}scr_TEST`; const transport = new FakeTransport() - .on('GET','/api/v1/screens/scr_TEST',()=>response({id:targetId,revision:7,observation:{surfaces:[{width:1080,height:1920}]}})) + .on('GET',`/api/v1/screens/${prefix}scr_TEST`,()=>response({id:targetId,revision:7,observation:{surfaces:[{width:1080,height:1920}]}})) .on('GET','/api/v1/media/med_IMAGE',()=>response({...media[0],state:mediaState})); async function invoke(inputs:string[], output='prepared.json') { - let out=''; const code = await run({...processRuntime(),argv:['--config',config,'playlist','init',...inputs,'--name','Lobby','--screen','scr_TEST','--output',output],env:{},cwd:()=>dir,transport, + let out=''; const code = await run({...processRuntime(),argv:['--config',config,'playlist','init',...inputs,'--name','Lobby','--screen',`${prefix}scr_TEST`,'--output',output],env:{},cwd:()=>dir,transport, stdout:new Writable({write(c,e,d){out+=c;d();}}),stderr:new Writable({write(c,e,d){d();}})}); return {code,body:JSON.parse(out)}; } - const result = await invoke(['med_IMAGE','rel_TEST','https://example.com/board']); + const result = await invoke(['med_IMAGE',`${prefix}rel_TEST`,'https://example.com/board']); assert.equal(result.code,0,JSON.stringify(result.body)); - assert.equal(result.body.data.screen_id,'scr_TEST'); assert.equal(result.body.data.screen_revision,7); + assert.equal(result.body.data.screen_id,`${prefix}scr_TEST`); assert.equal(result.body.data.screen_revision,7); const doc = JSON.parse(await readFile(dir+'/prepared.json','utf8')); assert.deepEqual(Object.keys(doc).sort(),['name','pages']); assert.deepEqual(doc.pages.map((p:any)=>p.primitives[0].primitive),['image','application','iframe']); - assert.equal(doc.pages[1].primitives[0].release_id,'rel_TEST'); + assert.equal(doc.pages[1].primitives[0].release_id,`${prefix}rel_TEST`); assert.equal(doc.pages[1].primitives[0].content_fit,'fill'); assert.deepEqual(doc.pages[1].advance,{mode:'duration',after_ms:8000}); assert.equal(doc.pages[2].primitives[0].src,'https://example.com/board'); @@ -224,7 +224,7 @@ test("mixed preparation carries target revision without publishing and guards up mediaState = 'processing'; assert.equal((await invoke(['med_IMAGE'],'not-ready.json')).code,2); targetId = 'scr_OTHER'; - assert.equal((await invoke(['rel_TEST'],'wrong-target.json')).code,2); + assert.equal((await invoke([`${prefix}rel_TEST`],'wrong-target.json')).code,2); assert.equal(transport.calls.some(c=>c.method!=='GET'),false); }); diff --git a/src/playlist-write-validation.ts b/src/playlist-write-validation.ts index 1d8763ee..b47752d3 100644 --- a/src/playlist-write-validation.ts +++ b/src/playlist-write-validation.ts @@ -1,10 +1,11 @@ +import { RESOURCE_ID_PATTERNS } from "./generated/resource-ids.js"; import { isIP } from "node:net"; import { usageError } from "./problems.js"; interface JsonRecord { [key: string]: unknown } const ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; -const MEDIA_ID_PATTERN = /^med_[A-Za-z0-9_-]+$/; +const MEDIA_ID_PATTERN = RESOURCE_ID_PATTERNS.media; const COLOR_PATTERN = /^#[0-9A-F]{8}$/; const CIVIL_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):([0-5]\d)$/; const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; diff --git a/src/resource-ids.test.ts b/src/resource-ids.test.ts new file mode 100644 index 00000000..9dff1a9d --- /dev/null +++ b/src/resource-ids.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { isResourceID, type ResourceIDKind } from "./generated/resource-ids.js"; + +for (const [kind, type] of Object.entries({account:"acc",agent:"agt",connection:"acn",screen:"scr",playlist:"pl",release:"rel"})) { + test(`${kind} supports retained and environment-qualified IDs without accepting other resources`, () => { + for (const prefix of ["", "development_", "qa_", "stage_"]) { + for (const suffix of ["abcdefghijkmnpqr", "Legacy_ABC-123"]) { + assert.ok(isResourceID(`${prefix}${type}_${suffix}`, kind as ResourceIDKind)); + } + assert.equal(isResourceID(`${prefix}med_TEST`, kind as ResourceIDKind), false); + } + for (const id of [null, {}, `${type}_`, `unknown_${type}_TEST`, `development_qa_${type}_TEST`, `${type}_a/b`, `${type}_a?x`, `${type}_a\n`]) { + assert.equal(isResourceID(id, kind as ResourceIDKind), false); + } + }); +} +test("content-derived media identifiers do not acquire environment markers", () => { + assert.ok(isResourceID("med_" + "a".repeat(64), "media")); + for (const prefix of ["development_", "qa_", "stage_"]) assert.equal(isResourceID(prefix + "med_TEST", "media"), false); +}); diff --git a/src/screen-publish.ts b/src/screen-publish.ts index 0ee4484b..b40f6922 100644 --- a/src/screen-publish.ts +++ b/src/screen-publish.ts @@ -1,3 +1,4 @@ +import { RESOURCE_ID_PATTERNS, isResourceID, type ResourceIDKind } from "./generated/resource-ids.js"; import { createHash, randomUUID } from "node:crypto"; import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -10,9 +11,9 @@ import type { CliRuntime } from "./runtime.js"; type Resource = { id: string; revision: number; playlist_id?: string; timezone?: string }; interface Journal { version: 1; created_at: number; fingerprint: string; create_key: string; assign_key: string; playlist?: Resource; assigned?: boolean } const digest = (value: string) => createHash("sha256").update(value).digest("hex"); -function resource(value: unknown, prefix: string): Resource { +function resource(value: unknown, kind: ResourceIDKind): Resource { const item = value as Resource; - if (!item || typeof item.id !== "string" || !item.id.startsWith(prefix) || !Number.isSafeInteger(item.revision) || item.revision < 1) throw usageError("Server response has invalid resource identity or revision."); + if (!item || typeof item.id !== "string" || !isResourceID(item.id, kind) || !Number.isSafeInteger(item.revision) || item.revision < 1) throw usageError("Server response has invalid resource identity or revision."); return item; } function conflict(detail: string, revision?: number): never { @@ -27,9 +28,9 @@ export async function publishScreen(options: { const { client, screenId, document } = options; const expected = options.revision === undefined ? undefined : Number(options.revision.replaceAll('"', '')); if (options.revision !== undefined) quotedRevision(options.revision); - if (!/^scr_[A-Za-z0-9_-]+$/.test(screenId)) throw usageError("screen publish requires a screen identifier."); + if (!RESOURCE_ID_PATTERNS.screen.test(screenId)) throw usageError("screen publish requires a screen identifier."); const account = (await client.call({ method: "GET", path: "/api/v1/account" })).body as { id: string }; - if (!account?.id?.startsWith("acc_")) throw usageError("Account identity is missing."); + if (!isResourceID(account?.id, "account")) throw usageError("Account identity is missing."); const fingerprint = digest(JSON.stringify([options.apiUrl, account.id, screenId, expected, document, options.requestedKey ?? ""])); const directory = path.join(path.dirname(options.configPath), "publishes"); await mkdir(directory, { recursive: true, mode: 0o700 }); @@ -69,10 +70,10 @@ export async function publishScreen(options: { if ((info.mode & 0o077) !== 0) throw usageError("Publish journal must be private."); state = JSON.parse(await readFile(journalPath, "utf8")); if (state?.version !== 1 || state.fingerprint !== fingerprint || !Number.isFinite(state.created_at) || !state.create_key || !state.assign_key) throw usageError("Publish journal is invalid."); - if (state.playlist) resource(state.playlist, "pl_"); + if (state.playlist) resource(state.playlist, "playlist"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } if (!state) { - const screen = resource((await client.call({ method: "GET", path: `/api/v1/screens/${screenId}` })).body, "scr_"); + const screen = resource((await client.call({ method: "GET", path: `/api/v1/screens/${screenId}` })).body, "screen"); if (screen.id !== screenId) throw usageError("Screen response identity did not match."); if (expected !== undefined && screen.revision !== expected) conflict("Screen changed before publishing. Inspect it and retry with its intended revision.", screen.revision); if (document.pages.some((page: any) => page.visibility !== undefined) && !screen.timezone) throw usageError("Set the screen timezone before publishing a scheduled playlist."); @@ -84,7 +85,7 @@ export async function publishScreen(options: { } if (!state.playlist) { const response = await client.call({ method: "POST", path: "/api/v1/playlists", body: document, idempotent: true, idempotencyKey: state.create_key }); - state.playlist = resource(response.body, "pl_"); + state.playlist = resource(response.body, "playlist"); // Persist identity only, never the returned playlist or resolved media. state.playlist = { id: state.playlist.id, revision: state.playlist.revision }; await save(); @@ -94,7 +95,7 @@ export async function publishScreen(options: { state.assigned = true; await save(); } - const screen = resource((await client.call({ method: "GET", path: `/api/v1/screens/${screenId}` })).body, "scr_"); + const screen = resource((await client.call({ method: "GET", path: `/api/v1/screens/${screenId}` })).body, "screen"); if (screen.id !== screenId || screen.playlist_id !== state.playlist.id) conflict("The screen no longer has this playlist assigned. Inspect it before making another change.", screen.revision); return { playlist_id: state.playlist.id, playlist_revision: state.playlist.revision, screen_id: screenId, screen_revision: screen.revision, assignment_verified: true, playback_verified: false, journal: journalPath }; } catch (error) { diff --git a/vendor/manifest.json b/vendor/manifest.json index 57e22884..c02f8710 100644 --- a/vendor/manifest.json +++ b/vendor/manifest.json @@ -2,11 +2,17 @@ "schema": "screenrig.vendor-snapshot/v1", "purpose": "cli-contract-and-sdk-inputs", "files": [ + { + "path": "src/generated/resource-ids.ts", + "source": "packages/protocol/src/resource-ids.ts", + "bytes": 984, + "sha256": "91e6b893e6a8ecfe7c3f550465449e041b54e49c3b86427a7cb7fb65aa15fd1f" + }, { "path": "assets/playlist-write.schema.json", "source": "packages/protocol/schema/playlist-write.schema.json", "bytes": 25438, - "sha256": "dd59548e7649453d8ef5805e48b8d16086f74bc8958d5004e395cbb703515741" + "sha256": "2906314caa4e059a1b4f7b24dc5424542d6df0bffb29015d35d6b4a60c13422a" }, { "path": "src/generated/playlist-write-semantics.ts", @@ -17,14 +23,14 @@ { "path": "vendor/protocol/openapi.gen.ts", "source": "packages/protocol/src/openapi.gen.ts", - "bytes": 43520, - "sha256": "dc708b021e2fc349ad3d8c06f84d7a8bde2aacdc25285adc4444bf07695d2e22" + "bytes": 43550, + "sha256": "7b45e5c10657554135cdd6ca84a8c95f02e6ffea0acc0f06009a6b6d4b9b063a" }, { "path": "vendor/openapi.yaml", "source": "api/openapi.yaml", - "bytes": 324096, - "sha256": "0b3d50daa0155690822dedb58db295982e3b33e84389f22ffa463093b48b1e60" + "bytes": 324282, + "sha256": "771381b39950f56df6b6f220d4787ccac26271ca56cdb9d9e9a164ad97515a1c" }, { "path": "assets/screenrig.runtime.js", diff --git a/vendor/openapi.yaml b/vendor/openapi.yaml index b0872d79..d4f1977a 100644 --- a/vendor/openapi.yaml +++ b/vendor/openapi.yaml @@ -1456,7 +1456,7 @@ paths: servers: [{ url: https://play.screenrig.ai, description: Trusted player/runtime origin }] parameters: - { name: manifest_revision, in: path, required: true, schema: { type: string, pattern: "^man_" } } - - { name: release_id, in: path, required: true, schema: { type: string, pattern: "^rel_" } } + - { name: release_id, in: path, required: true, schema: { type: string, pattern: "^(?:(?:stage|qa|development)_)?rel_" } } - { $ref: "#/components/parameters/Range" } - { $ref: "#/components/parameters/SecFetchSite" } - { $ref: "#/components/parameters/SecFetchMode" } @@ -2614,7 +2614,7 @@ components: additionalProperties: false required: [id, name, agent_type, state, authenticated_requests, metered_credits, created_at] properties: - id: { type: string, pattern: "^agt_" } + id: { type: string, pattern: "^(?:(?:stage|qa|development)_)?agt_" } name: { type: string, minLength: 1, maxLength: 80 } agent_type: { type: string, pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$" } platform: { type: string, maxLength: 80 } @@ -2631,8 +2631,8 @@ components: AgentSelfStatus: { type: object, additionalProperties: false, required: [agent, connection_ready], properties: { agent: { $ref: "#/components/schemas/Agent" }, connection_ready: { type: boolean, description: True when the existing account has either a persisted WebAuthn credential or an Argon2id password credential owned by a dashboard user and can authorize another agent. } } } X25519PublicJWK: { type: object, additionalProperties: false, required: [kty, crv, x], properties: { kty: { type: string, enum: [OKP] }, crv: { type: string, enum: [X25519] }, x: { type: string, pattern: "^[A-Za-z0-9_-]{43}$" } } } AgentConnectionRequest: { type: object, additionalProperties: false, required: [recipient_public_key], properties: { name: { type: string, minLength: 1, maxLength: 80 }, agent_type: { type: string, pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$" }, platform: { type: string, maxLength: 80 }, version: { type: string, maxLength: 40 }, recipient_public_key: { $ref: "#/components/schemas/X25519PublicJWK" } } } - AgentConnectionStart: { type: object, additionalProperties: false, required: [connection_id, connection_token, approval_url, expires_at], properties: { connection_id: { type: string, pattern: "^acn_" }, connection_token: { type: string, pattern: "^sac_[A-Za-z0-9_-]{43}$", description: Temporary header credential. Never place in a URL or log. }, approval_url: { type: string, format: uri, description: Safe URL containing only the non-authority connection id. }, expires_at: { type: string, format: date-time } } } - AgentConnection: { type: object, additionalProperties: false, required: [connection_id, name, agent_type, status, expires_at, created_at], properties: { connection_id: { type: string, pattern: "^acn_" }, name: { type: string }, agent_type: { type: string }, platform: { type: string }, version: { type: string }, status: { type: string, enum: [pending, approved, connected, denied, expired, cancelled] }, expires_at: { type: string, format: date-time }, created_at: { type: string, format: date-time } } } + AgentConnectionStart: { type: object, additionalProperties: false, required: [connection_id, connection_token, approval_url, expires_at], properties: { connection_id: { type: string, pattern: "^(?:(?:stage|qa|development)_)?acn_" }, connection_token: { type: string, pattern: "^sac_[A-Za-z0-9_-]{43}$", description: Temporary header credential. Never place in a URL or log. }, approval_url: { type: string, format: uri, description: Safe URL containing only the non-authority connection id. }, expires_at: { type: string, format: date-time } } } + AgentConnection: { type: object, additionalProperties: false, required: [connection_id, name, agent_type, status, expires_at, created_at], properties: { connection_id: { type: string, pattern: "^(?:(?:stage|qa|development)_)?acn_" }, name: { type: string }, agent_type: { type: string }, platform: { type: string }, version: { type: string }, status: { type: string, enum: [pending, approved, connected, denied, expired, cancelled] }, expires_at: { type: string, format: date-time }, created_at: { type: string, format: date-time } } } AgentCredentialEnvelope: { type: object, additionalProperties: false, required: [algorithm, ephemeral_public_key, nonce, ciphertext], properties: { algorithm: { type: string, enum: [X25519-HKDF-SHA256-A256GCM] }, ephemeral_public_key: { $ref: "#/components/schemas/X25519PublicJWK" }, nonce: { type: string, description: Unpadded base64url AES-GCM nonce. }, ciphertext: { type: string, description: Unpadded base64url ciphertext and authentication tag. Decrypt with AAD screenrig/agent-credential-envelope/aad/v1 NUL connection_id NUL agent_id. } } } AgentCredentialCollection: { type: object, additionalProperties: false, required: [agent, credential_envelope, issuance_expires_at], properties: { agent: { $ref: "#/components/schemas/Agent" }, credential_envelope: { $ref: "#/components/schemas/AgentCredentialEnvelope" }, issuance_expires_at: { type: string, format: date-time } } } AgentDisconnectRequest: { type: object, additionalProperties: false, properties: { allow_last_agent: { type: boolean, default: false, description: Explicitly accept loss of the last active agent bearer. A registered dashboard passkey remains independent administrative authority. } } } @@ -2974,7 +2974,7 @@ components: to any account other than the owner's. Read-only on the account API; ScreenPatch cannot write it. properties: - platform: { type: string, enum: [tizen, android, windows, qt, apple, chromeos, browser] } + platform: { type: string, enum: [tizen, android, windows, qt, apple, chromeos, browser, brightsign] } host_version: { type: string, minLength: 1, maxLength: 40, description: "Shell or container product version, CalVer or vendor form." } device: { $ref: "#/components/schemas/HostDevice" } capabilities: { type: array, maxItems: 32, uniqueItems: true, items: { type: string, pattern: "^[a-z0-9_]{1,40}$" } } @@ -3018,7 +3018,7 @@ components: the pairing start's host object. Descriptive fields only; it never carries duid, serial, or mac. Absent fields were not reported. properties: - platform: { type: string, enum: [tizen, android, windows, qt, apple, chromeos, browser] } + platform: { type: string, enum: [tizen, android, windows, qt, apple, chromeos, browser, brightsign] } model: { type: string, minLength: 1, maxLength: 80 } firmware: { type: string, minLength: 1, maxLength: 80 } manufacturer: { type: string, minLength: 1, maxLength: 80 } @@ -3772,7 +3772,7 @@ components: required: [agent_id, name, agent_type] description: Safe agent principal attribution present only when the event was directly caused by an authenticated agent bearer. It is never authorization. properties: - agent_id: { type: string, pattern: "^agt_" } + agent_id: { type: string, pattern: "^(?:(?:stage|qa|development)_)?agt_" } name: { type: string, maxLength: 80 } agent_type: { type: string, maxLength: 32 } DashboardPasskeyRegistration: @@ -3866,7 +3866,7 @@ components: ceremony_id: { type: string } credential: { type: object, additionalProperties: true } dev_confirm: { type: boolean, description: Must be true only on a server running dashboard development authentication; rejected as a substitute for a production passkey assertion. } - session_confirm: { type: boolean, description: Must be true only to approve an agent connection from a password-authenticated account session; it is not accepted as passkey proof or development confirmation. } + session_confirm: { type: boolean, description: "Must be true only to approve, deny, or disconnect an agent from a password-authenticated account session; it is not accepted as passkey proof or development confirmation." } DashboardSummary: type: object additionalProperties: false diff --git a/vendor/protocol/openapi.gen.ts b/vendor/protocol/openapi.gen.ts index 5caedf29..2a82c2fa 100644 --- a/vendor/protocol/openapi.gen.ts +++ b/vendor/protocol/openapi.gen.ts @@ -1,4 +1,4 @@ -// Code generated by screenrig-protocol-gen from api/openapi.yaml (0b3d50daa0155690822dedb58db295982e3b33e84389f22ffa463093b48b1e60). DO NOT EDIT. +// Code generated by screenrig-protocol-gen from api/openapi.yaml (771381b39950f56df6b6f220d4787ccac26271ca56cdb9d9e9a164ad97515a1c). DO NOT EDIT. export interface OpenAPIPaths { "/.health": { @@ -831,7 +831,7 @@ export interface HostContext { "capabilities"?: Array; "device"?: HostDevice; "host_version"?: string; - "platform": "tizen" | "android" | "windows" | "qt" | "apple" | "chromeos" | "browser"; + "platform": "tizen" | "android" | "windows" | "qt" | "apple" | "chromeos" | "browser" | "brightsign"; } export interface HostDevice { @@ -1755,7 +1755,7 @@ export interface ScreenRecoveryHost { "firmware"?: string; "manufacturer"?: string; "model"?: string; - "platform"?: "tizen" | "android" | "windows" | "qt" | "apple" | "chromeos" | "browser"; + "platform"?: "tizen" | "android" | "windows" | "qt" | "apple" | "chromeos" | "browser" | "brightsign"; } export interface ScreenRecoveryPending {