Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/playlist-write.schema.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions scripts/sync-contract-snapshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 5 additions & 5 deletions src/agent-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
);
});
Expand Down
5 changes: 3 additions & 2 deletions src/agent-identity.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RESOURCE_ID_PATTERNS } from "./generated/resource-ids.js";
import {
createDecipheriv,
createHash,
Expand All @@ -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([
Expand Down
27 changes: 27 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }); }
});
19 changes: 10 additions & 9 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -2064,7 +2065,7 @@ async function mediaUpdate(args: ParsedArgs, client: ApiClient): Promise<Command
};
}

const MEDIA_ID_PATTERN = /^med_[A-Za-z0-9_-]+$/;
const MEDIA_ID_PATTERN = RESOURCE_ID_PATTERNS.media;

/** Canonical file extension for each verified media content type, matching the server's Content-Disposition. */
const MEDIA_CONTENT_EXTENSIONS: Record<string, string> = {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -4289,11 +4290,11 @@ export const handlePlaylistInit = commandHandler(async (args, runtime, resolved)
const files = new Map<number, string>();
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<string, any>;
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;
Expand Down
18 changes: 18 additions & 0 deletions src/generated/resource-ids.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 2 additions & 1 deletion src/media-upload-batch.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 5 additions & 4 deletions src/playlist-bundle.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Record<string, string>> = {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -560,7 +561,7 @@ export async function exportPlaylistBundle(options: {
outputDirectory: string;
client: ApiClient;
}): Promise<PlaylistBundleExportResult> {
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);
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 16 additions & 16 deletions src/playlist-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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);
});

Expand Down
3 changes: 2 additions & 1 deletion src/playlist-write-validation.ts
Original file line number Diff line number Diff line change
@@ -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$/;
Expand Down
Loading
Loading