diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 32c6499b..981763a0 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -62,6 +62,14 @@ import type { import { createMemoryRouter } from './routes/memory.js'; import { createDatasetsRouter } from './routes/datasets.js'; import { createBulkPromotionRouter } from './routes/bulkPromotion.js'; +import { createSkillPromotionRouter } from './routes/skillPromotion.js'; +import { PgSkillOwnershipLifecycleStore } from './services/skillLifecycleStore.js'; +import { resolveSkillManifestSigningKey } from './services/skillManifestSigningKey.js'; +import { createCredentialAskRouter } from './routes/credentialAsks.js'; +import { InMemoryCredentialAskStore } from './credentials/asks.js'; +import { PostgresCredentialAskStore } from './credentials/postgresCredentialAskStore.js'; +import { resolveCredentialMasterKey } from './credentials/crypto.js'; +import { createCredentialStore } from './credentials/credentialStoreFactory.js'; import { createInconsistenciesRouter } from './routes/inconsistencies.js'; import { createDuplicatesRouter } from './routes/duplicates.js'; import { createTopicsRouter } from './routes/topics.js'; @@ -803,6 +811,30 @@ async function main(): Promise { // runtimes are constructed) because it doubles as the key the `ctx.flows` // toolkit signs plugin-flow state with (spec 004 FR-B3). const sessionSigningKey = await resolveSessionSigningKey(secretVault); + // #778 W1 — HMAC key `promoteSkillOwnerScope` (#577 P3) re-signs a skill's + // manifest with. Resolved here alongside the session key: same vault, + // same "generate once, persist, reuse every boot" pattern — see + // `services/skillManifestSigningKey.ts`. + const skillManifestSigningKey = await resolveSkillManifestSigningKey(secretVault); + // #778 W1 — the credential keychain's own master key (#578 Phase 1), + // resolved but never actually used anywhere until now. Same + // `resolveMasterKey` call `credentials/crypto.ts`'s module doc documents + // (`CREDENTIAL_KEYCHAIN_KEY` env, deliberately a DIFFERENT key/env var than + // `VAULT_KEY` — different trust domain). Needed here because + // `InMemoryCredentialAskStore` (the no-Postgres fallback) holds a live + // `CredentialStore` reference to validate an ask's `credentialId` in + // process, the same way `PostgresCredentialAskStore` validates it via SQL. + const credentialMasterKey = await resolveCredentialMasterKey( + DATA_DIR, + process.env['NODE_ENV'] === 'production', + ); + if (credentialMasterKey.source === 'env') { + console.log('[middleware] credential-keychain master key loaded from CREDENTIAL_KEYCHAIN_KEY env'); + } else if (credentialMasterKey.source === 'dev-file-existed') { + console.log('[middleware] ⚠ credential-keychain master key loaded from dev file — set CREDENTIAL_KEYCHAIN_KEY for production'); + } else { + console.warn('[middleware] ⚠ credential-keychain master key GENERATED (dev file) — DEV ONLY. Set CREDENTIAL_KEYCHAIN_KEY for production.'); + } // Spec 004 (FR-B5) — origin plugin flow callbacks resolve against. const flowPublicBaseUrl = config.FLOW_PUBLIC_BASE_URL ?? config.PUBLIC_BASE_URL; @@ -2952,6 +2984,53 @@ async function main(): Promise { ); } + // #778 W1 — #577 P3's admin-gated skill promotion route. Deliberately + // deferred by #771 to keep that PR's blast radius to new files only (see + // its "Not in this PR" section) — this is the mount. `PgSkillOwnershipLifecycleStore` + // needs a real Postgres pool (raw SQL over the `skills` table's #577 + // columns), so it is only constructed/mounted when `graphPool` is + // available, the same gate `bulkPromotionService` above uses. `requireAuth` + // gates the router; the router's own `requireSessionUserId` check replicates + // the `routes/bulkPromotion.ts` auth chain exactly (single-tenant byte5 — + // every authenticated session is an operator). + if (graphPool) { + const skillLifecycleStore = new PgSkillOwnershipLifecycleStore(graphPool); + app.use( + '/api/v1/admin/skills', + requireAuth, + createSkillPromotionRouter({ store: skillLifecycleStore, signingKey: skillManifestSigningKey }), + ); + console.log( + '[middleware] skill-promotion endpoint ready at /api/v1/admin/skills/:skillId/promote', + ); + } else { + console.log( + '[middleware] skill-promotion endpoint skipped — no graphPool (Neon backend missing?)', + ); + } + + // #778 W1 — #578 Phase 3's keychain-asks HTTP surface. Built and + // route-tested by #774 but deliberately left unmounted (same "new files + // only" blast-radius discipline as #577 P3) — this is the mount. + // `CredentialAskStore` follows the exact backend-choice precedent + // `credentials/credentialStoreFactory.ts` documents for the credential + // keychain itself: Postgres when a pool is configured, in-memory + // otherwise (works within one process; asks do not survive a restart). + // `requireAuth` gates the router, per that file's own module doc + // ("behind `requireAuth` like every other `/api/v1/admin/*` router"). + const { store: credentialStoreForAsks } = createCredentialStore(graphPool, credentialMasterKey.key); + const credentialAskStore = graphPool + ? new PostgresCredentialAskStore(graphPool) + : new InMemoryCredentialAskStore(credentialStoreForAsks); + app.use( + '/api/v1/admin/credential-asks', + requireAuth, + createCredentialAskRouter({ store: credentialAskStore }), + ); + console.log( + `[middleware] credential-asks endpoint ready at /api/v1/admin/credential-asks (backend=${graphPool ? 'postgres' : 'in-memory'})`, + ); + // Slice 9 — inconsistency detection workflow. Always mount (the // routes work without a detector — manual /detect 503s, list/get/ // resolve work because they only touch the KG). Resolve hits the diff --git a/middleware/src/routes/skillPromotion.ts b/middleware/src/routes/skillPromotion.ts new file mode 100644 index 00000000..8cfecece --- /dev/null +++ b/middleware/src/routes/skillPromotion.ts @@ -0,0 +1,125 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import { z } from 'zod'; + +import { + SkillLifecycleTransitionRejected, + type PgSkillOwnershipLifecycleStore, + type SkillOwnershipLifecycleRow, +} from '../services/skillLifecycleStore.js'; +import { SkillAutomationWriteBlocked } from '../services/skillLifecycle.js'; + +/** + * #778 W1 — REST surface for `PgSkillOwnershipLifecycleStore.promoteSkillOwnerScope` + * (#577 P3), the only path a skill ever reaches `group`/`org` ownership. + * Mounted under `/api/v1/admin/skills`. + * + * One endpoint: + * POST /:skillId/promote → promote an already-published skill to a + * team (group) or org home, re-signing its + * manifest at the new owner scope. + * + * Auth follows the EXACT `routes/bulkPromotion.ts` precedent + * (`req.session.omadia_user_id`, single-tenant byte5 — every authenticated + * session is an operator). `promoteSkillOwnerScope` itself has no notion of + * roles; this route's session check IS the "admin-gated" half #577 P3's PR + * description explicitly left to the route layer. A subtly wrong auth check + * here is a security regression — this is why the route was not rushed + * alongside the service layer. + */ + +const TargetScopeSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('group'), groupRef: z.string().min(1) }), + z.object({ kind: z.literal('org'), orgId: z.string().min(1) }), +]); + +const PromoteBodySchema = z.object({ + targetScope: TargetScopeSchema, +}); + +function requireSessionUserId(req: Request, res: Response): string | null { + const id = req.session?.omadia_user_id; + if (!id) { + res.status(401).json({ code: 'auth.required', message: 'login required' }); + return null; + } + return id; +} + +function toSkillBody(row: SkillOwnershipLifecycleRow) { + return { + id: row.id, + slug: row.slug, + name: row.name, + ownerScope: row.ownerScope, + lifecycleStatus: row.lifecycleStatus, + manifestSignedAt: row.manifestSignedAt ? row.manifestSignedAt.toISOString() : null, + }; +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export interface SkillPromotionRouteDeps { + /** Narrowed to the one method this route calls (`Pick`, not the concrete + * class) so a test double can stand in without a real `Pool`. */ + readonly store: Pick; + /** HMAC key `promoteSkillOwnerScope` re-signs the manifest with — see + * `services/skillManifestSigningKey.ts`. */ + readonly signingKey: string; +} + +export function createSkillPromotionRouter(deps: SkillPromotionRouteDeps): Router { + const router = Router(); + + router.post('/:skillId/promote', async (req: Request, res: Response): Promise => { + const sessionUserId = requireSessionUserId(req, res); + if (!sessionUserId) return; + + const skillId = req.params.skillId as string; + const parsed = PromoteBodySchema.safeParse(req.body ?? {}); + if (!parsed.success) { + res.status(400).json({ code: 'skill_promotion.invalid_request', issues: parsed.error.issues }); + return; + } + + try { + const updated = await deps.store.promoteSkillOwnerScope(skillId, parsed.data.targetScope, { + actorScope: { kind: 'personal', userId: sessionUserId }, + signingKey: deps.signingKey, + }); + res.json(toSkillBody(updated)); + } catch (err) { + if (err instanceof SkillAutomationWriteBlocked) { + // Unreachable today (actorScope is always 'personal' here, never + // 'system') but handled explicitly rather than falling into the + // generic 500 branch below — a machine actor being rejected is a + // 403, not a server error. + res.status(403).json({ code: 'skill_promotion.automation_blocked', message: err.message }); + return; + } + if (err instanceof SkillLifecycleTransitionRejected) { + res.status(409).json({ + code: 'skill_promotion.transition_rejected', + reason: err.reason, + ...(err.missing ? { missing: err.missing } : {}), + message: err.message, + }); + return; + } + const message = errMsg(err); + if (message.includes('not found')) { + res.status(404).json({ code: 'skill_promotion.not_found', message }); + return; + } + if (message.includes('is not published') || message.includes('has no owner scope yet')) { + res.status(409).json({ code: 'skill_promotion.not_eligible', message }); + return; + } + res.status(500).json({ code: 'skill_promotion.failed', message }); + } + }); + + return router; +} diff --git a/middleware/src/services/skillManifestSigningKey.ts b/middleware/src/services/skillManifestSigningKey.ts new file mode 100644 index 00000000..4826eead --- /dev/null +++ b/middleware/src/services/skillManifestSigningKey.ts @@ -0,0 +1,35 @@ +import crypto from 'node:crypto'; + +import type { SecretVault } from '../secrets/vault.js'; + +/** + * #778 W1 — the HMAC key `signSkillManifest`/`promoteSkillOwnerScope` + * (#577 P1/P3) sign a skill's tamper-evident manifest with. + * + * Mirrors `auth/sessionSigningKey.ts` exactly: generate on first call, + * persist in the vault so every subsequent boot (and any replacement process + * reading the same vault) re-signs with the SAME key — a rotation here + * invalidates every previously-issued manifest signature, the same + * "log everyone out" trade-off `resolveSessionSigningKey` documents for + * cookies. + * + * A dedicated vault scope (`core:skills`), not `core:auth` — this key signs + * data-integrity artefacts, not authentication tokens. Different trust + * domain, same reasoning `credentials/crypto.ts` gives for keeping the + * credential-keychain master key separate from the provider-secret vault's: + * a single compromised key should not unlock both. + */ +export const CORE_SKILLS_AGENT_ID = 'core:skills'; + +const SIGNING_KEY_VAULT_KEY = 'skill_manifest_signing_key'; +const KEY_BYTES = 32; + +export async function resolveSkillManifestSigningKey( + vault: SecretVault, +): Promise { + const existing = await vault.get(CORE_SKILLS_AGENT_ID, SIGNING_KEY_VAULT_KEY); + if (existing) return existing; + const fresh = crypto.randomBytes(KEY_BYTES).toString('hex'); + await vault.set(CORE_SKILLS_AGENT_ID, SIGNING_KEY_VAULT_KEY, fresh); + return fresh; +} diff --git a/middleware/test/778RouteMounts.wiring.test.ts b/middleware/test/778RouteMounts.wiring.test.ts new file mode 100644 index 00000000..0072f6dc --- /dev/null +++ b/middleware/test/778RouteMounts.wiring.test.ts @@ -0,0 +1,101 @@ +/** + * #778 W1 — composition-root wiring regression. + * + * The exact bug class this issue exists to close: `routes/credentialAsks.ts` + * (#774) and `PgSkillOwnershipLifecycleStore.promoteSkillOwnerScope` (#577 + * P3) each shipped fully built and fully route/unit-tested — and stayed + * unreachable for a whole phase because nobody added the one-line + * `app.use(...)` in `src/index.ts`. A router's OWN test suite (e.g. + * `credentialAskRoutes.test.ts`, which mounts the router into its own + * throwaway `express()` app) passes identically whether or not `index.ts` + * ever mounts it — that is the "passes every route test" trap the issue + * names explicitly. + * + * `src/index.ts` runs `main().catch(...)` unconditionally at import time + * (DB pools, mDNS, plugin catalog, `app.listen`), so it cannot be imported + * or booted from a unit test without a full deployment's worth of config — + * no test in this repo does that (verified: zero references to + * `src/index.ts` from `test/**`). So this test drives the actual source + * text of the composition root instead of executing it: it is the + * deterministic half of "prove the mount," catching exactly the failure + * mode of a route file existing, fully tested standalone, but never called + * from `index.ts`. Reworded per the #470 ratchet's own guidance (never + * touch a baseline it matches) — this file matches no ratchet pattern. + * + * The second half — that the mounted router actually behaves correctly at + * runtime — is proven by `skillPromotionRoute.test.ts` (live `app.listen(0)` + * + real `fetch`, this repo's established router-test pattern; see + * `credentialAskRoutes.test.ts` and `adminProvidersRoute.test.ts`) and by + * the pre-existing `credentialAskRoutes.test.ts` for the ask surface. + */ + +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const middlewareRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const indexSource = readFileSync(resolve(middlewareRoot, 'src', 'index.ts'), 'utf8'); + +/** Strip line comments so a mount reference sitting only in a `//` doc + * comment can never satisfy this check — it must be live code. */ +function withoutLineComments(src: string): string { + return src + .split('\n') + .map((line) => line.replace(/\/\/.*$/, '')) + .join('\n'); +} + +const liveIndexSource = withoutLineComments(indexSource); + +describe('#778 W1 — index.ts actually mounts the #577/#578 routers', () => { + it('imports createSkillPromotionRouter from routes/skillPromotion.js', () => { + assert.match( + indexSource, + /import\s*\{\s*createSkillPromotionRouter\s*\}\s*from\s*'\.\/routes\/skillPromotion\.js';/, + 'src/index.ts must import createSkillPromotionRouter — a route module that exists but is never imported can never be mounted', + ); + }); + + it('mounts the skill-promotion router at /api/v1/admin/skills behind requireAuth', () => { + assert.match( + liveIndexSource, + /app\.use\(\s*'\/api\/v1\/admin\/skills',\s*requireAuth,\s*createSkillPromotionRouter\(/, + "app.use('/api/v1/admin/skills', requireAuth, createSkillPromotionRouter(...)) must appear as LIVE code in index.ts, not only in a comment", + ); + }); + + it('imports createCredentialAskRouter from routes/credentialAsks.js', () => { + assert.match( + indexSource, + /import\s*\{\s*createCredentialAskRouter\s*\}\s*from\s*'\.\/routes\/credentialAsks\.js';/, + 'src/index.ts must import createCredentialAskRouter — #774 built and route-tested this router but deliberately left it unmounted', + ); + }); + + it('mounts the credential-asks router at /api/v1/admin/credential-asks behind requireAuth', () => { + assert.match( + liveIndexSource, + /app\.use\(\s*'\/api\/v1\/admin\/credential-asks',\s*requireAuth,\s*createCredentialAskRouter\(/, + "app.use('/api/v1/admin/credential-asks', requireAuth, createCredentialAskRouter(...)) must appear as LIVE code in index.ts, not only in a comment", + ); + }); + + it('regression guard: fails if the skill-promotion mount line is commented out', () => { + // Proves the "strip comments" step above actually does something — + // without it, commenting out the app.use(...) line would still match + // the raw-source regex and this whole test file would be a no-op. + const withMountCommentedOut = indexSource.replace( + /app\.use\(\s*'\/api\/v1\/admin\/skills',\s*requireAuth,\s*createSkillPromotionRouter\(/, + (m) => `// ${m}`, + ); + assert.notEqual(withMountCommentedOut, indexSource, 'the mount line must exist to be commented out by this check'); + const strippedIfCommented = withoutLineComments(withMountCommentedOut); + assert.doesNotMatch( + strippedIfCommented, + /app\.use\(\s*'\/api\/v1\/admin\/skills',\s*requireAuth,\s*createSkillPromotionRouter\(/, + 'a commented-out mount must not satisfy the live-code check', + ); + }); +}); diff --git a/middleware/test/skillPromotionRoute.test.ts b/middleware/test/skillPromotionRoute.test.ts new file mode 100644 index 00000000..a2f8bb56 --- /dev/null +++ b/middleware/test/skillPromotionRoute.test.ts @@ -0,0 +1,198 @@ +/** + * #778 W1 — the HTTP surface for `PgSkillOwnershipLifecycleStore.promoteSkillOwnerScope` + * (#577 P3), end to end against a real Express app (`app.listen(0, ...)` + + * real `fetch`), the same pattern `credentialAskRoutes.test.ts` and + * `adminProvidersRoute.test.ts` use. + * + * `store` is a fake implementing only `promoteSkillOwnerScope` — the route's + * deps type is deliberately `Pick` so this test never needs a real `Pool`. The + * store's own promotion logic (published-only gate, cron-actor guard, + * re-signing) is covered by `test/skillOwnershipLifecycleStore.pg.test.ts`; + * this file covers the ROUTE layer only: session auth (the precedent this + * route was explicitly required to replicate exactly from + * `routes/bulkPromotion.ts`), request validation, and error-to-status + * mapping. + */ + +import { strict as assert } from 'node:assert'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { describe, it } from 'node:test'; + +import express, { type Express } from 'express'; + +import { createSkillPromotionRouter, type SkillPromotionRouteDeps } from '../src/routes/skillPromotion.js'; +import { SkillAutomationWriteBlocked } from '../src/services/skillLifecycle.js'; +import type { PgSkillOwnershipLifecycleStore } from '../src/services/skillLifecycleStore.js'; +import type { ScopeId } from '@omadia/channel-sdk'; + +type PromoteArgs = Parameters; + +class FakeSkillLifecycleStore { + public calls: PromoteArgs[] = []; + public behavior: 'ok' | 'not-found' | 'not-published' | 'automation-blocked' = 'ok'; + + async promoteSkillOwnerScope(...args: PromoteArgs) { + this.calls.push(args); + const [skillId, targetScope] = args; + if (this.behavior === 'not-found') { + throw new Error(`skill ${skillId} not found`); + } + if (this.behavior === 'not-published') { + throw new Error(`skill ${skillId} is not published (status: draft) — only a published skill may be promoted`); + } + if (this.behavior === 'automation-blocked') { + throw new SkillAutomationWriteBlocked({ kind: 'system', origin: 'schedule', id: 'x' }); + } + return { + id: skillId, + slug: 'demo-skill', + name: 'Demo Skill', + frontmatter: {}, + body: '', + ownerScope: + targetScope.kind === 'group' ? `group:${targetScope.groupRef}` : `org:${targetScope.orgId}`, + lifecycleStatus: 'published' as const, + manifestSignature: 'deadbeef', + manifestSignedAt: new Date('2026-08-20T12:00:00Z'), + }; + } +} + +function buildApp(store: FakeSkillLifecycleStore, deps: Partial = {}): Express { + const app = express(); + app.use(express.json()); + app.use('/api/v1/admin/skills', (req, _res, next) => { + const withSession = req as typeof req & { session?: { omadia_user_id?: string } }; + // Stand in for requireAuth, which the real mount puts in front of this + // router — a query flag lets individual tests exercise the "no session" + // (401) path without a second app instance. + if (req.query['noSession'] !== '1') { + withSession.session = { + sub: 'sub-op-1', + email: 'op@example.com', + display_name: 'Operator One', + provider: 'local', + role: 'admin', + omadia_user_id: 'op-1', + }; + } + next(); + }); + app.use( + '/api/v1/admin/skills', + createSkillPromotionRouter({ store, signingKey: 'test-signing-key', ...deps }), + ); + return app; +} + +async function withServer(app: Express, run: (baseUrl: string) => Promise): Promise { + const server: Server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + const port = (server.address() as AddressInfo).port; + try { + return await run(`http://127.0.0.1:${String(port)}/api/v1/admin/skills`); + } finally { + await new Promise((resolve) => server.close(() => resolve(undefined))); + } +} + +async function postJson(url: string, body: unknown): Promise<{ status: number; body: Record }> { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: (await res.json()) as Record }; +} + +describe('#778 W1 skill-promotion route', () => { + it('401s with auth.required when no session is present', async () => { + const store = new FakeSkillLifecycleStore(); + const app = buildApp(store); + await withServer(app, async (baseUrl) => { + const res = await postJson(`${baseUrl}/skill-1/promote?noSession=1`, { + targetScope: { kind: 'org', orgId: 'byte5' }, + }); + assert.equal(res.status, 401); + assert.equal(res.body['code'], 'auth.required'); + assert.equal(store.calls.length, 0, 'the store must never be called without a session'); + }); + }); + + it('400s on a malformed targetScope', async () => { + const store = new FakeSkillLifecycleStore(); + const app = buildApp(store); + await withServer(app, async (baseUrl) => { + const res = await postJson(`${baseUrl}/skill-1/promote`, { targetScope: { kind: 'personal' } }); + assert.equal(res.status, 400); + assert.equal(res.body['code'], 'skill_promotion.invalid_request'); + assert.equal(store.calls.length, 0); + }); + }); + + it('promotes to an org scope and echoes the actorScope built from the session', async () => { + const store = new FakeSkillLifecycleStore(); + const app = buildApp(store); + await withServer(app, async (baseUrl) => { + const res = await postJson(`${baseUrl}/skill-1/promote`, { + targetScope: { kind: 'org', orgId: 'byte5' }, + }); + assert.equal(res.status, 200); + assert.equal(res.body['ownerScope'], 'org:byte5'); + assert.equal(store.calls.length, 1); + const [skillId, targetScope, opts] = store.calls[0]!; + assert.equal(skillId, 'skill-1'); + assert.deepEqual(targetScope, { kind: 'org', orgId: 'byte5' }); + assert.deepEqual(opts.actorScope, { kind: 'personal', userId: 'op-1' }); + assert.equal(opts.signingKey, 'test-signing-key'); + }); + }); + + it('promotes to a group (team) scope', async () => { + const store = new FakeSkillLifecycleStore(); + const app = buildApp(store); + await withServer(app, async (baseUrl) => { + const res = await postJson(`${baseUrl}/skill-2/promote`, { + targetScope: { kind: 'group', groupRef: 'platform-team' }, + }); + assert.equal(res.status, 200); + assert.equal(res.body['ownerScope'], 'group:platform-team'); + }); + }); + + it('404s when the store reports the skill was not found', async () => { + const store = new FakeSkillLifecycleStore(); + store.behavior = 'not-found'; + const app = buildApp(store); + await withServer(app, async (baseUrl) => { + const res = await postJson(`${baseUrl}/missing/promote`, { targetScope: { kind: 'org', orgId: 'byte5' } }); + assert.equal(res.status, 404); + assert.equal(res.body['code'], 'skill_promotion.not_found'); + }); + }); + + it('409s when the store refuses an unpublished skill', async () => { + const store = new FakeSkillLifecycleStore(); + store.behavior = 'not-published'; + const app = buildApp(store); + await withServer(app, async (baseUrl) => { + const res = await postJson(`${baseUrl}/draft-1/promote`, { targetScope: { kind: 'org', orgId: 'byte5' } }); + assert.equal(res.status, 409); + assert.equal(res.body['code'], 'skill_promotion.not_eligible'); + }); + }); + + it('403s when the store rejects a machine actor (defensive branch)', async () => { + const store = new FakeSkillLifecycleStore(); + store.behavior = 'automation-blocked'; + const app = buildApp(store); + await withServer(app, async (baseUrl) => { + const res = await postJson(`${baseUrl}/skill-1/promote`, { targetScope: { kind: 'org', orgId: 'byte5' } }); + assert.equal(res.status, 403); + assert.equal(res.body['code'], 'skill_promotion.automation_blocked'); + }); + }); +});