spine-framework 0.5.0-rc.4 → 0.5.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.framework/cli/commands/install-app.ts +183 -13
  2. package/.framework/cli/commands/validate-app.ts +127 -1
  3. package/.framework/functions/_shared/app-manifest.ts +2 -0
  4. package/.framework/functions/_shared/permissions.ts +4 -0
  5. package/.framework/functions/admin-data.ts +14 -2
  6. package/.framework/functions/apps.ts +21 -4
  7. package/.framework/functions/embeddings.ts +5 -3
  8. package/.framework/functions/observability.ts +1 -1
  9. package/.framework/migrations/074_app_route_prefix_domains.sql +168 -0
  10. package/.framework/migrations/075_security_definer_grants.sql +157 -0
  11. package/.framework/src/contexts/AppContext.tsx +5 -12
  12. package/.framework/src/contexts/AuthContext.tsx +17 -9
  13. package/.framework/src/hooks/useApps.ts +18 -22
  14. package/.framework/src/instructions/README.md +17 -0
  15. package/.framework/src/instructions/accounts.md +97 -0
  16. package/.framework/src/instructions/apps.md +3 -3
  17. package/.framework/src/instructions/concepts/apps-and-renderers.md +68 -2
  18. package/.framework/src/instructions/internal/docs-contract.md +158 -0
  19. package/.framework/src/instructions/notifications.md +77 -0
  20. package/.framework/src/instructions/operations/security-checklist.md +24 -2
  21. package/.framework/src/instructions/people.md +91 -0
  22. package/.framework/src/instructions/reference/app-manifest.md +37 -3
  23. package/.framework/src/instructions/reference/frontend-sdk.md +207 -0
  24. package/.framework/src/instructions/reference/functions/admin-data.md +4 -2
  25. package/.framework/src/instructions/runtime-collaboration.md +106 -0
  26. package/.framework/src/instructions/tasks/document-your-app.md +117 -0
  27. package/.framework/src/instructions/tasks/package-and-install-an-app.md +14 -5
  28. package/.framework/src/instructions/tasks/register-an-app.md +137 -0
  29. package/.framework/src/instructions/timers.md +4 -2
  30. package/.framework/src/lib/app-mount.ts +88 -0
  31. package/.framework/src/pages/auth/LoginPage.tsx +3 -2
  32. package/.framework/src/pages/auth/RegisterPage.tsx +16 -8
  33. package/.framework/templates/agent/AGENTS.md +17 -0
  34. package/.framework/templates/agent/skills/spine-install-app/SKILL.md +7 -0
  35. package/.framework/templates/agent/skills/spine-new-app/SKILL.md +14 -2
  36. package/.framework/templates/agent/skills/spine-new-function/SKILL.md +6 -0
  37. package/.framework/templates/agent/skills/spine-new-type/SKILL.md +7 -0
  38. package/.framework/tests/unit/account-scoping-gates.test.ts +3 -1
  39. package/.framework/tests/unit/app-mount.test.ts +80 -0
  40. package/.framework/tests/unit/security-definer-grants.test.ts +154 -0
  41. package/package.json +1 -1
  42. package/scripts/checksum-manifest.mts +32 -2
  43. package/scripts/verify-manifest.mts +6 -2
