diff --git a/middleware/src/services/skillLifecycle.ts b/middleware/src/services/skillLifecycle.ts index 7eca2a96..0a3bf4df 100644 --- a/middleware/src/services/skillLifecycle.ts +++ b/middleware/src/services/skillLifecycle.ts @@ -48,6 +48,45 @@ export function isSkillOwnerScope(scope: ScopeId): scope is SkillOwnerScope { return scope.kind === 'personal' || scope.kind === 'group' || scope.kind === 'org'; } +// ── Automation write-guard (#577 Kernkonzept #6) ──────────────────────── + +/** + * Thrown when a skill-mutating call is attempted by a machine actor. Carries + * the rejected `ScopeId` so a caller (route layer, log line) can report which + * automation origin was blocked without re-deriving it from the message. + */ +export class SkillAutomationWriteBlocked extends Error { + readonly actorScope: ScopeId; + + constructor(actorScope: ScopeId) { + const origin = actorScope.kind === 'system' ? `:${actorScope.origin}` : ''; + super( + `skill mutation blocked: actor scope is a machine origin ('${actorScope.kind}${origin}') — only a live human may modify a skill`, + ); + this.name = 'SkillAutomationWriteBlocked'; + this.actorScope = actorScope; + } +} + +/** + * "Automations (crons) may not modify skills — as an enforced guard, not a + * convention" (#577 Kernkonzept #6). `ScopeId`'s own contract (`scopeId.ts`) + * is what makes this a one-line check rather than a new taxonomy: `kind: + * 'system'` is explicitly documented there as "no human is present in any of + * them" — routines, schedules and the Conductor's own automated runs. Every + * OTHER `ScopeId` kind — including `unscoped` — arises from an actual live + * turn, so `system` is the exact and only boundary this guard needs. + * + * Callers pass the ACTOR's scope (who/what is performing the write), never + * the skill's `ownerScope` (whose home it is) — those are unrelated axes; an + * org-owned skill can still be legitimately edited by a live human operator. + */ +export function assertHumanActor(actorScope: ScopeId): void { + if (actorScope.kind === 'system') { + throw new SkillAutomationWriteBlocked(actorScope); + } +} + // ── Lifecycle status ───────────────────────────────────────────────────── export const SKILL_LIFECYCLE_STATUSES = ['draft', 'reviewed', 'published', 'archived'] as const; diff --git a/middleware/src/services/skillLifecycleStore.ts b/middleware/src/services/skillLifecycleStore.ts index 675347ac..c259c50d 100644 --- a/middleware/src/services/skillLifecycleStore.ts +++ b/middleware/src/services/skillLifecycleStore.ts @@ -17,12 +17,15 @@ import { computeSkillHash } from '@omadia/orchestrator'; import { formatSessionScope, parseSessionScope, type ScopeId } from '@omadia/channel-sdk'; import { + assertHumanActor, canonicalSkillManifest, isSkillOwnerScope, requiredCapabilitiesFromFrontmatter, + signSkillManifest, transitionSkillLifecycle, type SkillLifecycleStatus, type SkillLifecycleTransitionResult, + type SkillOwnerScope, } from './skillLifecycle.js'; export interface SkillOwnershipLifecycleRow { @@ -102,13 +105,22 @@ export class PgSkillOwnershipLifecycleStore { /** * Assign a PERSONAL owner to a still-unowned draft skill. This is the ONLY * direct-assignment path: #577 Kernkonzept #5 forbids creating a skill - * directly in team/org scope — those homes are reached only through the - * admin-gated promotion route (P3). Refuses to reassign an already-owned - * skill (call the — not-yet-built — promotion path for that) and refuses a + * directly in team/org scope — those homes are reached only through + * {@link promoteSkillOwnerScope} (admin-gated, P3). Refuses to reassign an + * already-owned skill (call `promoteSkillOwnerScope` for that) and refuses a * non-draft target (ownership must be settled before review begins, since * `ownerScope` is part of what gets signed). + * + * `actorScope` is who is performing the write — checked by + * {@link assertHumanActor} (#577 Kernkonzept #6) BEFORE any query runs, so + * an automation actor never even reaches the database. */ - async assignPersonalOwner(skillId: string, owner: Extract): Promise { + async assignPersonalOwner( + skillId: string, + owner: Extract, + actorScope: ScopeId, + ): Promise { + assertHumanActor(actorScope); const result = await this.pool.query( `UPDATE skills SET owner_scope = $2, updated_at = now() WHERE id = $1 AND owner_scope IS NULL AND lifecycle_status = 'draft'`, @@ -127,13 +139,15 @@ export class PgSkillOwnershipLifecycleStore { /** * Move a skill's lifecycle status, re-signing its manifest on success. * Throws `SkillLifecycleTransitionRejected` for every rejected move — never - * returns a "false-ish" result a caller could accidentally ignore. + * returns a "false-ish" result a caller could accidentally ignore. Checks + * {@link assertHumanActor} first, same as every other mutating method here. */ async transition( skillId: string, targetStatus: SkillLifecycleStatus, - opts: { readonly granted: ReadonlySet; readonly signingKey: string }, + opts: { readonly granted: ReadonlySet; readonly signingKey: string; readonly actorScope: ScopeId }, ): Promise { + assertHumanActor(opts.actorScope); const row = await this.getSkill(skillId); if (!row) throw new Error(`skill ${skillId} not found`); if (row.ownerScope === null) { @@ -163,6 +177,66 @@ export class PgSkillOwnershipLifecycleStore { if (!updated.rows[0]) throw new Error(`skill ${skillId} vanished during transition`); return mapRow(updated.rows[0]); } + + /** + * Admin-gated promotion: move an ALREADY-PUBLISHED skill to a team (`group`) + * or org home. This is the only way a skill ever reaches team/org + * ownership — #577 Kernkonzept #5 forbids creating one there directly, and + * `assignPersonalOwner` above only ever assigns `personal`. Callers are + * responsible for the "admin-gated" half (an authenticated-session check at + * the route layer, P3/P4 — this method has no notion of roles); what it + * enforces itself is: + * + * - {@link assertHumanActor} — a cron may not promote a skill, same as it + * may not do anything else to one (#577 Kernkonzept #6); + * - the skill must currently be `published` — an unreviewed draft has no + * business reaching a wider audience, and archiving/promoting are not + * composable (an archived skill must be republished first, if that ever + * becomes a supported path); + * - the manifest is re-signed at the NEW `ownerScope`, same `published` + * status — `ownerScope` is a signed field (#577 P1), so a promotion IS a + * signature-changing event, not just a column update. + */ + async promoteSkillOwnerScope( + skillId: string, + targetScope: Extract, + opts: { readonly actorScope: ScopeId; readonly signingKey: string }, + ): Promise { + assertHumanActor(opts.actorScope); + const row = await this.getSkill(skillId); + if (!row) throw new Error(`skill ${skillId} not found`); + if (row.ownerScope === null) { + throw new Error(`skill ${skillId} has no owner scope yet — assign one before promoting`); + } + if (row.lifecycleStatus !== 'published') { + throw new Error(`skill ${skillId} is not published (status: ${row.lifecycleStatus}) — only a published skill may be promoted`); + } + + const newOwnerScope = formatSessionScope(targetScope); + const contentHash = computeSkillHash(row.frontmatter, row.body); + const requiredCapabilities = requiredCapabilitiesFromFrontmatter(row.frontmatter); + const signature = signSkillManifest( + { + slug: row.slug, + name: row.name, + ownerScope: newOwnerScope, + status: row.lifecycleStatus, + contentHash, + requiredCapabilities, + }, + opts.signingKey, + ); + + const updated = await this.pool.query( + `UPDATE skills SET owner_scope = $2, manifest_signature = $3, manifest_signed_at = now(), updated_at = now() + WHERE id = $1 + RETURNING id, slug, name, frontmatter, body, owner_scope, lifecycle_status, + manifest_signature, manifest_signed_at`, + [skillId, newOwnerScope, signature], + ); + if (!updated.rows[0]) throw new Error(`skill ${skillId} vanished during promotion`); + return mapRow(updated.rows[0]); + } } /** Re-exported for callers that only need to verify a row without a Pool (e.g. a webhook). */ diff --git a/middleware/src/services/skillSharing.ts b/middleware/src/services/skillSharing.ts new file mode 100644 index 00000000..23b37911 --- /dev/null +++ b/middleware/src/services/skillSharing.ts @@ -0,0 +1,95 @@ +/** + * #577 P3 — sharing = a grant over `GrantStore` (#575), not a parallel ACL. + * + * Kernkonzept #5: "Sharing ist grant-basiert (`share` = ACL-Grant, `move` = + * Home-Wechsel)". This module is the one place that knows how a skill's + * "shared with me" fact is encoded as a `Capability` string, and how to turn + * a resolved capability set back into the `sharedSkillIds` P2's + * `resolveSkillByName` (`skillResolver.ts`) needs. It never touches + * `grants.ts`'s body — only its exported `GrantStore`/`resolveCapabilities` + * contract, per the #577 binding surface separation. + * + * ## Fail-closed, not "unresolved is a type" — and why THIS module differs + * + * `resolveCapabilities` (#575) treats a partial role lookup as `undefined` + * ("unresolved"), because collapsing it to an empty capability set would + * silently look like real policy on the audience-floor path where too few + * capabilities can wrongly widen who is excluded from a room. Skill sharing + * has the opposite risk shape: a grants-backend hiccup here can only ever + * HIDE a skill that would have been visible, never reveal one that + * shouldn't be — the personal/team/org buckets in `skillResolver.ts` are + * completely independent of this module and still work. So + * `resolveSharedSkillIds` still surfaces the `unresolved` fact (never lies + * about it), but `toSharedSkillIdsSet` gives callers who just want the + * resolver's `sharedSkillIds` input a one-line fail-closed default — + * "no visible shares right now" is a safe, honest degradation here in a way + * it is NOT on the audience floor. + */ + +import { + resolveCapabilities, + type GrantStore, + type Principal, + type RoleSourceRegistry, +} from '@omadia/channel-sdk'; + +/** The `Capability` prefix that encodes "read access to this specific skill". */ +const SHARED_SKILL_CAPABILITY_PREFIX = 'skill:read:'; + +/** The `Capability` string a grant records to share `skillId` with its holder. */ +export function sharedSkillCapability(skillId: string): string { + return `${SHARED_SKILL_CAPABILITY_PREFIX}${skillId}`; +} + +/** Extracts the skill id from a capability string, or `undefined` if it isn't a skill-share capability. */ +export function parseSharedSkillCapability(capability: string): string | undefined { + if (!capability.startsWith(SHARED_SKILL_CAPABILITY_PREFIX)) return undefined; + const id = capability.slice(SHARED_SKILL_CAPABILITY_PREFIX.length); + return id.length > 0 ? id : undefined; +} + +export type SharedSkillIdsResult = + | { readonly ok: true; readonly ids: ReadonlySet } + | { readonly ok: false; readonly reason: 'unresolved' }; + +/** + * Resolve every skill id shared with `principal`, via `GrantStore` + + * `RoleSourceRegistry` (#575/#333) — direct grants union role grants, exactly + * as `resolveCapabilities` already defines for any other capability. Returns + * `{ ok: false, reason: 'unresolved' }` when the underlying role lookup was + * partial or the store threw — same trigger as `resolveCapabilities` + * returning `undefined` — so a caller that cares CAN distinguish "nothing is + * shared" from "we couldn't ask". A caller that doesn't care uses + * {@link toSharedSkillIdsSet}. + */ +export async function resolveSharedSkillIds( + principal: Principal, + roles: RoleSourceRegistry, + grants: GrantStore, +): Promise { + const resolved = await resolveCapabilities(principal, roles, grants); + if (!resolved) return { ok: false, reason: 'unresolved' }; + + const ids = new Set(); + for (const capability of resolved.capabilities) { + const id = parseSharedSkillCapability(capability); + if (id !== undefined) ids.add(id); + } + // A capability granted AND denied is denied — same "denials win" rule the + // audience floor applies (`audienceFloor.ts`), applied per-id here since a + // skill share is a single-recipient grant, not a room-wide floor. + for (const capability of resolved.denials) { + const id = parseSharedSkillCapability(capability); + if (id !== undefined) ids.delete(id); + } + return { ok: true, ids }; +} + +/** + * Fail-closed adapter for callers (P2's `SkillResolutionContext.sharedSkillIds`) + * that want a plain set: `unresolved` collapses to empty, per this module's + * header — safe here because it can only hide a skill, never leak one. + */ +export function toSharedSkillIdsSet(result: SharedSkillIdsResult): ReadonlySet { + return result.ok ? result.ids : new Set(); +} diff --git a/middleware/test/skillLifecycle.test.ts b/middleware/test/skillLifecycle.test.ts index 27155ec9..1bad5230 100644 --- a/middleware/test/skillLifecycle.test.ts +++ b/middleware/test/skillLifecycle.test.ts @@ -3,7 +3,9 @@ import { strict as assert } from 'node:assert'; import { SKILL_LIFECYCLE_STATUSES, + SkillAutomationWriteBlocked, SkillManifestError, + assertHumanActor, canPublishSkill, canTransitionSkillLifecycle, canonicalSkillManifest, @@ -240,6 +242,34 @@ describe('signSkillManifest / verifySkillManifestSignature', () => { }); }); +// ── Automation write-guard (#577 P3, Kernkonzept #6) ──────────────────── + +describe('assertHumanActor', () => { + it('does not throw for any non-system ScopeId kind', () => { + assert.doesNotThrow(() => assertHumanActor({ kind: 'personal', userId: 'u1' })); + assert.doesNotThrow(() => assertHumanActor({ kind: 'group', groupRef: 'team-a' })); + assert.doesNotThrow(() => assertHumanActor({ kind: 'org', orgId: 'byte5' })); + assert.doesNotThrow(() => assertHumanActor({ kind: 'conversation', conversationId: 'c1' })); + assert.doesNotThrow(() => assertHumanActor({ kind: 'unscoped', reason: 'absent' })); + }); + + it('throws SkillAutomationWriteBlocked for every system origin', () => { + for (const origin of ['routine', 'schedule', 'conductor', 'conductor-builder'] as const) { + assert.throws( + () => assertHumanActor({ kind: 'system', origin, id: 'run-1' }), + (err: unknown) => { + assert.ok(err instanceof SkillAutomationWriteBlocked); + assert.match(err.message, /machine origin/); + assert.match(err.message, new RegExp(origin)); + assert.deepEqual(err.actorScope, { kind: 'system', origin, id: 'run-1' }); + return true; + }, + origin, + ); + } + }); +}); + // ── Combined transition decision ──────────────────────────────────────── describe('transitionSkillLifecycle', () => { diff --git a/middleware/test/skillOwnershipLifecycleStore.pg.test.ts b/middleware/test/skillOwnershipLifecycleStore.pg.test.ts index 1271b451..af0d01a4 100644 --- a/middleware/test/skillOwnershipLifecycleStore.pg.test.ts +++ b/middleware/test/skillOwnershipLifecycleStore.pg.test.ts @@ -73,16 +73,16 @@ describe('PgSkillOwnershipLifecycleStore (pg)', { skip: !pgAvailable }, () => { it('assignPersonalOwner sets owner_scope on a draft, unowned skill', async () => { const id = await seedDraftSkill('assign'); - await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-1' }); + await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-1' }, { kind: 'personal', userId: 'u-actor' }); const row = await store.getSkill(id); assert.equal(row?.ownerScope, 'personal:u-1'); }); it('refuses to reassign an already-owned skill', async () => { const id = await seedDraftSkill('reassign'); - await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-1' }); + await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-1' }, { kind: 'personal', userId: 'u-actor' }); await assert.rejects( - () => store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-2' }), + () => store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-2' }, { kind: 'personal', userId: 'u-actor' }), /already has an owner scope/, ); const row = await store.getSkill(id); @@ -92,7 +92,7 @@ describe('PgSkillOwnershipLifecycleStore (pg)', { skip: !pgAvailable }, () => { it('transition() throws SkillLifecycleTransitionRejected on an unowned skill', async () => { const id = await seedDraftSkill('unowned'); await assert.rejects( - () => store.transition(id, 'reviewed', { granted: new Set(), signingKey: KEY }), + () => store.transition(id, 'reviewed', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }), (err: unknown) => { assert.ok(err instanceof SkillLifecycleTransitionRejected); assert.equal(err.reason, 'invalid-owner-scope'); @@ -103,16 +103,16 @@ describe('PgSkillOwnershipLifecycleStore (pg)', { skip: !pgAvailable }, () => { it('draft -> reviewed -> published -> archived signs at each step and persists the new status', async () => { const id = await seedDraftSkill('lifecycle', ['mcp.web-search']); - await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-lifecycle' }); + await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-lifecycle' }, { kind: 'personal', userId: 'u-actor' }); - const reviewed = await store.transition(id, 'reviewed', { granted: new Set(), signingKey: KEY }); + const reviewed = await store.transition(id, 'reviewed', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }); assert.equal(reviewed.lifecycleStatus, 'reviewed'); assert.ok(reviewed.manifestSignature); assert.ok(reviewed.manifestSignedAt instanceof Date); // Publish is blocked until the required capability is granted. await assert.rejects( - () => store.transition(id, 'published', { granted: new Set(), signingKey: KEY }), + () => store.transition(id, 'published', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }), (err: unknown) => { assert.ok(err instanceof SkillLifecycleTransitionRejected); assert.equal(err.reason, 'missing-capabilities'); @@ -126,15 +126,16 @@ describe('PgSkillOwnershipLifecycleStore (pg)', { skip: !pgAvailable }, () => { const published = await store.transition(id, 'published', { granted: new Set(['mcp.web-search']), signingKey: KEY, + actorScope: { kind: 'personal', userId: 'u-actor' }, }); assert.equal(published.lifecycleStatus, 'published'); assert.notEqual(published.manifestSignature, reviewed.manifestSignature, 're-signed at the new status'); - const archived = await store.transition(id, 'archived', { granted: new Set(), signingKey: KEY }); + const archived = await store.transition(id, 'archived', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }); assert.equal(archived.lifecycleStatus, 'archived'); await assert.rejects( - () => store.transition(id, 'draft', { granted: new Set(), signingKey: KEY }), + () => store.transition(id, 'draft', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }), (err: unknown) => { assert.ok(err instanceof SkillLifecycleTransitionRejected); assert.equal(err.reason, 'invalid-transition'); @@ -146,13 +147,98 @@ describe('PgSkillOwnershipLifecycleStore (pg)', { skip: !pgAvailable }, () => { it('a re-signed manifest changes when the underlying skill body changes (content_hash drift)', async () => { const id = await seedDraftSkill('drift'); - await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-drift' }); - const before1 = await store.transition(id, 'reviewed', { granted: new Set(), signingKey: KEY }); + await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-drift' }, { kind: 'personal', userId: 'u-actor' }); + const before1 = await store.transition(id, 'reviewed', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }); // Edit the body directly (bypassing the store, as an operator edit would). await graphStore.updateSkill(id, { body: 'a very different body' }); - const back = await store.transition(id, 'draft', { granted: new Set(), signingKey: KEY }); + const back = await store.transition(id, 'draft', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }); assert.notEqual(back.manifestSignature, before1.manifestSignature, 'signature tracks content_hash drift'); }); + + // ── #577 P3: automation write-guard ───────────────────────────────────── + + const CRON_ACTOR = { kind: 'system', origin: 'routine', id: 'nightly-cleanup' } as const; + + it('assignPersonalOwner refuses a machine (cron/routine) actor', async () => { + const id = await seedDraftSkill('cron-assign'); + await assert.rejects( + () => store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-1' }, CRON_ACTOR), + /machine origin/, + ); + const row = await store.getSkill(id); + assert.equal(row?.ownerScope, null, 'the blocked write never reached the database'); + }); + + it('transition refuses a machine (cron/routine) actor', async () => { + const id = await seedDraftSkill('cron-transition'); + await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-1' }, { kind: 'personal', userId: 'u-actor' }); + await assert.rejects( + () => store.transition(id, 'reviewed', { granted: new Set(), signingKey: KEY, actorScope: CRON_ACTOR }), + /machine origin/, + ); + const row = await store.getSkill(id); + assert.equal(row?.lifecycleStatus, 'draft', 'the blocked write never reached the database'); + }); + + it('promoteSkillOwnerScope refuses a machine (cron/routine) actor', async () => { + const id = await seedDraftSkill('cron-promote'); + await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-1' }, { kind: 'personal', userId: 'u-actor' }); + await store.transition(id, 'reviewed', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }); + await store.transition(id, 'published', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }); + await assert.rejects( + () => + store.promoteSkillOwnerScope(id, { kind: 'org', orgId: 'byte5' }, { actorScope: CRON_ACTOR, signingKey: KEY }), + /machine origin/, + ); + const row = await store.getSkill(id); + assert.equal(row?.ownerScope, 'personal:u-1', 'the blocked promotion never reached the database'); + }); + + // ── #577 P3: admin-gated promotion ────────────────────────────────────── + + it('promoteSkillOwnerScope moves an already-published skill to an org home and re-signs it', async () => { + const id = await seedDraftSkill('promote'); + await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-1' }, { kind: 'personal', userId: 'u-actor' }); + await store.transition(id, 'reviewed', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }); + const published = await store.transition(id, 'published', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }); + + const promoted = await store.promoteSkillOwnerScope( + id, + { kind: 'org', orgId: 'byte5' }, + { actorScope: { kind: 'personal', userId: 'u-admin' }, signingKey: KEY }, + ); + assert.equal(promoted.ownerScope, 'org:byte5'); + assert.equal(promoted.lifecycleStatus, 'published', 'promotion does not change lifecycle status'); + assert.notEqual(promoted.manifestSignature, published.manifestSignature, 're-signed at the new owner scope'); + }); + + it('promoteSkillOwnerScope refuses a draft skill', async () => { + const id = await seedDraftSkill('promote-draft'); + await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-1' }, { kind: 'personal', userId: 'u-actor' }); + await assert.rejects( + () => + store.promoteSkillOwnerScope( + id, + { kind: 'org', orgId: 'byte5' }, + { actorScope: { kind: 'personal', userId: 'u-admin' }, signingKey: KEY }, + ), + /is not published/, + ); + }); + + it('promoteSkillOwnerScope can target a team (group) home, not just org', async () => { + const id = await seedDraftSkill('promote-team'); + await store.assignPersonalOwner(id, { kind: 'personal', userId: 'u-1' }, { kind: 'personal', userId: 'u-actor' }); + await store.transition(id, 'reviewed', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }); + await store.transition(id, 'published', { granted: new Set(), signingKey: KEY, actorScope: { kind: 'personal', userId: 'u-actor' } }); + + const promoted = await store.promoteSkillOwnerScope( + id, + { kind: 'group', groupRef: 'team-a' }, + { actorScope: { kind: 'personal', userId: 'u-admin' }, signingKey: KEY }, + ); + assert.equal(promoted.ownerScope, 'group:team-a'); + }); }); diff --git a/middleware/test/skillSharing.test.ts b/middleware/test/skillSharing.test.ts new file mode 100644 index 00000000..5e96a4e8 --- /dev/null +++ b/middleware/test/skillSharing.test.ts @@ -0,0 +1,119 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + InMemoryGrantStore, + RoleSourceRegistry, + type Principal, + type RoleLookup, + type RoleSource, +} from '@omadia/channel-sdk'; + +import { + parseSharedSkillCapability, + resolveSharedSkillIds, + sharedSkillCapability, + toSharedSkillIdsSet, +} from '../src/services/skillSharing.js'; + +const ALICE: Principal = { kind: 'user', userId: 'alice' }; + +describe('sharedSkillCapability / parseSharedSkillCapability', () => { + it('round-trips a skill id through the capability string', () => { + const cap = sharedSkillCapability('skill-123'); + assert.equal(cap, 'skill:read:skill-123'); + assert.equal(parseSharedSkillCapability(cap), 'skill-123'); + }); + + it('returns undefined for a capability that is not a skill-share', () => { + assert.equal(parseSharedSkillCapability('mcp.web-search'), undefined); + assert.equal(parseSharedSkillCapability('skill:write:x'), undefined); + }); + + it('returns undefined for an empty id (malformed capability, never a fake empty-string id)', () => { + assert.equal(parseSharedSkillCapability('skill:read:'), undefined); + }); +}); + +describe('resolveSharedSkillIds', () => { + it('resolves ids granted directly to the principal', async () => { + const grants = new InMemoryGrantStore().grantToPrincipal( + ALICE, + sharedSkillCapability('skill-a'), + sharedSkillCapability('skill-b'), + ); + const roles = new RoleSourceRegistry(); + const result = await resolveSharedSkillIds(ALICE, roles, grants); + assert.deepEqual(result, { ok: true, ids: new Set(['skill-a', 'skill-b']) }); + }); + + it('ignores non-skill capabilities granted alongside real ones', async () => { + const grants = new InMemoryGrantStore().grantToPrincipal( + ALICE, + sharedSkillCapability('skill-a'), + 'mcp.web-search', + ); + const roles = new RoleSourceRegistry(); + const result = await resolveSharedSkillIds(ALICE, roles, grants); + assert.deepEqual(result, { ok: true, ids: new Set(['skill-a']) }); + }); + + it('unions role grants with direct grants', async () => { + const fakeSource: RoleSource = { + id: 'test-source', + displayName: 'Test', + rolesFor: async (): Promise => ({ outcome: 'resolved', roles: ['editors'] }), + }; + const roles = new RoleSourceRegistry(); + roles.register(fakeSource); + + const grants = new InMemoryGrantStore() + .grantToPrincipal(ALICE, sharedSkillCapability('skill-a')) + .grantToRole('editors', sharedSkillCapability('skill-b')); + + const result = await resolveSharedSkillIds(ALICE, roles, grants); + assert.deepEqual(result, { ok: true, ids: new Set(['skill-a', 'skill-b']) }); + }); + + it('a denial removes an otherwise-granted skill id', async () => { + const grants = new InMemoryGrantStore() + .grantToPrincipal(ALICE, sharedSkillCapability('skill-a'), sharedSkillCapability('skill-b')) + .denyToPrincipal(ALICE, sharedSkillCapability('skill-b')); + const roles = new RoleSourceRegistry(); + const result = await resolveSharedSkillIds(ALICE, roles, grants); + assert.deepEqual(result, { ok: true, ids: new Set(['skill-a']) }); + }); + + it('returns unresolved when a role source is partial (never silently empties the set)', async () => { + const throwingSource: RoleSource = { + id: 'broken-source', + displayName: 'Broken', + rolesFor: async (): Promise => { + throw new Error('directory unreachable'); + }, + }; + const roles = new RoleSourceRegistry(); + roles.register(throwingSource); + const grants = new InMemoryGrantStore().grantToPrincipal(ALICE, sharedSkillCapability('skill-a')); + + const result = await resolveSharedSkillIds(ALICE, roles, grants); + assert.deepEqual(result, { ok: false, reason: 'unresolved' }); + }); + + it('resolves to an empty set (not unresolved) when nothing is granted', async () => { + const grants = new InMemoryGrantStore(); + const roles = new RoleSourceRegistry(); + const result = await resolveSharedSkillIds(ALICE, roles, grants); + assert.deepEqual(result, { ok: true, ids: new Set() }); + }); +}); + +describe('toSharedSkillIdsSet', () => { + it('passes through the ids on a resolved result', () => { + assert.deepEqual(toSharedSkillIdsSet({ ok: true, ids: new Set(['a']) }), new Set(['a'])); + }); + + it('fails closed to an empty set on unresolved', () => { + assert.deepEqual(toSharedSkillIdsSet({ ok: false, reason: 'unresolved' }), new Set()); + }); +});