An entry for the streamplace vod showcase
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add full Deno sandbox permissions and derive env from secrets

- Add run, ffi, sys, hrtime permission types to match all Deno sandbox options
- Derive --allow-env from configured secrets instead of manifest declarations
- Update VOD bundle to use valibot schemas for input validation
- Add ROADMAP.md documenting future ideas beyond the PoC

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+372 -94
+1
README.md
··· 70 70 71 71 - [at-run README](./packages/at-run/README.md) - Full documentation 72 72 - [VOD Bundle](./apps/vod/README.md) - Example bundle 73 + - [Roadmap](./packages/at-run/ROADMAP.md) - Future ideas beyond the PoC 73 74 74 75 ## Requirements 75 76
+34 -31
apps/vod/src/index.ts
··· 4 4 * Handles video listing, muxing, and quality selection 5 5 */ 6 6 7 - import { endpoint, manifest } from "@at-run/runtime" 7 + import { endpoint, manifest, v } from "@at-run/runtime" 8 8 import { AtpAgent } from "@atproto/api" 9 9 import { IdResolver } from "@atproto/identity" 10 10 import { AtUri } from "@atproto/syntax" ··· 172 172 } 173 173 174 174 // ============================================================================ 175 - // Endpoint Types 175 + // Schemas & Types 176 176 // ============================================================================ 177 177 178 + const AtUriSchema = v.pipe( 179 + v.string(), 180 + v.startsWith("at://", "Must be a valid AT URI") 181 + ) 182 + 183 + const ListVideosSchema = v.object({ 184 + limit: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))), 185 + cursor: v.optional(v.string()), 186 + }) 187 + 188 + const UriOnlySchema = v.object({ 189 + uri: AtUriSchema, 190 + }) 191 + 192 + const GetPlaylistSchema = v.object({ 193 + uri: AtUriSchema, 194 + quality: v.optional(v.picklist(["1080p", "720p", "480p", "240p"])), 195 + }) 196 + 178 197 interface Video { 179 198 uri: string 180 199 title: string 181 200 creator: string 182 201 duration: number 183 202 createdAt: string 184 - } 185 - 186 - interface ListVideosArgs { 187 - limit?: number 188 - cursor?: string 189 - } 190 - 191 - interface GetPlaylistArgs { 192 - uri: string 193 - quality?: "1080p" | "720p" | "480p" | "240p" 194 203 } 195 204 196 205 interface StreamInfo { ··· 215 224 /** 216 225 * List all AtmosphereConf VODs 217 226 */ 218 - export const listVideos = endpoint<ListVideosArgs, { videos: Video[]; cursor?: string }>({ 227 + export const listVideos = endpoint<v.InferOutput<typeof ListVideosSchema>, { videos: Video[]; cursor?: string }>({ 228 + input: ListVideosSchema, 219 229 async handler(args) { 220 230 const response = await streamPlaceAgent.com.atproto.repo.listRecords({ 221 231 repo: STREAM_PLACE_DID, ··· 250 260 /** 251 261 * Get available streams for a video (for UI to show quality options) 252 262 */ 253 - export const getVideoStreams = endpoint<{ uri: string }, VideoStreamsResult>({ 263 + export const getVideoStreams = endpoint<v.InferOutput<typeof UriOnlySchema>, VideoStreamsResult>({ 264 + input: UriOnlySchema, 254 265 async handler(args) { 255 - if (!args.uri) { 256 - throw new Error("uri is required") 257 - } 258 - 259 266 const manifest = await fetchMasterPlaylist(args.uri) 260 267 261 268 const streams: StreamInfo[] = (manifest.playlists || []).map((p: any) => ({ ··· 294 301 * @param uri - AT URI of the video record 295 302 * @param quality - Optional quality filter (e.g., "1080p", "720p") 296 303 */ 297 - export const getPlaylist = endpoint<GetPlaylistArgs, Response>({ 304 + export const getPlaylist = endpoint<v.InferOutput<typeof GetPlaylistSchema>, Response>({ 305 + input: GetPlaylistSchema, 298 306 async handler(args) { 299 - if (!args.uri) { 300 - throw new Error("uri is required") 301 - } 302 - 303 307 const manifest = await fetchMasterPlaylist(args.uri) 304 308 const playlist = generateMasterPlaylist(manifest, { quality: args.quality }) 305 309 ··· 316 320 /** 317 321 * Get video metadata 318 322 */ 319 - export const getVideo = endpoint<{ uri: string }, Video>({ 323 + export const getVideo = endpoint<v.InferOutput<typeof UriOnlySchema>, Video>({ 324 + input: UriOnlySchema, 320 325 async handler(args) { 321 - if (!args.uri) { 322 - throw new Error("uri is required") 323 - } 324 - 325 326 const atUri = new AtUri(args.uri) 326 327 const agent = await getAgentForDid(atUri.host) 327 328 ··· 348 349 }, 349 350 }) 350 351 351 - export const pollTranscoding = endpoint<{ uri: string }, { status: "pending" | "complete", playlistUri: string }>({ 352 + export const pollTranscoding = endpoint<v.InferOutput<typeof UriOnlySchema>, { status: "pending" | "complete"; playlistUri: string }>({ 353 + input: UriOnlySchema, 354 + permissions: {}, 352 355 async handler({ uri }) { 353 356 return { 354 357 status: "pending", 355 - playlistUri: "" 358 + playlistUri: "", 356 359 } 357 - } 360 + }, 358 361 })
+26 -1
packages/at-run/README.md
··· 167 167 net: ["api.example.com", "cdn.example.com"], // Network access 168 168 read: ["/data"], // Filesystem read 169 169 write: ["/tmp"], // Filesystem write 170 - env: ["API_KEY"], // Environment variables 171 170 run: ["ffmpeg"], // Subprocess execution 171 + ffi: ["/usr/lib/libfoo.so"], // Dynamic libraries 172 + sys: ["cpus", "systemMemoryInfo"], // System info APIs 173 + hrtime: true, // High-resolution time 172 174 }, 173 175 }) 174 176 ``` 177 + 178 + ### Environment Variables & Secrets 179 + 180 + Environment variables are **not** declared in the manifest. Instead, they are derived from configured secrets: 181 + 182 + 1. Bundle author sets secrets for a specific runner using `at-run secrets set` 183 + 2. Secrets are encrypted with the runner's public key 184 + 3. At runtime, the runner decrypts secrets and injects them as env vars 185 + 4. `--allow-env` is automatically set to only the secret keys 186 + 187 + ```bash 188 + # Set a secret for your bundle on a runner 189 + at-run secrets set my-api runner-did API_KEY sk-xxx 190 + 191 + # In your bundle, access it via Deno.env 192 + const apiKey = Deno.env.get("API_KEY") 193 + ``` 194 + 195 + This ensures bundles can only access env vars they have secrets for, preventing leakage of system environment. 175 196 176 197 ### Per-Endpoint Permissions 177 198 ··· 235 256 - **Sandboxed**: Deno enforces declared permissions at runtime 236 257 - **Versioned**: Semantic versioning with automatic bumping 237 258 - **Decentralized**: Anyone can run a runner, bundles are fetched from any PDS 259 + 260 + ## Roadmap 261 + 262 + See [ROADMAP.md](./ROADMAP.md) for future ideas beyond the PoC. 238 263 239 264 ## License 240 265
+113
packages/at-run/ROADMAP.md
··· 1 + # at-run Roadmap 2 + 3 + Ideas for future development beyond the PoC. 4 + 5 + ## Execution Layer 6 + 7 + ### Worker Pools / Warm Starts 8 + Currently each request spawns a fresh Deno process. Pre-warming workers per bundle and reusing them for subsequent requests would dramatically reduce latency (cold start ~500ms → warm <50ms). 9 + 10 + ### Streaming Responses 11 + Support `ReadableStream` returns for SSE and chunked responses. Useful for long-running operations like media transcoding where you want to stream progress back. 12 + 13 + ### Background Jobs 14 + ```typescript 15 + export const transcode = endpoint({ 16 + background: true, 17 + handler: async ({ videoUri }) => { 18 + // Returns job ID immediately, runs async 19 + } 20 + }) 21 + ``` 22 + Store job status in a `dev.mainasara.at-run.beta.job` record. Poll or webhook for completion. 23 + 24 + ### WASM Sandbox Alternative 25 + Deno subprocess is heavy. WASM runtimes (Extism, Wasmer) would be lighter and more portable. Trade-off: smaller ecosystem, no direct npm access. 26 + 27 + ## Distribution 28 + 29 + ### Runner Federation 30 + - Runners discover each other via `runner` records on the network 31 + - Route requests to nearest/least-loaded runner 32 + - Authors specify capability requirements, any matching runner can execute 33 + 34 + ### Bundle Caching 35 + Bundles are immutable (CID-addressed). Runners could share a distributed cache - first runner to fetch populates it for others. 36 + 37 + ### Geographic Routing 38 + The `runner.region` field exists in the lexicon. Use it to route requests to runners near users. Important for latency-sensitive workloads like video streaming. 39 + 40 + ## Developer Experience 41 + 42 + ### Local Dev Server 43 + ```bash 44 + at-run dev ./src/index.ts 45 + ``` 46 + - Hot reload on file changes 47 + - Mock secrets from `.env.local` 48 + - No PDS deployment during development 49 + 50 + ### Logs / Tracing 51 + - The `execution` lexicon exists but isn't used yet 52 + - Capture `console.log` as structured log records 53 + - Distributed tracing across runner hops 54 + 55 + ### Bundle Dependencies 56 + Currently everything must be bundled into a single file. Support an `imports` field pointing to other bundles for shared code without rebundling. 57 + 58 + ## Security 59 + 60 + ### Request Signing 61 + Verify caller identity via DID signatures. Bundles could restrict access: 62 + ```typescript 63 + export const internalApi = endpoint({ 64 + allowedCallers: ["did:plc:trusted-service"], 65 + handler: async () => { ... } 66 + }) 67 + ``` 68 + 69 + ### Rate Limiting 70 + Per-DID, per-bundle, per-endpoint rate limits configured in runner config. Prevents abuse of public runners. 71 + 72 + ### Audit Log 73 + Who called what, when, from where. Stored on runner operator's PDS for compliance. 74 + 75 + ## Economics (Longer Term) 76 + 77 + ### Metered Execution 78 + Runner tracks CPU/memory/bandwidth per DID. Could enable pay-per-use runners when AT Protocol native payments exist. 79 + 80 + ### Reputation System 81 + Runners build reputation over time based on uptime, speed, honesty. Bundles prefer reputable runners. Bad actors get avoided organically. 82 + 83 + ## Media Processing 84 + 85 + For workloads like video transcoding (subprocess spawning is now supported): 86 + 87 + ```typescript 88 + export const transcode = endpoint({ 89 + limits: { 90 + maxMemoryMB: 1024, 91 + timeoutMs: 300000 // 5 min 92 + }, 93 + permissions: { 94 + run: ["ffmpeg"], // ✅ Now supported 95 + write: ["/tmp"], 96 + net: ["storage.example.com"], 97 + sys: ["cpus", "systemMemoryInfo"], // ✅ New: system info for adaptive transcoding 98 + }, 99 + handler: async ({ videoUri, targetResolution }) => { 100 + // Spawn ffmpeg, stream progress, upload result 101 + } 102 + }) 103 + ``` 104 + 105 + Key needs: 106 + - ~~`run` permission for subprocess execution~~ ✅ Implemented 107 + - Background jobs (transcoding takes minutes) 108 + - Progress streaming or job polling 109 + - Temporary storage for intermediate files 110 + 111 + ## Contributing 112 + 113 + This is a PoC for AtmosphereConf 2026. If any of these ideas interest you, open an issue or reach out.
+22
packages/at-run/lexicons/dev.mainasara.at-run.beta.bundle.json
··· 92 92 "maxLength": 256 93 93 }, 94 94 "maxLength": 16 95 + }, 96 + "ffi": { 97 + "type": "array", 98 + "description": "Allowed dynamic library paths for FFI", 99 + "items": { 100 + "type": "string", 101 + "maxLength": 512 102 + }, 103 + "maxLength": 16 104 + }, 105 + "sys": { 106 + "type": "array", 107 + "description": "Allowed system info APIs (hostname, cpus, systemMemoryInfo, etc.). Empty array means all sys APIs allowed.", 108 + "items": { 109 + "type": "string", 110 + "maxLength": 64 111 + }, 112 + "maxLength": 16 113 + }, 114 + "hrtime": { 115 + "type": "boolean", 116 + "description": "Allow high-resolution time measurement" 95 117 } 96 118 } 97 119 }
+74 -41
packages/at-run/runner/src/config.ts
··· 37 37 env?: string[] | boolean 38 38 read?: string[] | boolean 39 39 write?: string[] | boolean 40 - run?: boolean 40 + run?: string[] | boolean 41 + ffi?: string[] | boolean 42 + sys?: string[] | boolean 43 + hrtime?: boolean 41 44 } 42 45 43 46 // Access control ··· 173 176 174 177 const filtered: Permissions = {} 175 178 176 - // Net 177 - if (maxPerms.net === false) { 178 - filtered.net = [] 179 - } else if (maxPerms.net === true) { 180 - filtered.net = requested.net 181 - } else if (Array.isArray(maxPerms.net) && requested.net) { 182 - const allowedNets = maxPerms.net as string[] 183 - filtered.net = requested.net.filter((h) => 184 - allowedNets.some((allowed) => { 185 - if (allowed.startsWith("*.")) { 186 - return h.endsWith(allowed.slice(1)) 187 - } 188 - return h === allowed 189 - }) 190 - ) 179 + // Helper for string array permissions with exact match 180 + const filterArray = ( 181 + reqArr: string[] | undefined, 182 + maxArr: string[] | boolean | undefined 183 + ): string[] | undefined => { 184 + if (maxArr === false) return [] 185 + if (maxArr === true) return reqArr 186 + if (Array.isArray(maxArr) && reqArr) { 187 + return reqArr.filter((item) => maxArr.includes(item)) 188 + } 189 + return reqArr 191 190 } 192 191 193 - // Env 194 - if (maxPerms.env === false) { 195 - filtered.env = [] 196 - } else if (maxPerms.env === true) { 197 - filtered.env = requested.env 198 - } else if (Array.isArray(maxPerms.env) && requested.env) { 199 - filtered.env = requested.env.filter((e) => (maxPerms.env as string[]).includes(e)) 192 + // Helper for path-based permissions (supports prefix matching) 193 + const filterPaths = ( 194 + reqPaths: string[] | undefined, 195 + maxPaths: string[] | boolean | undefined 196 + ): string[] | undefined => { 197 + if (maxPaths === false) return [] 198 + if (maxPaths === true) return reqPaths 199 + if (Array.isArray(maxPaths) && reqPaths) { 200 + return reqPaths.filter((p) => 201 + (maxPaths as string[]).some((allowed) => p.startsWith(allowed)) 202 + ) 203 + } 204 + return reqPaths 200 205 } 201 206 202 - // Read 203 - if (maxPerms.read === false) { 204 - filtered.read = [] 205 - } else if (maxPerms.read === true) { 206 - filtered.read = requested.read 207 - } else if (Array.isArray(maxPerms.read) && requested.read) { 208 - filtered.read = requested.read.filter((p) => 209 - (maxPerms.read as string[]).some((allowed) => p.startsWith(allowed)) 210 - ) 207 + // Helper for net permissions (supports wildcard domains) 208 + const filterNet = ( 209 + reqNet: string[] | undefined, 210 + maxNet: string[] | boolean | undefined 211 + ): string[] | undefined => { 212 + if (maxNet === false) return [] 213 + if (maxNet === true) return reqNet 214 + if (Array.isArray(maxNet) && reqNet) { 215 + return reqNet.filter((h) => 216 + (maxNet as string[]).some((allowed) => { 217 + if (allowed.startsWith("*.")) { 218 + return h.endsWith(allowed.slice(1)) 219 + } 220 + return h === allowed 221 + }) 222 + ) 223 + } 224 + return reqNet 211 225 } 212 226 213 - // Write 214 - if (maxPerms.write === false) { 215 - filtered.write = [] 216 - } else if (maxPerms.write === true) { 217 - filtered.write = requested.write 218 - } else if (Array.isArray(maxPerms.write) && requested.write) { 219 - filtered.write = requested.write.filter((p) => 220 - (maxPerms.write as string[]).some((allowed) => p.startsWith(allowed)) 221 - ) 227 + // Apply filters 228 + filtered.net = filterNet(requested.net, maxPerms.net) 229 + filtered.env = filterArray(requested.env, maxPerms.env) 230 + filtered.read = filterPaths(requested.read, maxPerms.read) 231 + filtered.write = filterPaths(requested.write, maxPerms.write) 232 + filtered.run = filterArray(requested.run, maxPerms.run) 233 + filtered.ffi = filterPaths(requested.ffi, maxPerms.ffi) 234 + 235 + // sys can be boolean or string[] 236 + if (maxPerms.sys === false) { 237 + filtered.sys = undefined 238 + } else if (maxPerms.sys === true) { 239 + filtered.sys = requested.sys 240 + } else if (Array.isArray(maxPerms.sys) && requested.sys) { 241 + if (requested.sys === true) { 242 + filtered.sys = maxPerms.sys 243 + } else if (Array.isArray(requested.sys)) { 244 + filtered.sys = requested.sys.filter((s) => (maxPerms.sys as string[]).includes(s)) 245 + } 246 + } else { 247 + filtered.sys = requested.sys 248 + } 249 + 250 + // hrtime is boolean 251 + if (maxPerms.hrtime === false) { 252 + filtered.hrtime = false 253 + } else { 254 + filtered.hrtime = requested.hrtime 222 255 } 223 256 224 257 return filtered
+14 -8
packages/at-run/runner/src/sandbox.ts
··· 151 151 const effectivePerms = intersectPermissions(globalPermissions, endpointPermissions) 152 152 const effectiveLimits = intersectLimits(globalLimits, endpointLimits) 153 153 154 + // Derive env permissions from secrets - only allow env vars that have secrets 155 + const secretKeys = Object.keys(secrets) 156 + if (secretKeys.length > 0) { 157 + // Replace any declared env permissions with just the secret keys 158 + // This ensures bundles can only access env vars they have secrets for 159 + effectivePerms.env = secretKeys 160 + } else { 161 + // No secrets = no env access (unless explicitly empty which means none) 162 + effectivePerms.env = [] 163 + } 164 + 154 165 // Build Deno permission flags 155 166 const permFlags = permissionsToDenoCLI(effectivePerms) 156 167 ··· 228 239 "-", 229 240 ] 230 241 231 - // Merge secrets into environment (only allowed env vars) 232 - const allowedEnv = effectivePerms.env || [] 233 - const env: Record<string, string> = {} 234 - for (const [key, value] of Object.entries(secrets)) { 235 - if (allowedEnv.includes(key)) { 236 - env[key] = value 237 - } 238 - } 242 + // Pass secrets directly as environment variables 243 + // (env permissions are already derived from secret keys above) 244 + const env: Record<string, string> = { ...secrets } 239 245 240 246 const proc = Bun.spawn(denoArgs, { 241 247 stdin: new TextEncoder().encode(executeScript),
+88 -13
packages/at-run/runtime/src/index.ts
··· 19 19 export interface Permissions { 20 20 /** Allowed network hosts (e.g., ["api.example.com", "plc.directory"]) */ 21 21 net?: string[] 22 - /** Allowed environment variables (e.g., ["API_KEY"]) */ 22 + /** 23 + * Allowed environment variables (internal use). 24 + * NOTE: Bundle authors don't declare this - it's automatically derived 25 + * from the secrets configured for the bundle on each runner. 26 + */ 23 27 env?: string[] 24 28 /** Allowed read paths (e.g., ["/tmp"]) */ 25 29 read?: string[] 26 30 /** Allowed write paths (e.g., ["/tmp/cache"]) */ 27 31 write?: string[] 32 + /** Allowed subprocess executables (e.g., ["ffmpeg", "convert"]) */ 33 + run?: string[] 34 + /** Allowed dynamic library paths for FFI (e.g., ["/usr/lib/libfoo.so"]) */ 35 + ffi?: string[] 36 + /** 37 + * Allowed system info APIs. Can be: 38 + * - true: allow all sys APIs 39 + * - string[]: specific APIs like ["hostname", "cpus", "systemMemoryInfo"] 40 + * 41 + * Available scopes: hostname, osRelease, osUptime, loadavg, networkInterfaces, 42 + * systemMemoryInfo, uid, gid, cpus, homedir, statfs, getPriority, setPriority 43 + */ 44 + sys?: string[] | boolean 45 + /** Allow high-resolution time measurement (Deno.hrtime) */ 46 + hrtime?: boolean 28 47 } 29 48 30 49 // ============================================================================= ··· 206 225 ): Permissions { 207 226 if (!endpoint) return global 208 227 228 + // Helper to intersect string arrays 229 + const intersectArrays = ( 230 + globalArr: string[] | undefined, 231 + endpointArr: string[] | undefined 232 + ): string[] | undefined => { 233 + if (!endpointArr) return globalArr 234 + if (!globalArr) return undefined 235 + return endpointArr.filter((item) => globalArr.includes(item)) 236 + } 237 + 238 + // Helper for path-based permissions (read/write) 239 + const intersectPaths = ( 240 + globalPaths: string[] | undefined, 241 + endpointPaths: string[] | undefined 242 + ): string[] | undefined => { 243 + if (!endpointPaths) return globalPaths 244 + if (!globalPaths) return undefined 245 + return endpointPaths.filter((p) => 246 + globalPaths.some((gp) => p.startsWith(gp)) 247 + ) 248 + } 249 + 250 + // Helper for sys permission (can be boolean or string[]) 251 + const intersectSys = (): string[] | boolean | undefined => { 252 + if (endpoint.sys === undefined) return global.sys 253 + if (global.sys === undefined) return undefined 254 + if (global.sys === true) return endpoint.sys 255 + if (endpoint.sys === true) return global.sys 256 + // Both are arrays - intersect them 257 + const globalSysArr = global.sys 258 + const endpointSysArr = endpoint.sys 259 + if (Array.isArray(endpointSysArr) && Array.isArray(globalSysArr)) { 260 + return endpointSysArr.filter((s: string) => globalSysArr.includes(s)) 261 + } 262 + return undefined 263 + } 264 + 209 265 return { 210 - net: endpoint.net 211 - ? endpoint.net.filter((h) => global.net?.includes(h)) 212 - : global.net, 213 - env: endpoint.env 214 - ? endpoint.env.filter((e) => global.env?.includes(e)) 215 - : global.env, 216 - read: endpoint.read 217 - ? endpoint.read.filter((p) => global.read?.some((gp) => p.startsWith(gp))) 218 - : global.read, 219 - write: endpoint.write 220 - ? endpoint.write.filter((p) => global.write?.some((gp) => p.startsWith(gp))) 221 - : global.write, 266 + net: intersectArrays(global.net, endpoint.net), 267 + env: intersectArrays(global.env, endpoint.env), 268 + read: intersectPaths(global.read, endpoint.read), 269 + write: intersectPaths(global.write, endpoint.write), 270 + run: intersectArrays(global.run, endpoint.run), 271 + ffi: intersectArrays(global.ffi, endpoint.ffi), 272 + sys: intersectSys(), 273 + hrtime: endpoint.hrtime !== undefined 274 + ? global.hrtime && endpoint.hrtime 275 + : global.hrtime, 222 276 } 223 277 } 224 278 ··· 245 299 export function permissionsToDenoCLI(perms: Permissions): string[] { 246 300 const flags: string[] = [] 247 301 302 + // String array permissions 248 303 if (perms.net && perms.net.length > 0) { 249 304 flags.push(`--allow-net=${perms.net.join(",")}`) 250 305 } ··· 259 314 260 315 if (perms.write && perms.write.length > 0) { 261 316 flags.push(`--allow-write=${perms.write.join(",")}`) 317 + } 318 + 319 + if (perms.run && perms.run.length > 0) { 320 + flags.push(`--allow-run=${perms.run.join(",")}`) 321 + } 322 + 323 + if (perms.ffi && perms.ffi.length > 0) { 324 + flags.push(`--allow-ffi=${perms.ffi.join(",")}`) 325 + } 326 + 327 + // sys can be boolean or string[] 328 + if (perms.sys === true) { 329 + flags.push("--allow-sys") 330 + } else if (Array.isArray(perms.sys) && perms.sys.length > 0) { 331 + flags.push(`--allow-sys=${perms.sys.join(",")}`) 332 + } 333 + 334 + // hrtime is boolean only 335 + if (perms.hrtime) { 336 + flags.push("--allow-hrtime") 262 337 } 263 338 264 339 return flags