@@ -142,7 +142,7 @@ interface SpineConfig {
142
142
  // CIL[DOC-AUDIENCE audience="internal"]
143
143
 
144
144
  import type { Command } from 'commander'
145
- import { existsSync, readFileSync, mkdirSync, cpSync, writeFileSync, realpathSync, statSync } from 'fs'
145
+ import { existsSync, readFileSync, mkdirSync, cpSync, writeFileSync, realpathSync, statSync, readdirSync, rmSync } from 'fs'
146
146
  import { resolve, dirname, join, basename, isAbsolute, sep } from 'path'
147
147
  import { fileURLToPath } from 'url'
148
148
  import { execSync } from 'child_process'
@@ -319,6 +319,165 @@ const SPINE_CONFIG_PATH = join(PROJECT_ROOT, 'spine.config.json')
319
319
  // CIL[DOC-VECTOR type="user_journey" content="" status="pending_human_input"]
320
320
 
321
321
 
322
+ // CIL[BEGIN stable_id="~/.framework/cli/commands/install-app.ts:appcopyentries:constant" type="constant" identity="APP_COPY_ENTRIES" parent="none"]
323
+ // CIL[DOC-MACRO purpose="Single allowlist of package entries copied into custom/apps/<slug>/ on install and update"]
324
+ // CIL[DOC-RESPONSIBILITY responsibility="Declare the shared copy allowlist so install-app and update-app cannot drift apart"]
325
+ // CIL[DOC-CONTEXT module="install-app" layer="cli" related_files=".framework/cli/commands/validate-app.ts"]
326
+ // CIL[DOC-CONTRACT inputs="none" outputs="readonly string[] of top-level package entries" errors="none" side_effects="none"]
327
+ // CIL[DOC-MICRO logic_summary="Union of both commands' previous lists plus entries real apps ship (lib/views/docs/installer/mcp/migrations/tests/standalone.config.json); 'agent' is handled separately by syncAppAgentAssets" notable_decisions="['shared_allowlist_prevents_drift','agent_staged_not_copied_verbatim']"]
328
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
329
+ // CIL[DOC-STABILITY stability="stable"]
330
+ const APP_COPY_ENTRIES: readonly string[] = [
331
+ 'index.tsx', 'manifest.json', 'package.json', 'standalone.config.json',
332
+ 'pages', 'components', 'hooks', 'utils', 'lib', 'views', 'docs',
333
+ 'functions', 'api', 'seed', 'public', 'installer', 'mcp',
334
+ 'migrations', 'migrations-archive', 'tests', 'agent',
335
+ ]
336
+ // CIL[END stable_id="~/.framework/cli/commands/install-app.ts:appcopyentries:constant"]
337
+
338
+ // CIL[BEGIN stable_id="~/.framework/cli/commands/install-app.ts:agentskillprefixre:constant" type="constant" identity="AGENT_SKILL_PREFIX_RE" parent="none"]
339
+ // CIL[DOC-MACRO purpose="Validation regex for manifest agent.skill_prefix"]
340
+ // CIL[DOC-RESPONSIBILITY responsibility="Declare the single source of truth for the skill-prefix rule shared by install and validate paths"]
341
+ // CIL[DOC-CONTEXT module="install-app" layer="cli" related_files=".framework/cli/commands/validate-app.ts"]
342
+ // CIL[DOC-CONTRACT inputs="none" outputs="RegExp matching 1-8 char lowercase-alphanumeric prefixes starting with a letter" errors="none" side_effects="none"]
343
+ // CIL[DOC-MICRO logic_summary="/^[a-z][a-z0-9]{0,7}$/ — letter first, then up to 7 lowercase letters or digits" notable_decisions="['exported_for_shared_use']"]
344
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
345
+ // CIL[DOC-STABILITY stability="stable"]
346
+ export const AGENT_SKILL_PREFIX_RE = /^[a-z][a-z0-9]{0,7}$/
347
+ // CIL[END stable_id="~/.framework/cli/commands/install-app.ts:agentskillprefixre:constant"]
348
+
349
+ // CIL[BEGIN stable_id="~/.framework/cli/commands/install-app.ts:syncappagentassets:function" type="function" identity="syncAppAgentAssets" parent="none"]
350
+ // CIL[DOC-MACRO purpose="Sync an installed app's staged agent assets (agent/skills, agent/instructions) to their live locations"]
351
+ // CIL[DOC-RESPONSIBILITY responsibility="Copy agent/skills/<prefix>_* to custom/.devin/skills/ and agent/instructions to the app's src/instructions/, enforcing the manifest-declared skill prefix"]
352
+ // CIL[DOC-CONTEXT module="install-app" layer="cli" related_files=".framework/cli/commands/validate-app.ts,.framework/cli/lib/project-files.ts"]
353
+ // CIL[DOC-CONTRACT inputs="appDir (installed app dir), appSlug" outputs="void" errors="Throws when agent/skills exists without manifest.agent.skill_prefix, a dir violates <prefix>_ naming, or SKILL.md name mismatches its dir" side_effects="writes custom/.devin/skills and app src/instructions"]
354
+ // CIL[DOC-MICRO logic_summary="No-op without an agent/ dir; requires manifest.agent.skill_prefix /^[a-z][a-z0-9]{0,7}$/ when skills are staged; fails on cross-app prefix collisions by scanning installed manifests; copies instructions to src/instructions" notable_decisions="['fail_closed_on_missing_prefix','prefix_scoped_ownership']"]
355
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
356
+ // CIL[DOC-STABILITY stability="stable"]
357
+ function syncAppAgentAssets(appDir: string, appSlug: string): void {
358
+ const agentDir = join(appDir, 'agent')
359
+ if (!existsSync(agentDir)) return
360
+
361
+ const stagedSkills = join(agentDir, 'skills')
362
+ const stagedInstructions = join(agentDir, 'instructions')
363
+ const hasSkills = existsSync(stagedSkills)
364
+ const hasInstructions = existsSync(stagedInstructions)
365
+ if (!hasSkills && !hasInstructions) return
366
+
367
+ const manifest: Record<string, any> = JSON.parse(readFileSync(join(appDir, 'manifest.json'), 'utf8'))
368
+ const prefix = manifest.agent?.skill_prefix
369
+
370
+ if (hasSkills) {
371
+ if (typeof prefix !== 'string' || !AGENT_SKILL_PREFIX_RE.test(prefix)) {
372
+ throw new Error(`manifest.json agent.skill_prefix for "${appSlug}" is required (^[a-z][a-z0-9]{0,7}$) when the package ships agent/skills/`)
373
+ }
374
+
375
+ for (const other of readdirSync(join(PROJECT_ROOT, 'custom', 'apps'), { withFileTypes: true })) {
376
+ if (!other.isDirectory() || other.name === appSlug) continue
377
+ const otherManifest = join(PROJECT_ROOT, 'custom', 'apps', other.name, 'manifest.json')
378
+ try {
379
+ const m = JSON.parse(readFileSync(otherManifest, 'utf8'))
380
+ if (m.agent?.skill_prefix === prefix) {
381
+ throw new Error(`skill_prefix "${prefix}" is already used by app "${other.name}" — prefixes must be unique across installed apps`)
382
+ }
383
+ } catch (e: any) {
384
+ if (e.message?.includes('skill_prefix')) throw e
385
+ }
386
+ }
387
+
388
+ const skillsDest = join(PROJECT_ROOT, 'custom', '.devin', 'skills')
389
+ for (const dir of readdirSync(stagedSkills, { withFileTypes: true })) {
390
+ if (!dir.isDirectory()) continue
391
+ if (!dir.name.startsWith(`${prefix}_`)) {
392
+ throw new Error(`agent/skills/${dir.name} does not match the declared prefix "${prefix}_"`)
393
+ }
394
+ const skillFile = join(stagedSkills, dir.name, 'SKILL.md')
395
+ if (!existsSync(skillFile)) {
396
+ throw new Error(`agent/skills/${dir.name} is missing SKILL.md`)
397
+ }
398
+ const nameMatch = /^name:\s*(.+)$/m.exec(readFileSync(skillFile, 'utf8'))
399
+ if (!nameMatch || nameMatch[1].trim() !== dir.name) {
400
+ throw new Error(`agent/skills/${dir.name}/SKILL.md name field must equal its directory name "${dir.name}"`)
401
+ }
402
+ cpSync(join(stagedSkills, dir.name), join(skillsDest, dir.name), { recursive: true })
403
+ console.log(` Skill installed: custom/.devin/skills/${dir.name}`)
404
+ }
405
+ }
406
+
407
+ if (hasInstructions) {
408
+ const instrDest = join(appDir, 'src', 'instructions')
409
+ mkdirSync(instrDest, { recursive: true })
410
+ cpSync(stagedInstructions, instrDest, { recursive: true })
411
+ console.log(` Instructions installed: custom/apps/${appSlug}/src/instructions/`)
412
+ }
413
+ }
414
+ // CIL[END stable_id="~/.framework/cli/commands/install-app.ts:syncappagentassets:function"]
415
+
416
+ // CIL[BEGIN stable_id="~/.framework/cli/commands/install-app.ts:removeappagentskills:function" type="function" identity="removeAppAgentSkills" parent="none"]
417
+ // CIL[DOC-MACRO purpose="Remove an app's prefix-scoped skills from custom/.devin/skills on uninstall"]
418
+ // CIL[DOC-RESPONSIBILITY responsibility="Delete only the skill dirs owned by the uninstalled app's declared skill_prefix"]
419
+ // CIL[DOC-CONTEXT module="install-app" layer="cli" related_files=".framework/cli/commands/install-app.ts"]
420
+ // CIL[DOC-CONTRACT inputs="appDir, appSlug" outputs="void" errors="none" side_effects="removes custom/.devin/skills/<prefix>_* dirs"]
421
+ // CIL[DOC-MICRO logic_summary="Reads manifest.agent.skill_prefix from the app dir before deletion; globs <prefix>_* dirs and removes them; never touches unowned dirs" notable_decisions="['prefix_glob_scoped_removal']"]
422
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
423
+ // CIL[DOC-STABILITY stability="stable"]
424
+ function removeAppAgentSkills(appDir: string, appSlug: string): void {
425
+ try {
426
+ const manifest: Record<string, any> = JSON.parse(readFileSync(join(appDir, 'manifest.json'), 'utf8'))
427
+ const prefix = manifest.agent?.skill_prefix
428
+ if (typeof prefix !== 'string' || !prefix) return
429
+ const skillsDir = join(PROJECT_ROOT, 'custom', '.devin', 'skills')
430
+ if (!existsSync(skillsDir)) return
431
+ for (const dir of readdirSync(skillsDir, { withFileTypes: true })) {
432
+ if (dir.isDirectory() && dir.name.startsWith(`${prefix}_`)) {
433
+ rmSync(join(skillsDir, dir.name), { recursive: true, force: true })
434
+ console.log(` ✓ Removed skill custom/.devin/skills/${dir.name}`)
435
+ }
436
+ }
437
+ } catch {
438
+ console.log(` ○ No agent.skill_prefix for "${appSlug}" — no skills to remove`)
439
+ }
440
+ }
441
+ // CIL[END stable_id="~/.framework/cli/commands/install-app.ts:removeappagentskills:function"]
442
+
443
+ // CIL[BEGIN stable_id="~/.framework/cli/commands/install-app.ts:islocalpackagespec:function" type="function" identity="isLocalPackageSpec" parent="none"]
444
+ // CIL[DOC-MACRO purpose="Detect package specs that resolve to a local path instead of an npm registry name"]
445
+ // CIL[DOC-RESPONSIBILITY responsibility="Return true for file:, ./, ../, / and *.tgz specs so commands can skip registry-only syntax like @latest"]
446
+ // CIL[DOC-CONTEXT module="install-app" layer="cli" related_files=".framework/cli/commands/install-app.ts"]
447
+ // CIL[DOC-CONTRACT inputs="packageName: string (the CLI <package> argument)" outputs="boolean" errors="none" side_effects="none"]
448
+ // CIL[DOC-MICRO logic_summary="Path-shaped specs (file:, dot-relative, absolute, .tgz) are local; everything else is a registry spec" notable_decisions="['local_specs_have_no_version_tag']"]
449
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
450
+ // CIL[DOC-STABILITY stability="stable"]
451
+ function isLocalPackageSpec(packageName: string): boolean {
452
+ return packageName.startsWith('file:')
453
+ || packageName.startsWith('.')
454
+ || packageName.startsWith('/')
455
+ || packageName.endsWith('.tgz')
456
+ }
457
+ // CIL[END stable_id="~/.framework/cli/commands/install-app.ts:islocalpackagespec:function"]
458
+
459
+ // CIL[BEGIN stable_id="~/.framework/cli/commands/install-app.ts:resolvepackagedirname:function" type="function" identity="resolvePackageDirName" parent="none"]
460
+ // CIL[DOC-MACRO purpose="Resolve the node_modules directory name an install spec lands under"]
461
+ // CIL[DOC-RESPONSIBILITY responsibility="Map install-app/update-app/uninstall-app <package> args — registry names (plain or scoped), tarball paths, and file:/path dirs — to the name npm uses for node_modules/<name>"]
462
+ // CIL[DOC-CONTEXT module="install-app" layer="cli" related_files=".framework/cli/commands/install-app.ts"]
463
+ // CIL[DOC-CONTRACT inputs="packageName: string" outputs="npm package name (may be scoped, e.g. @scope/name)" errors="throws if a tarball or directory spec has no readable package.json" side_effects="execSync tar for .tgz specs"]
464
+ // CIL[DOC-MICRO logic_summary="Tarballs yield their embedded package/package.json name via tar -xOf; file:/path and relative/absolute dirs read package.json directly; registry specs strip only the trailing @version so scoped names survive" notable_decisions="['tarball_name_comes_from_the_package_not_the_filename','split_on_last_at_preserves_scopes']"]
465
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
466
+ // CIL[DOC-STABILITY stability="stable"]
467
+ function resolvePackageDirName(packageName: string): string {
468
+ let spec = packageName
469
+ if (spec.startsWith('file:')) spec = spec.slice(5)
470
+ if (spec.endsWith('.tgz')) {
471
+ const json = execSync(`tar -xOf "${spec}" package/package.json`, { cwd: PROJECT_ROOT, encoding: 'utf8' })
472
+ return JSON.parse(json).name
473
+ }
474
+ if (spec.startsWith('.') || spec.startsWith('/')) {
475
+ return JSON.parse(readFileSync(join(PROJECT_ROOT, spec, 'package.json'), 'utf8')).name
476
+ }
477
+ return packageName.replace(/@[^@/]+$/, '')
478
+ }
479
+ // CIL[END stable_id="~/.framework/cli/commands/install-app.ts:resolvepackagedirname:function"]
480
+
322
481
  // CIL[DOC-MACRO purpose="Command registration and installation handler for Spine Framework apps."]
323
482
  // CIL[DOC-RESPONSIBILITY responsibility="Registers a command to install a Spine Framework app from npm and handles the installation process, including file copying and database migrations. This function ensures that the app is properly set up within the project environment."]
324
483
  // CIL[DOC-CONTEXT module="install-app" layer="app-specific" related_files=".framework/cli/commands/migrate.js"]
@@ -371,7 +530,7 @@ export function registerInstallAppCommands(program: Command) {
371
530
  console.log(' ✓ npm install complete')
372
531
 
373
532
 
374
- const pkgSlug = packageName.split('@')[0] // strip version
533
+ const pkgSlug = resolvePackageDirName(packageName)
375
534
  const pkgPath = join(PROJECT_ROOT, 'node_modules', pkgSlug)
376
535
 
377
536
  if (!existsSync(pkgPath)) {
@@ -392,14 +551,9 @@ export function registerInstallAppCommands(program: Command) {
392
551
  }
393
552
 
394
553
 
395
- const allowedEntries = [
396
- 'index.tsx', 'manifest.json', 'package.json',
397
- 'pages', 'components', 'hooks', 'utils', 'functions', 'seed', 'public'
398
- ]
399
-
400
554
  console.log(` Copying files from ${pkgPath} to ${appDest}`)
401
555
 
402
- for (const entry of allowedEntries) {
556
+ for (const entry of APP_COPY_ENTRIES) {
403
557
  const src = join(pkgPath, entry)
404
558
  if (!existsSync(src)) continue
405
559
 
@@ -415,6 +569,9 @@ export function registerInstallAppCommands(program: Command) {
415
569
  console.log(` ✓ App files copied to custom/apps/${appSlug}/`)
416
570
 
417
571
 
572
+ syncAppAgentAssets(appDest, appSlug)
573
+
574
+
418
575
  console.log(' Running migrations...')
419
576
  const migrateResult = await runMigrations({ frameworkOnly: false })
420
577
  if (migrateResult === null) {
@@ -468,14 +625,14 @@ export function registerInstallAppCommands(program: Command) {
468
625
  console.log(`\nUninstalling app: ${packageName}\n`)
469
626
  loadEnv()
470
627
 
471
- const pkgSlug = packageName.split('@')[0]
628
+ const pkgSlug = resolvePackageDirName(packageName)
472
629
  const pkgPath = join(PROJECT_ROOT, 'node_modules', pkgSlug)
473
630
  const appSlug = resolveAppSlug(pkgPath, pkgSlug)
474
631
  const appDir = join(PROJECT_ROOT, 'custom/apps', appSlug)
475
632
 
476
633
  try {
477
634
  if (existsSync(appDir)) {
478
- const { rmSync } = await import('fs')
635
+ removeAppAgentSkills(appDir, appSlug)
479
636
  rmSync(appDir, { recursive: true, force: true })
480
637
  console.log(` ✓ Removed custom/apps/${appSlug}/`)
481
638
  } else {
@@ -501,12 +658,12 @@ export function registerInstallAppCommands(program: Command) {
501
658
  loadEnv()
502
659
 
503
660
  try {
504
- execSync(`npm install ${packageName}@latest --save`, {
661
+ execSync(`npm install ${isLocalPackageSpec(packageName) ? packageName : `${packageName}@latest`} --save`, {
505
662
  cwd: PROJECT_ROOT,
506
663
  stdio: 'inherit',
507
664
  })
508
665
 
509
- const pkgSlug = packageName.split('@')[0]
666
+ const pkgSlug = resolvePackageDirName(packageName)
510
667
  const pkgPath = join(PROJECT_ROOT, 'node_modules', pkgSlug)
511
668
  const appSlug = resolveAppSlug(pkgPath, pkgSlug)
512
669
  const appDest = join(PROJECT_ROOT, 'custom/apps', appSlug)
@@ -515,13 +672,16 @@ export function registerInstallAppCommands(program: Command) {
515
672
  mkdirSync(appDest, { recursive: true })
516
673
  }
517
674
 
518
- for (const entry of ['index.tsx', 'manifest.json', 'pages', 'components', 'hooks', 'api', 'seed']) {
675
+ for (const entry of APP_COPY_ENTRIES) {
519
676
  const src = join(pkgPath, entry)
520
677
  if (existsSync(src)) {
678
+ if (entry === 'manifest.json' && existsSync(join(appDest, 'manifest.json'))) continue
521
679
  cpSync(src, join(appDest, entry), { recursive: true })
522
680
  }
523
681
  }
524
682
 
683
+ syncAppAgentAssets(appDest, appSlug)
684
+
525
685
 
526
686
  const manifestPath = join(appDest, 'manifest.json')
527
687
  let manifest: Record<string, any> = {}
@@ -1195,6 +1355,14 @@ function preflightAppInputs(appDir: string, appSlug: string, configPath: string)
1195
1355
  if (manifest.config !== undefined && manifest.config !== null && !isPlainObject(manifest.config)) {
1196
1356
  throw new Error(`manifest.json config for "${appSlug}" must be an object`)
1197
1357
  }
1358
+ if (manifest.agent !== undefined && manifest.agent !== null) {
1359
+ if (!isPlainObject(manifest.agent)) {
1360
+ throw new Error(`manifest.json agent for "${appSlug}" must be an object`)
1361
+ }
1362
+ if (manifest.agent.skill_prefix !== undefined && !AGENT_SKILL_PREFIX_RE.test(manifest.agent.skill_prefix)) {
1363
+ throw new Error(`manifest.json agent.skill_prefix for "${appSlug}" must match ${AGENT_SKILL_PREFIX_RE}`)
1364
+ }
1365
+ }
1198
1366
  if (manifest.owned_types !== undefined) {
1199
1367
  if (!Array.isArray(manifest.owned_types) || manifest.owned_types.some((s: any) => typeof s !== 'string' || !s.trim())) {
1200
1368
  throw new Error(`manifest.json owned_types for "${appSlug}" must be an array of non-empty strings`)
@@ -1483,6 +1651,8 @@ function appRowPayload(manifest: Record<string, any>, appSlug: string, existingC
1483
1651
  min_role: manifest.required_roles?.[0] ?? manifest.min_role ?? null,
1484
1652
  preview_domain: manifest.preview_domain ?? null,
1485
1653
  prod_domain: manifest.prod_domain ?? null,
1654
+ preview_route_prefix: manifest.preview_route_prefix ?? null,
1655
+ prod_route_prefix: manifest.prod_route_prefix ?? null,
1486
1656
  config: mergedConfig,
1487
1657
  metadata: manifest.metadata ?? undefined,
1488
1658
  }
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { Command } from 'commander'
22
22
  import { readFileSync, existsSync, readdirSync, statSync, realpathSync } from 'fs'
23
+ import { createHash } from 'crypto'
23
24
  import { join, dirname, resolve, isAbsolute, sep } from 'path'
24
25
  import { fileURLToPath } from 'url'
25
26
  // CIL[DOC-VECTOR type="context" content="purpose='Provide module-level imports for the validate-app CLI command'; No verified external callers; imports: none"]
@@ -127,6 +128,14 @@ interface Manifest {
127
128
  parent_account?: string
128
129
  default_workspace_name?: boolean
129
130
  }
131
+ route_prefix?: string
132
+ preview_domain?: string
133
+ prod_domain?: string
134
+ preview_route_prefix?: string
135
+ prod_route_prefix?: string
136
+ agent?: {
137
+ skill_prefix?: string
138
+ }
130
139
  }
131
140
  // CIL[DOC-VECTOR type="context" content="purpose='Define the shape of an app manifest'; No verified external callers; imports: none"]
132
141
  // CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
@@ -337,7 +346,8 @@ function validateAllowedDirectories(appPath: string): void {
337
346
  const allowedDirs = [
338
347
  'pages', 'components', 'hooks', 'utils', 'functions',
339
348
  'seed', 'public', 'lib', 'views', 'docs', 'mcp',
340
- 'migrations', 'migrations-archive', 'tests'
349
+ 'migrations', 'migrations-archive', 'tests', 'installer',
350
+ 'agent', 'src'
341
351
  ]
342
352
 
343
353
  const entries = readdirSync(appPath, { withFileTypes: true })
@@ -414,6 +424,23 @@ function validateDevinDirectory(appPath: string): void {
414
424
  // CIL[DOC-CONFIDENCE pass="second" score="0.90" flags="none"]
415
425
  // CIL[DOC-STABILITY stable]
416
426
  function validateRoutes(appPath: string, manifest: Manifest): void {
427
+ // Host-keyed mount overrides: an override prefix only makes sense with its
428
+ // domain trigger, and both must be root-relative.
429
+ const pairs: [keyof Manifest, keyof Manifest][] = [
430
+ ['prod_route_prefix', 'prod_domain'],
431
+ ['preview_route_prefix', 'preview_domain'],
432
+ ]
433
+ for (const [prefixKey, domainKey] of pairs) {
434
+ const prefix = manifest[prefixKey] as string | undefined
435
+ if (prefix === undefined) continue
436
+ if (typeof prefix !== 'string' || !prefix.startsWith('/')) {
437
+ throw new ValidationError(`❌ manifest.json ${prefixKey} "${prefix}" must start with /`)
438
+ }
439
+ if (!manifest[domainKey]) {
440
+ throw new ValidationError(`❌ manifest.json ${prefixKey} requires ${domainKey} — the override has no trigger host without it`)
441
+ }
442
+ }
443
+
417
444
  if (!manifest.route_prefix) {
418
445
  console.warn('⚠️ manifest.json is missing route_prefix — install-app will default to /{slug}')
419
446
  return
@@ -642,6 +669,7 @@ function validateOwnedTypes(appPath: string, manifest: Manifest): void {
642
669
  // CIL[DOC-VECTOR type="implementation_pattern" content="" status="pending_human_input"]
643
670
  // CIL[DOC-VECTOR type="user_journey" content="" status="pending_human_input"]
644
671
  // CIL[END stable_id="~/.framework/cli/commands/validate-app.ts:validateownedtypes:function"]
672
+ // CIL[BEGIN stable_id="~/.framework/cli/commands/validate-app.ts:validateknowledgeenvelope:function" type="function" identity="validateKnowledgeEnvelope" parent="~/.framework/cli/commands/validate-app.ts"]
645
673
  // CIL[DOC-MACRO purpose="Validate the manifest knowledge envelope and seed prompt-config picks"]
646
674
  // CIL[DOC-RESPONSIBILITY responsibility="Check config.knowledge shape and verify seeded prompt_configs' retrieval_policy picks stay inside the envelope — an agent may narrow the app envelope but never widen it"]
647
675
  // CIL[DOC-CONTEXT module="validate-app" layer="cli" related_files=".framework/functions/_shared/kb.ts"]
@@ -740,6 +768,103 @@ function validateKnowledgeEnvelope(appPath: string, manifest: Manifest): void {
740
768
  }
741
769
  // CIL[END stable_id="~/.framework/cli/commands/validate-app.ts:validateknowledgeenvelope:function"]
742
770
 
771
+ // CIL[BEGIN stable_id="~/.framework/cli/commands/validate-app.ts:validateagentdirectory:function" type="function" identity="validateAgentDirectory" parent="~/.framework/cli/commands/validate-app.ts"]
772
+ // CIL[DOC-AUDIENCE audience="CLI engineers and framework maintainers"]
773
+ // CIL[DOC-MACRO purpose="Validate the app's staged agent/ directory (skills and instructions)"]
774
+ // CIL[DOC-RESPONSIBILITY responsibility="Enforce skill prefix naming and SKILL.md name/dir equality, markdown-only instructions, and warn when staged assets drift from their live copies"]
775
+ // CIL[DOC-CONTEXT module="validate-app" layer="cli" related_files=".framework/cli/commands/install-app.ts"]
776
+ // CIL[DOC-CONTRACT inputs="appPath: string, manifest: Manifest" outputs="void" errors="ValidationError on missing skill_prefix, prefix mismatch, missing SKILL.md, or non-markdown instructions" side_effects="logs warnings to stdout on staged-vs-live drift"]
777
+ // CIL[DOC-MICRO logic_summary="agent/skills requires manifest.agent.skill_prefix; every staged dir must start <prefix>_ and its SKILL.md name must equal the dir name; agent/instructions may only contain .md; sha1 snapshots of staged trees are compared to live custom/.devin/skills and src/instructions" notable_decisions="['drift_is_a_warning_not_an_error','skill_prefix_fail_closed']"]
778
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
779
+ // CIL[DOC-STABILITY stability="stable"]
780
+ function validateAgentDirectory(appPath: string, manifest: Manifest): void {
781
+ const agentPath = join(appPath, 'agent')
782
+ if (!existsSync(agentPath)) return
783
+
784
+ const skillsPath = join(agentPath, 'skills')
785
+ const instructionsPath = join(agentPath, 'instructions')
786
+
787
+ if (existsSync(skillsPath)) {
788
+ const prefix = manifest.agent?.skill_prefix
789
+ if (typeof prefix !== 'string' || !/^[a-z][a-z0-9]{0,7}$/.test(prefix)) {
790
+ throw new ValidationError(
791
+ `❌ agent/skills/ requires manifest.json agent.skill_prefix matching /^[a-z][a-z0-9]{0,7}$/`
792
+ )
793
+ }
794
+ for (const dir of readdirSync(skillsPath, { withFileTypes: true })) {
795
+ if (!dir.isDirectory()) continue
796
+ if (!dir.name.startsWith(`${prefix}_`)) {
797
+ throw new ValidationError(
798
+ `❌ agent/skills/${dir.name} must start with the declared prefix "${prefix}_"`
799
+ )
800
+ }
801
+ const skillFile = join(skillsPath, dir.name, 'SKILL.md')
802
+ if (!existsSync(skillFile)) {
803
+ throw new ValidationError(`❌ agent/skills/${dir.name}/ is missing SKILL.md`)
804
+ }
805
+ const nameMatch = /^name:\s*(.+)$/m.exec(readFileSync(skillFile, 'utf8'))
806
+ if (!nameMatch || nameMatch[1].trim() !== dir.name) {
807
+ throw new ValidationError(
808
+ `❌ agent/skills/${dir.name}/SKILL.md name must equal its directory name "${dir.name}"`
809
+ )
810
+ }
811
+ }
812
+ }
813
+
814
+ if (existsSync(instructionsPath)) {
815
+ const checkMarkdownOnly = (dirPath: string): void => {
816
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
817
+ const full = join(dirPath, entry.name)
818
+ if (entry.isDirectory()) checkMarkdownOnly(full)
819
+ else if (!entry.name.endsWith('.md')) {
820
+ throw new ValidationError(
821
+ `❌ agent/instructions may only contain .md files — found ${entry.name}`
822
+ )
823
+ }
824
+ }
825
+ }
826
+ checkMarkdownOnly(instructionsPath)
827
+ }
828
+
829
+ // Staged copies should match their live counterparts — drift means one
830
+ // side was edited without re-staging (or the live install is stale).
831
+ const snapshot = (root: string): Map<string, string> => {
832
+ const out = new Map<string, string>()
833
+ const walk = (dir: string): void => {
834
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
835
+ const full = join(dir, entry.name)
836
+ if (entry.isDirectory()) walk(full)
837
+ else out.set(full.slice(root.length + 1), createHash('sha1').update(readFileSync(full)).digest('hex'))
838
+ }
839
+ }
840
+ if (existsSync(root)) walk(root)
841
+ return out
842
+ }
843
+ const sameTree = (a: Map<string, string>, b: Map<string, string>): boolean =>
844
+ a.size === b.size && [...a].every(([k, v]) => b.get(k) === v)
845
+
846
+ if (existsSync(skillsPath)) {
847
+ const liveSkillsRoot = join(appPath, '..', '..', '.devin', 'skills')
848
+ for (const dir of readdirSync(skillsPath, { withFileTypes: true })) {
849
+ if (!dir.isDirectory()) continue
850
+ if (!sameTree(snapshot(join(skillsPath, dir.name)), snapshot(join(liveSkillsRoot, dir.name)))) {
851
+ console.warn(`⚠️ agent/skills/${dir.name} differs from live custom/.devin/skills/${dir.name} — re-stage or reinstall`)
852
+ }
853
+ }
854
+ }
855
+
856
+ if (existsSync(instructionsPath)) {
857
+ if (!sameTree(snapshot(instructionsPath), snapshot(join(appPath, 'src', 'instructions')))) {
858
+ console.warn(`⚠️ agent/instructions differs from src/instructions — re-stage before packaging`)
859
+ }
860
+ }
861
+
862
+ if (existsSync(skillsPath) || existsSync(instructionsPath)) {
863
+ console.log(`✅ agent/ staged assets valid`)
864
+ }
865
+ }
866
+ // CIL[END stable_id="~/.framework/cli/commands/validate-app.ts:validateagentdirectory:function"]
867
+
743
868
  // CIL[BEGIN stable_id="~/.framework/cli/commands/validate-app.ts:validateAppStructure:function" type="function" identity="validateAppStructure" parent="~/.framework/cli/commands/validate-app.ts"]
744
869
  // CIL[DOC-AUDIENCE audience="CLI engineers and framework maintainers"]
745
870
  // CIL[DOC-MACRO purpose="Run the full app structure validation pipeline"]
@@ -755,6 +880,7 @@ export function validateAppStructure(appPath: string): void {
755
880
  validateRequiredFiles(appPath, manifest)
756
881
  validateAllowedDirectories(appPath)
757
882
  validateDevinDirectory(appPath)
883
+ validateAgentDirectory(appPath, manifest)
758
884
  validateRoutes(appPath, manifest)
759
885
  validateManifestComponents(appPath, manifest)
760
886
  validateSeedBlock(appPath, manifest)
@@ -49,6 +49,8 @@ export interface AppManifest {
49
49
  auth_required?: boolean
50
50
  preview_domain?: string
51
51
  prod_domain?: string
52
+ preview_route_prefix?: string
53
+ prod_route_prefix?: string
52
54
  }
53
55
  // CIL[DOC-VECTOR type="context" content="purpose='Describes the runtime configuration shape of a Spine app manifest'; No verified external callers; imports: none"]
54
56
  // CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
@@ -376,6 +376,8 @@ class _PermissionEngineInternal {
376
376
  required_roles: record.required_roles,
377
377
  preview_domain: record.preview_domain,
378
378
  prod_domain: record.prod_domain,
379
+ preview_route_prefix: record.preview_route_prefix,
380
+ prod_route_prefix: record.prod_route_prefix,
379
381
  is_active: record.is_active,
380
382
  is_system: record.is_system,
381
383
  renderer: record.renderer,
@@ -735,6 +737,8 @@ class _PermissionEngineInternal {
735
737
  required_roles: record.required_roles,
736
738
  preview_domain: record.preview_domain,
737
739
  prod_domain: record.prod_domain,
740
+ preview_route_prefix: record.preview_route_prefix,
741
+ prod_route_prefix: record.prod_route_prefix,
738
742
  is_active: record.is_active,
739
743
  is_system: record.is_system,
740
744
  renderer: record.renderer,
@@ -106,6 +106,16 @@ const PERMISSIONS_ALL = {
106
106
  // CIL[BEGIN stable_id="~/.framework/functions/admin-data.ts:VALID_ENTITIES:constant" type="constant" identity="VALID_ENTITIES" parent="~/.framework/functions/admin-data.ts"]
107
107
  const VALID_ENTITIES = ['accounts', 'people', 'items', 'threads', 'messages', 'links', 'attachments', 'watchers', 'item_progress', 'logs', 'test_runs', 'observability', 'test_cases', 'types', 'link_types', 'ai_agents', 'prompt_configs']
108
108
  // CIL[END stable_id="~/.framework/functions/admin-data.ts:VALID_ENTITIES:constant"]
109
+ // CIL[BEGIN stable_id="~/.framework/functions/admin-data.ts:ENTITIES_WITHOUT_IS_ACTIVE:constant" type="constant" identity="ENTITIES_WITHOUT_IS_ACTIVE" parent="none"]
110
+ // CIL[DOC-MACRO purpose="Entities whose table has no is_active column — the default soft-delete filter must skip them"]
111
+ // CIL[DOC-RESPONSIBILITY responsibility="List VALID_ENTITIES tables lacking is_active so the list handler only applies the default is_active filter where the column exists"]
112
+ // CIL[DOC-CONTEXT module="admin-data" layer="functions" related_files=".framework/migrations/001_core_schema.sql"]
113
+ // CIL[DOC-CONTRACT inputs="none" outputs="readonly Set<string> of entity names" errors="none" side_effects="none"]
114
+ // CIL[DOC-MICRO logic_summary="links, attachments, watchers, logs, and observability have no is_active column; applying the default filter to them yields 42703 column-does-not-exist" notable_decisions="['explicit_is_active_param_still_errors_on_these_tables']"]
115
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
116
+ // CIL[DOC-STABILITY stability="stable"]
117
+ const ENTITIES_WITHOUT_IS_ACTIVE = new Set(['links', 'attachments', 'watchers', 'logs', 'observability'])
118
+ // CIL[END stable_id="~/.framework/functions/admin-data.ts:ENTITIES_WITHOUT_IS_ACTIVE:constant"]
109
119
  // CIL[DOC-MACRO purpose="VALID_ENTITIES constant"]
110
120
  // CIL[DOC-RESPONSIBILITY responsibility="Implements the VALID_ENTITIES constant."]
111
121
  // CIL[DOC-CONTEXT module=".framework/functions/admin-data.ts" layer="app-specific" related_files=""]
@@ -295,8 +305,10 @@ export const list = createHandler(async (ctx, _body) => {
295
305
  })
296
306
 
297
307
  // Soft-deleted rows are hidden by default — pass is_active=false or
298
- // include_inactive=true to see them.
299
- if (filters.is_active === undefined && include_inactive !== 'true') {
308
+ // include_inactive=true to see them. Entities without an is_active
309
+ // column (links, attachments, watchers, logs, observability) skip the
310
+ // default filter — it would 500 with 42703 column-does-not-exist.
311
+ if (filters.is_active === undefined && include_inactive !== 'true' && !ENTITIES_WITHOUT_IS_ACTIVE.has(entity)) {
300
312
  query = query.eq('is_active', true)
301
313
  }
302
314
 
@@ -93,12 +93,25 @@ export const list = createHandler(async (ctx, body) => {
93
93
  throw new Error('Account context required')
94
94
  }
95
95
 
96
- const db = isSystemAdmin(ctx.principal) ? adminDb : ctx.db
97
-
98
96
  const userRoles = ctx.principal?.roles || []
99
97
  const isSysAdmin = userRoles.includes('system_admin')
98
+ const isSystem = isSysAdmin || (ctx.principal?.isInternal === true && !ctx.accountId)
99
+
100
+ // get_account_apps is service-role-only — the account boundary is enforced
101
+ // here. A scoped caller may only resolve apps inside its access set
102
+ // (own account plus descendants); the query param alone never widened that.
103
+ if (!isSystem && targetAccountId !== ctx.accountId) {
104
+ const { data: accessible, error: accessErr } = await adminDb
105
+ .rpc('get_accessible_accounts', { actor_id: ctx.principal?.id })
106
+ const ids = (accessible || []).map((r: any) => r.account_id ?? r)
107
+ if (accessErr || !ids.includes(targetAccountId)) {
108
+ const e: any = new Error('Account not accessible')
109
+ e.statusCode = 403
110
+ throw e
111
+ }
112
+ }
100
113
 
101
- const { data, error: err } = await db
114
+ const { data, error: err } = await adminDb
102
115
  .rpc('get_account_apps', {
103
116
  account_id: targetAccountId,
104
117
  include_system: include_system !== 'false',
@@ -204,7 +217,7 @@ export const getSchema = createHandler(async (ctx, body) => {
204
217
  // CIL[DOC-STABILITY stability="stable"]
205
218
  // CIL[BEGIN stable_id="~/.framework/functions/apps.ts:create:constant" type="constant" identity="create" parent="~/.framework/functions/apps.ts"]
206
219
  export const create = createHandler(async (ctx, body) => {
207
- const { slug, name, description, icon, color, version, app_type, source, owner_account_id, config, nav_items, min_role, required_roles, integration_deps, metadata, route_prefix, renderer } = body
220
+ const { slug, name, description, icon, color, version, app_type, source, owner_account_id, config, nav_items, min_role, required_roles, integration_deps, metadata, route_prefix, renderer, preview_domain, prod_domain, preview_route_prefix, prod_route_prefix } = body
208
221
 
209
222
  if (!slug || !name) {
210
223
  throw new Error('slug and name are required')
@@ -259,6 +272,10 @@ export const create = createHandler(async (ctx, body) => {
259
272
  metadata: metadata || {},
260
273
  route_prefix: route_prefix !== undefined ? route_prefix : ('/' + slug),
261
274
  renderer: renderer || 'generic',
275
+ preview_domain: preview_domain ?? null,
276
+ prod_domain: prod_domain ?? null,
277
+ preview_route_prefix: preview_route_prefix ?? null,
278
+ prod_route_prefix: prod_route_prefix ?? null,
262
279
  is_active: true,
263
280
  is_system: false
264
281
  })
@@ -1448,7 +1448,7 @@ export const cleanup = createHandler(async (ctx, body) => {
1448
1448
  // CIL[DOC-RESPONSIBILITY responsibility="Returns embeddings whose vectors are within a threshold of a query embedding."]
1449
1449
  // CIL[DOC-CONTEXT module="embeddings" layer="functions" related_files=".framework/functions/_shared/audit.ts,.framework/functions/_shared/db.ts,.framework/functions/_shared/llm.ts,.framework/functions/_shared/middleware.ts,.framework/functions/_shared/permissions.ts,.framework/functions/_shared/principal.ts,.framework/functions/embeddings.ts"]
1450
1450
  // CIL[DOC-CONTRACT inputs="ctx: RequestContext, body: { query_embedding, model_id?, limit?, threshold? }" outputs="{ results, count, model_id, threshold }" errors="throws if account or query_embedding missing, or RPC fails" side_effects="executes a database RPC"]
1451
- // CIL[DOC-MICRO logic_summary="Validates account and query_embedding, calls the search_similar_embeddings RPC, and returns the results, count, model, and threshold." notable_decisions="none"]
1451
+ // CIL[DOC-MICRO logic_summary="Validates account and query_embedding, calls the match_embeddings RPC via adminDb, and returns the results, count, model, and threshold." notable_decisions="adminDb (service role) required — the RPC is service-role-only; scoping is enforced here via filter_account_ids"]
1452
1452
  // CIL[DOC-CONFIDENCE pass="second" score="0.90" flags="none"]
1453
1453
  // CIL[DOC-STABILITY stability="stable"]
1454
1454
  // CIL[DOC-AUDIENCE audience="developers"]
@@ -1474,10 +1474,12 @@ export const searchSimilar = createHandler(async (ctx, body) => {
1474
1474
  throw new Error('Account context required')
1475
1475
  }
1476
1476
 
1477
- const { data, error: err } = await ctx.db.rpc('match_embeddings', {
1477
+ // adminDb (service role): match_embeddings is revoked from anon/authenticated.
1478
+ // Scoping is enforced here — non-system callers may not widen the corpus
1479
+ // beyond their own account.
1480
+ const { data, error: err } = await adminDb.rpc('match_embeddings', {
1478
1481
  query_embedding,
1479
1482
  match_count: parseInt(limit.toString()),
1480
- // Scoped callers may not widen the corpus beyond their own account.
1481
1483
  filter_account_ids: isSystem ? (filter_account_ids ?? null) : [ctx.accountId],
1482
1484
  filter_vector_type: filter_vector_type ?? null,
1483
1485
  similarity_threshold: parseFloat(threshold.toString()),
@@ -468,7 +468,7 @@ export const cleanupOldLogs = createHandler(async (ctx, body) => {
468
468
  throw new Error('System admin required')
469
469
  }
470
470
 
471
- const { data, error: err } = await ctx.db
471
+ const { data, error: err } = await adminDb
472
472
  .rpc('cleanup_old_logs', {
473
473
  p_retention_days: parseInt(days.toString()) || 30,
474
474
  })