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.
- package/.framework/cli/commands/install-app.ts +183 -13
- package/.framework/cli/commands/validate-app.ts +127 -1
- package/.framework/functions/_shared/app-manifest.ts +2 -0
- package/.framework/functions/_shared/permissions.ts +4 -0
- package/.framework/functions/admin-data.ts +14 -2
- package/.framework/functions/apps.ts +21 -4
- package/.framework/functions/embeddings.ts +5 -3
- package/.framework/functions/observability.ts +1 -1
- package/.framework/migrations/074_app_route_prefix_domains.sql +168 -0
- package/.framework/migrations/075_security_definer_grants.sql +157 -0
- package/.framework/src/contexts/AppContext.tsx +5 -12
- package/.framework/src/contexts/AuthContext.tsx +17 -9
- package/.framework/src/hooks/useApps.ts +18 -22
- package/.framework/src/instructions/README.md +17 -0
- package/.framework/src/instructions/accounts.md +97 -0
- package/.framework/src/instructions/apps.md +3 -3
- package/.framework/src/instructions/concepts/apps-and-renderers.md +68 -2
- package/.framework/src/instructions/internal/docs-contract.md +158 -0
- package/.framework/src/instructions/notifications.md +77 -0
- package/.framework/src/instructions/operations/security-checklist.md +24 -2
- package/.framework/src/instructions/people.md +91 -0
- package/.framework/src/instructions/reference/app-manifest.md +37 -3
- package/.framework/src/instructions/reference/frontend-sdk.md +207 -0
- package/.framework/src/instructions/reference/functions/admin-data.md +4 -2
- package/.framework/src/instructions/runtime-collaboration.md +106 -0
- package/.framework/src/instructions/tasks/document-your-app.md +117 -0
- package/.framework/src/instructions/tasks/package-and-install-an-app.md +14 -5
- package/.framework/src/instructions/tasks/register-an-app.md +137 -0
- package/.framework/src/instructions/timers.md +4 -2
- package/.framework/src/lib/app-mount.ts +88 -0
- package/.framework/src/pages/auth/LoginPage.tsx +3 -2
- package/.framework/src/pages/auth/RegisterPage.tsx +16 -8
- package/.framework/templates/agent/AGENTS.md +17 -0
- package/.framework/templates/agent/skills/spine-install-app/SKILL.md +7 -0
- package/.framework/templates/agent/skills/spine-new-app/SKILL.md +14 -2
- package/.framework/templates/agent/skills/spine-new-function/SKILL.md +6 -0
- package/.framework/templates/agent/skills/spine-new-type/SKILL.md +7 -0
- package/.framework/tests/unit/account-scoping-gates.test.ts +3 -1
- package/.framework/tests/unit/app-mount.test.ts +80 -0
- package/.framework/tests/unit/security-definer-grants.test.ts +154 -0
- package/package.json +1 -1
- package/scripts/checksum-manifest.mts +32 -2
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
471
|
+
const { data, error: err } = await adminDb
|
|
472
472
|
.rpc('cleanup_old_logs', {
|
|
473
473
|
p_retention_days: parseInt(days.toString()) || 30,
|
|
474
474
|
})
|