diff --git a/packages/cli/src/runtime-host-access-command.ts b/packages/cli/src/runtime-host-access-command.ts index 47311f16af..35c450738b 100644 --- a/packages/cli/src/runtime-host-access-command.ts +++ b/packages/cli/src/runtime-host-access-command.ts @@ -27,7 +27,7 @@ import { REMOTE_OWNER_OPERATION_GRANTS, RUNTIME_HOST_PROTOCOL_VERSION, type AccessCredentialRotationRevokeInput, - type AccessCredentialPrincipalKind, + type ManagedAccessCredentialPrincipalKind, type OperationKey, } from '@maka/runtime-host/protocol'; import { @@ -58,7 +58,7 @@ export class RuntimeHostAccessUnavailableError extends Error { export interface RuntimeHostAccessIssueOptions { readonly rootPath: string; readonly expectedRootId?: string; - readonly principalKind: AccessCredentialPrincipalKind; + readonly principalKind: ManagedAccessCredentialPrincipalKind; readonly principalId: string; readonly operationGrants: readonly string[]; readonly canPublishClientCapabilities: boolean; @@ -70,7 +70,7 @@ export interface RuntimeHostAccessIssueOptions { export type RuntimeHostAccessPreset = 'desktop-client' | 'terminal-client'; export interface ResolvedRuntimeHostAccessIssue { - readonly principalKind: AccessCredentialPrincipalKind; + readonly principalKind: ManagedAccessCredentialPrincipalKind; readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; @@ -99,7 +99,7 @@ export interface IssuedRuntimeHostAccessCredential { readonly rootId: string; readonly credential: string; readonly credentialId: string; - readonly principalKind: AccessCredentialPrincipalKind; + readonly principalKind: ManagedAccessCredentialPrincipalKind; readonly principalId: string; readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; diff --git a/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts b/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts index 18455197f7..a89f2257a7 100644 --- a/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts +++ b/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts @@ -90,9 +90,13 @@ test('credential metadata exposes only usable public access state', async (t) => new Date(Date.now() - 60_000).toISOString(), ); const revoked = credential('revoked', revokedSecret, 'revoked'); + const guest = { + ...credential('guest', 'maka_rh_guest_secret', 'active'), + principalKind: 'session_guest' as const, + }; await writeAccessCredentialFile( join(owner.controlDirectory, ACCESS_FILE_NAME), - createAccessCredentialFile([active, pending, expired, revoked]), + createAccessCredentialFile([active, pending, expired, revoked, guest]), ); const metadata = await readRuntimeHostAccessCredentialMetadata(root, capability.rootId); diff --git a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts new file mode 100644 index 0000000000..2ac5c2ecdb --- /dev/null +++ b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { decodeCollaborationInvitationCode } from '../protocol/index.js'; +import { openRuntimeHostAccessAuthority } from '../server/access-authority.js'; + +test('Session Guest invitation, grants, and revocation form one durable authority lifecycle', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-session-collaboration-')); + const authority = await openRuntimeHostAccessAuthority(directory); + try { + const prepared = await authority.prepareCollaborationInvitation('root-1', { + sessionId: 'session-1', + grantKinds: ['session_observation', 'session_turn_request'], + }); + const invitation = decodeCollaborationInvitationCode(prepared.invitationCode); + + assert.deepEqual(authority.authenticate(invitation.credential)?.operationGrants, [ + 'host.status', + 'access.credential.finalize', + ]); + const credentialId = authority.authenticate(invitation.credential)?.credentialId; + assert.ok(credentialId); + await authority.finalize(credentialId, 'guest-client'); + assert.deepEqual(authority.authenticate(invitation.credential)?.operationGrants, [ + 'host.status', + ]); + + const observation = prepared.grants.find((grant) => grant.kind === 'session_observation')!; + assert.equal( + authority.activeSessionGrant(prepared.principalId, 'session-1', 'session_observation') + ?.grantId, + observation.grantId, + ); + assert.equal( + (await authority.revokeCollaborationGrant({ grantId: observation.grantId })).revoked, + true, + ); + assert.equal( + authority.activeSessionGrant(prepared.principalId, 'session-1', 'session_observation'), + undefined, + ); + + assert.deepEqual(await authority.revokeCollaborationPrincipal(prepared.principalId), { + revoked: true, + }); + assert.equal(authority.authenticate(invitation.credential), undefined); + assert.equal(authority.queryCollaborationAccess({ sessionId: 'session-1' }).grants.length, 0); + } finally { + await authority.close(); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/__tests__/websocket-listener.test.ts b/packages/runtime-host/src/__tests__/websocket-listener.test.ts index 7611cb7d39..45f641d6ae 100644 --- a/packages/runtime-host/src/__tests__/websocket-listener.test.ts +++ b/packages/runtime-host/src/__tests__/websocket-listener.test.ts @@ -173,7 +173,7 @@ test('WebSocket admission, health, Origin, and message policy fail closed', { test('credential revocation during WebSocket upgrade cannot admit stale authority', async () => { let credentialActive = true; let accepted = false; - const authority: RuntimeHostAccessAuthority = { + const authority: Pick = { authenticate: () => { if (!credentialActive) return undefined; credentialActive = false; @@ -186,16 +186,6 @@ test('credential revocation during WebSocket upgrade cannot admit stale authorit canUseHostPaths: false, }); }, - issue: async () => assert.fail('Credential issue is not expected'), - replace: async () => assert.fail('Credential replacement is not expected'), - prepare: async () => assert.fail('Credential preparation is not expected'), - prepareRotation: async () => assert.fail('Credential rotation is not expected'), - revoke: async () => assert.fail('Credential revoke is not expected'), - revokePrincipal: async () => assert.fail('Principal revoke is not expected'), - revokeRotation: async () => assert.fail('Credential rotation revoke is not expected'), - finalize: async () => assert.fail('Credential finalize is not expected'), - subscribeRevocations: () => () => undefined, - close: async () => undefined, }; const listener = await startRuntimeHostWebSocketListener({ host: '127.0.0.1', diff --git a/packages/runtime-host/src/protocol/access-authority.ts b/packages/runtime-host/src/protocol/access-authority.ts index 78ffc7789d..223b7fa668 100644 --- a/packages/runtime-host/src/protocol/access-authority.ts +++ b/packages/runtime-host/src/protocol/access-authority.ts @@ -30,7 +30,14 @@ import type { OperationKey } from './operations.js'; export const ACCESS_CREDENTIAL_MAX_GRANTS = 256; -export type AccessCredentialPrincipalKind = 'remote_owner' | 'capability_provider'; +export type AccessCredentialPrincipalKind = + | 'remote_owner' + | 'capability_provider' + | 'session_guest'; +export type ManagedAccessCredentialPrincipalKind = Exclude< + AccessCredentialPrincipalKind, + 'session_guest' +>; const ACCESS_ERRORS = [ 'host_not_ready', @@ -43,7 +50,7 @@ const ACCESS_ERRORS = [ ] as const; export interface AccessCredentialIssueInput { - readonly principalKind: AccessCredentialPrincipalKind; + readonly principalKind: ManagedAccessCredentialPrincipalKind; readonly principalId: string; readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; @@ -53,7 +60,7 @@ export interface AccessCredentialIssueInput { export interface AccessCredentialIssueResult { readonly credentialId: string; readonly deliveryId: string; - readonly principalKind: AccessCredentialPrincipalKind; + readonly principalKind: ManagedAccessCredentialPrincipalKind; readonly principalId: string; readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; @@ -266,13 +273,20 @@ export function decodeAccessCredentialIssueResult(value: unknown): AccessCredent }; } -function principalKind(value: unknown): AccessCredentialPrincipalKind { +function principalKind(value: unknown): ManagedAccessCredentialPrincipalKind { if (value !== 'remote_owner' && value !== 'capability_provider') { throw invalidProtocolFrame('Invalid access credential principalKind'); } return value; } +function revocablePrincipalKind(value: unknown): AccessCredentialPrincipalKind { + if (value !== 'remote_owner' && value !== 'capability_provider' && value !== 'session_guest') { + throw invalidProtocolFrame('Invalid access credential principalKind'); + } + return value; +} + export function decodeAccessCredentialRevokeInput(value: unknown): AccessCredentialRevokeInput { const record = requireExactRecord(value, 'access credential revoke input', ['credentialId']); return { credentialId: requireId(record.credentialId, 'credentialId') }; @@ -284,7 +298,7 @@ export function decodeAccessPrincipalRevokeInput(value: unknown): AccessPrincipa 'principalId', ]); return { - principalKind: principalKind(record.principalKind), + principalKind: revocablePrincipalKind(record.principalKind), principalId: principalId(record.principalId), }; } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 5c208bf3d8..7fa57c3559 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -81,6 +81,7 @@ export * from './operations.js'; export * from './runtime-resource.js'; export * from './session-continuity.js'; export * from './session-catalog-change.js'; +export * from './session-collaboration.js'; export * from './scheduled-task-change.js'; export * from './session-retirement.js'; export * from './session-transcript.js'; @@ -94,7 +95,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 68 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 69 as const; +// 69: Runtime Host access authority recognizes restricted Session Guest +// principals and typed Session collaboration grants. Older Hosts would either +// reject the new operations or misclassify the authenticated principal. // 68: Connection onboarding replaces nullable canonical-slug targeting with // explicit create/existing identity and returns the committed Connection. // Older peers reject the closed target and saved-result shapes. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 392347d4f7..67c05a29f7 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -54,6 +54,7 @@ import { SESSION_CATALOG_OPERATION_SPECS } from './session-catalog.js'; import { SESSION_CONTINUITY_OPERATION_SPECS } from './session-continuity.js'; import { SESSION_TRANSCRIPT_OPERATION_SPECS } from './session-transcript.js'; import { SESSION_TURNS_OPERATION_SPECS } from './session-turns.js'; +import { SESSION_COLLABORATION_OPERATION_SPECS } from './session-collaboration.js'; import { SESSION_REVISION_OPERATION_SPECS } from './session-revision.js'; import { SESSION_RETIREMENT_OPERATION_SPECS } from './session-retirement.js'; import { SESSION_EFFECT_OPERATION_SPECS } from './session-effects.js'; @@ -166,6 +167,7 @@ export * from './runtime-policy.js'; export * from './runtime-resource.js'; export * from './scheduled-task.js'; export * from './session-catalog.js'; +export * from './session-collaboration.js'; export * from './session-revision.js'; export * from './session-retirement.js'; export * from './session-transcript.js'; @@ -181,6 +183,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( PEER_MESH_OPERATION_SPECS, HOSTED_EXECUTION_OPERATION_SPECS, ACCESS_AUTHORITY_OPERATION_SPECS, + SESSION_COLLABORATION_OPERATION_SPECS, AGENT_GRAPH_OPERATION_SPECS, GOAL_OPERATION_SPECS, TURN_OPERATION_SPECS, @@ -234,6 +237,10 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'client.capability.replace', 'client.capability.unregister', 'configuration.credentials.export', + 'collaboration.access.query', + 'collaboration.grant.revoke', + 'collaboration.invitation.prepare', + 'collaboration.principal.revoke', 'connection.catalog.create', 'connection.catalog.query', 'connection.catalog.remove', diff --git a/packages/runtime-host/src/protocol/session-collaboration.ts b/packages/runtime-host/src/protocol/session-collaboration.ts new file mode 100644 index 0000000000..25cdb43ea1 --- /dev/null +++ b/packages/runtime-host/src/protocol/session-collaboration.ts @@ -0,0 +1,349 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + requireEntityId, + requireExactRecord, + requireId, + requireShapedRecord, + requireUtf8String, +} from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { defineOperation } from './operation-spec.js'; + +export const COLLABORATION_INVITATION_SCHEMA_VERSION = 1 as const; +export const COLLABORATION_INVITATION_CODE_MAX_BYTES = 16 * 1024; +export const SESSION_COLLABORATION_MAX_GRANTS_PER_INVITATION = 2; + +export type SessionCollaborationGrantKind = 'session_observation' | 'session_turn_request'; + +interface SessionGrantIdentity { + readonly grantId: string; + readonly principalId: string; + readonly sessionId: string; + readonly createdAt: string; +} + +export interface SessionObservationGrant extends SessionGrantIdentity { + readonly kind: 'session_observation'; +} + +export interface SessionTurnRequestGrant extends SessionGrantIdentity { + readonly kind: 'session_turn_request'; +} + +export type SessionCollaborationGrant = SessionObservationGrant | SessionTurnRequestGrant; + +export interface CollaborationInvitationPrepareInput { + readonly sessionId: string; + readonly grantKinds: readonly SessionCollaborationGrantKind[]; +} + +export interface CollaborationInvitationPayload { + readonly schemaVersion: typeof COLLABORATION_INVITATION_SCHEMA_VERSION; + readonly rootId: string; + readonly credential: string; +} + +export interface CollaborationInvitationPrepareResult { + readonly invitationCode: string; + readonly principalId: string; + readonly expiresAt: string; + readonly grants: readonly SessionCollaborationGrant[]; +} + +export interface CollaborationAccessQueryInput { + readonly sessionId?: string; +} + +export interface SessionGuestPrincipalProjection { + readonly principalId: string; + readonly status: 'pending' | 'active'; + readonly createdAt: string; + readonly expiresAt?: string; +} + +export interface CollaborationAccessQueryResult { + readonly principals: readonly SessionGuestPrincipalProjection[]; + readonly grants: readonly SessionCollaborationGrant[]; +} + +export interface CollaborationGrantRevokeInput { + readonly grantId: string; +} + +export interface CollaborationGrantRevokeResult { + readonly revoked: boolean; +} + +export interface CollaborationPrincipalRevokeInput { + readonly principalId: string; +} + +export interface CollaborationPrincipalRevokeResult { + readonly revoked: boolean; +} + +const COLLABORATION_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'invalid_request', + 'persistence_failed', + 'commit_outcome_unknown', + 'internal_failure', +] as const; + +export const SESSION_COLLABORATION_OPERATION_SPECS = { + 'collaboration.invitation.prepare': defineOperation< + CollaborationInvitationPrepareInput, + CollaborationInvitationPrepareResult, + (typeof COLLABORATION_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: COLLABORATION_ERRORS, + decodeInput: decodeCollaborationInvitationPrepareInput, + decodeOutput: decodeCollaborationInvitationPrepareResult, + }), + 'collaboration.access.query': defineOperation< + CollaborationAccessQueryInput, + CollaborationAccessQueryResult, + (typeof COLLABORATION_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: COLLABORATION_ERRORS, + decodeInput: decodeCollaborationAccessQueryInput, + decodeOutput: decodeCollaborationAccessQueryResult, + }), + 'collaboration.grant.revoke': defineOperation< + CollaborationGrantRevokeInput, + CollaborationGrantRevokeResult, + (typeof COLLABORATION_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: COLLABORATION_ERRORS, + decodeInput: decodeCollaborationGrantRevokeInput, + decodeOutput: decodeCollaborationGrantRevokeResult, + }), + 'collaboration.principal.revoke': defineOperation< + CollaborationPrincipalRevokeInput, + CollaborationPrincipalRevokeResult, + (typeof COLLABORATION_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: COLLABORATION_ERRORS, + decodeInput: decodeCollaborationPrincipalRevokeInput, + decodeOutput: decodeCollaborationPrincipalRevokeResult, + }), +} as const; + +export function encodeCollaborationInvitationCode(payload: CollaborationInvitationPayload): string { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); +} + +export function decodeCollaborationInvitationCode(code: string): CollaborationInvitationPayload { + const bounded = requireUtf8String( + code, + 'collaboration invitation code', + COLLABORATION_INVITATION_CODE_MAX_BYTES, + ); + let decoded: unknown; + try { + decoded = JSON.parse(Buffer.from(bounded, 'base64url').toString('utf8')) as unknown; + } catch { + throw invalidProtocolFrame('Invalid collaboration invitation code'); + } + const record = requireExactRecord(decoded, 'collaboration invitation', [ + 'schemaVersion', + 'rootId', + 'credential', + ]); + if (record.schemaVersion !== COLLABORATION_INVITATION_SCHEMA_VERSION) { + throw invalidProtocolFrame('Unsupported collaboration invitation'); + } + return { + schemaVersion: COLLABORATION_INVITATION_SCHEMA_VERSION, + rootId: requireId(record.rootId, 'rootId'), + credential: requireUtf8String(record.credential, 'credential', 1024), + }; +} + +function decodeCollaborationInvitationPrepareInput( + value: unknown, +): CollaborationInvitationPrepareInput { + const record = requireExactRecord(value, 'collaboration invitation prepare input', [ + 'sessionId', + 'grantKinds', + ]); + if ( + !Array.isArray(record.grantKinds) || + record.grantKinds.length === 0 || + record.grantKinds.length > SESSION_COLLABORATION_MAX_GRANTS_PER_INVITATION + ) { + throw invalidProtocolFrame('Invalid collaboration invitation grant kinds'); + } + const grantKinds = record.grantKinds.map(decodeGrantKind); + if (new Set(grantKinds).size !== grantKinds.length) { + throw invalidProtocolFrame('Duplicate collaboration invitation grant kind'); + } + return { + sessionId: requireEntityId(record.sessionId, 'sessionId'), + grantKinds, + }; +} + +function decodeCollaborationInvitationPrepareResult( + value: unknown, +): CollaborationInvitationPrepareResult { + const record = requireExactRecord(value, 'collaboration invitation prepare result', [ + 'invitationCode', + 'principalId', + 'expiresAt', + 'grants', + ]); + if (!Array.isArray(record.grants)) { + throw invalidProtocolFrame('Invalid collaboration invitation grants'); + } + return { + invitationCode: requireUtf8String( + record.invitationCode, + 'invitationCode', + COLLABORATION_INVITATION_CODE_MAX_BYTES, + ), + principalId: decodePrincipalId(record.principalId), + expiresAt: decodeIsoTimestamp(record.expiresAt, 'expiresAt'), + grants: record.grants.map(decodeSessionCollaborationGrant), + }; +} + +function decodeCollaborationAccessQueryInput(value: unknown): CollaborationAccessQueryInput { + const record = requireShapedRecord(value, 'collaboration access query input', [], ['sessionId']); + return record.sessionId === undefined + ? {} + : { sessionId: requireEntityId(record.sessionId, 'sessionId') }; +} + +function decodeCollaborationAccessQueryResult(value: unknown): CollaborationAccessQueryResult { + const record = requireExactRecord(value, 'collaboration access query result', [ + 'principals', + 'grants', + ]); + if (!Array.isArray(record.principals) || !Array.isArray(record.grants)) { + throw invalidProtocolFrame('Invalid collaboration access result'); + } + return { + principals: record.principals.map(decodeSessionGuestPrincipal), + grants: record.grants.map(decodeSessionCollaborationGrant), + }; +} + +function decodeSessionGuestPrincipal(value: unknown): SessionGuestPrincipalProjection { + const record = requireShapedRecord( + value, + 'Session Guest principal', + ['principalId', 'status', 'createdAt'], + ['expiresAt'], + ); + if (record.status !== 'pending' && record.status !== 'active') { + throw invalidProtocolFrame('Invalid Session Guest principal status'); + } + return { + principalId: decodePrincipalId(record.principalId), + status: record.status, + createdAt: decodeIsoTimestamp(record.createdAt, 'createdAt'), + ...(record.expiresAt === undefined + ? {} + : { expiresAt: decodeIsoTimestamp(record.expiresAt, 'expiresAt') }), + }; +} + +function decodeCollaborationGrantRevokeInput(value: unknown): CollaborationGrantRevokeInput { + const record = requireExactRecord(value, 'collaboration grant revoke input', ['grantId']); + return { grantId: requireId(record.grantId, 'grantId') }; +} + +function decodeCollaborationGrantRevokeResult(value: unknown): CollaborationGrantRevokeResult { + const record = requireExactRecord(value, 'collaboration grant revoke result', ['revoked']); + if (typeof record.revoked !== 'boolean') { + throw invalidProtocolFrame('Invalid collaboration grant revoke result'); + } + return { revoked: record.revoked }; +} + +function decodeCollaborationPrincipalRevokeInput( + value: unknown, +): CollaborationPrincipalRevokeInput { + const record = requireExactRecord(value, 'collaboration principal revoke input', ['principalId']); + return { principalId: decodePrincipalId(record.principalId) }; +} + +function decodeCollaborationPrincipalRevokeResult( + value: unknown, +): CollaborationPrincipalRevokeResult { + const record = requireExactRecord(value, 'collaboration principal revoke result', ['revoked']); + if (typeof record.revoked !== 'boolean') { + throw invalidProtocolFrame('Invalid collaboration principal revoke result'); + } + return { revoked: record.revoked }; +} + +export function decodeSessionCollaborationGrant(value: unknown): SessionCollaborationGrant { + const record = requireExactRecord(value, 'Session collaboration grant', [ + 'kind', + 'grantId', + 'principalId', + 'sessionId', + 'createdAt', + ]); + const kind = decodeGrantKind(record.kind); + return { + kind, + grantId: requireId(record.grantId, 'grantId'), + principalId: decodePrincipalId(record.principalId), + sessionId: requireEntityId(record.sessionId, 'sessionId'), + createdAt: decodeIsoTimestamp(record.createdAt, 'createdAt'), + }; +} + +function decodeGrantKind(value: unknown): SessionCollaborationGrantKind { + if (value !== 'session_observation' && value !== 'session_turn_request') { + throw invalidProtocolFrame('Invalid Session collaboration grant kind'); + } + return value; +} + +function decodePrincipalId(value: unknown): string { + const principalId = requireUtf8String(value, 'principalId', 128); + if (!/^[A-Za-z0-9_.:-]+$/u.test(principalId)) { + throw invalidProtocolFrame('Invalid principalId'); + } + return principalId; +} + +function decodeIsoTimestamp(value: unknown, label: string): string { + const timestamp = requireUtf8String(value, label, 64); + if (!Number.isFinite(Date.parse(timestamp))) { + throw invalidProtocolFrame(`Invalid ${label}`); + } + return timestamp; +} diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts index b5c7f78072..0d25be84fc 100644 --- a/packages/runtime-host/src/server/access-authority.ts +++ b/packages/runtime-host/src/server/access-authority.ts @@ -36,6 +36,16 @@ import { type AccessCredentialRotationRevokeResult, type AccessPrincipalRevokeInput, type AccessPrincipalRevokeResult, + type CollaborationAccessQueryInput, + type CollaborationAccessQueryResult, + type CollaborationGrantRevokeInput, + type CollaborationGrantRevokeResult, + type CollaborationInvitationPrepareInput, + type CollaborationInvitationPrepareResult, + type CollaborationPrincipalRevokeResult, + encodeCollaborationInvitationCode, + type SessionCollaborationGrant, + type SessionCollaborationGrantKind, } from '../protocol/index.js'; import { createRuntimeHostConnectionAuthority, @@ -57,6 +67,7 @@ import { RuntimeHostAccessInputError, type AccessCredentialFile, type StoredAccessCredential, + SESSION_GUEST_OPERATION_GRANTS, writeAccessCredentialFile, } from './access-credential-store.js'; @@ -82,7 +93,22 @@ export interface RuntimeHostAccessAuthority { input: AccessCredentialRotationRevokeInput, ): Promise; finalize(credentialId: string, clientInstanceId: string): Promise; + prepareCollaborationInvitation( + rootId: string, + input: CollaborationInvitationPrepareInput, + ): Promise; + queryCollaborationAccess(input: CollaborationAccessQueryInput): CollaborationAccessQueryResult; + revokeCollaborationGrant( + input: CollaborationGrantRevokeInput, + ): Promise; + revokeCollaborationPrincipal(principalId: string): Promise; + activeSessionGrant( + principalId: string, + sessionId: string, + kind: SessionCollaborationGrantKind, + ): SessionCollaborationGrant | undefined; subscribeRevocations(listener: (credentialId: string) => void): () => void; + subscribeGrantRevocations(listener: (grant: SessionCollaborationGrant) => void): () => void; close(): Promise; } @@ -111,6 +137,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { #expiryTimer: NodeJS.Timeout | undefined; #closed = false; readonly #revocationListeners = new Set<(credentialId: string) => void>(); + readonly #grantRevocationListeners = new Set<(grant: SessionCollaborationGrant) => void>(); constructor( controlDirectory: string, @@ -169,6 +196,122 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { return this.#issue(input, 'prepare'); } + prepareCollaborationInvitation( + rootId: string, + input: CollaborationInvitationPrepareInput, + ): Promise { + return this.#mutate(async () => { + const principalId = `session_guest:${randomUUID()}`; + const credentialId = randomUUID(); + const credential = `${ACCESS_CREDENTIAL_PREFIX}${randomBytes(32).toString('base64url')}`; + const created = new Date(); + const createdAt = created.toISOString(); + const expiresAt = new Date(created.getTime() + PENDING_CREDENTIAL_LIFETIME_MS).toISOString(); + const stored: StoredAccessCredential = { + credentialId, + credentialHash: runtimeHostAccessCredentialHash(credential).toString('hex'), + principalId, + principalKind: 'session_guest', + status: 'pending', + operationGrants: SESSION_GUEST_OPERATION_GRANTS, + canPublishClientCapabilities: false, + canUseHostPaths: false, + createdAt, + expiresAt, + bindClientInstanceOnFinalize: true, + }; + const grants = input.grantKinds.map( + (kind): SessionCollaborationGrant => ({ + kind, + grantId: randomUUID(), + principalId, + sessionId: input.sessionId, + createdAt, + }), + ); + const nextFile = createAccessCredentialFile( + [...this.#file.credentials, stored], + [...this.#file.sessionGrants, ...grants], + ); + assertAccessCredentialFileCapacity(nextFile); + await this.#commit(nextFile); + return { + invitationCode: encodeCollaborationInvitationCode({ + schemaVersion: 1, + rootId, + credential, + }), + principalId, + expiresAt, + grants, + }; + }); + } + + queryCollaborationAccess(input: CollaborationAccessQueryInput): CollaborationAccessQueryResult { + const grants = this.#file.sessionGrants.filter( + (grant) => input.sessionId === undefined || grant.sessionId === input.sessionId, + ); + const now = Date.now(); + const principals = [...new Set(grants.map((grant) => grant.principalId))].flatMap( + (principalId) => { + const credentials = this.#file.credentials.filter( + (candidate) => + candidate.principalKind === 'session_guest' && + candidate.principalId === principalId && + (candidate.status === 'active' || + (candidate.status === 'pending' && Date.parse(candidate.expiresAt!) > now)), + ); + const credential = + credentials.find((candidate) => candidate.status === 'active') ?? credentials[0]; + return credential + ? [ + { + principalId, + status: credential.status as 'active' | 'pending', + createdAt: credential.createdAt, + ...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}), + }, + ] + : []; + }, + ); + return { principals, grants }; + } + + revokeCollaborationGrant( + input: CollaborationGrantRevokeInput, + ): Promise { + return this.#mutate(async () => { + const current = this.#file.sessionGrants.find((grant) => grant.grantId === input.grantId); + if (!current) return { revoked: false }; + await this.#commit( + createAccessCredentialFile( + this.#file.credentials, + this.#file.sessionGrants.filter((grant) => grant !== current), + ), + [], + [current], + ); + return { revoked: true }; + }); + } + + revokeCollaborationPrincipal(principalId: string): Promise { + return this.revokePrincipal({ principalKind: 'session_guest', principalId }); + } + + activeSessionGrant( + principalId: string, + sessionId: string, + kind: SessionCollaborationGrantKind, + ): SessionCollaborationGrant | undefined { + return this.#file.sessionGrants.find( + (grant) => + grant.principalId === principalId && grant.sessionId === sessionId && grant.kind === kind, + ); + } + prepareRotation( input: AccessCredentialRotationPrepareInput, ): Promise { @@ -181,7 +324,21 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { if (!current) { throw new RuntimeHostAccessInputError('The credential being rotated is no longer active'); } - return this.#createCredential(current, 'prepare', current.operationGrants); + if (current.principalKind !== 'remote_owner') { + throw new RuntimeHostAccessInputError('This credential cannot use owner rotation'); + } + return this.#createCredential( + { + principalId: current.principalId, + principalKind: current.principalKind, + operationGrants: current.operationGrants, + canPublishClientCapabilities: current.canPublishClientCapabilities, + canUseHostPaths: current.canUseHostPaths, + bindClientInstance: current.bindClientInstanceOnFinalize === true, + }, + 'prepare', + current.operationGrants, + ); }); } @@ -248,7 +405,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { replaced.length === 0 ? this.#file.credentials : this.#file.credentials.filter((candidate) => !replaced.includes(candidate)); - const nextFile = createAccessCredentialFile([...retained, stored]); + const nextFile = createAccessCredentialFile([...retained, stored], this.#file.sessionGrants); assertAccessCredentialFileCapacity(nextFile); const deliveryId = await createAccessCredentialDelivery( this.#controlDirectory, @@ -268,7 +425,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { credentialId, deliveryId, principalId: stored.principalId, - principalKind: stored.principalKind, + principalKind: input.principalKind, operationGrants, canPublishClientCapabilities: stored.canPublishClientCapabilities, canUseHostPaths: stored.canUseHostPaths, @@ -289,7 +446,11 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { credential.principalKind === input.principalKind && credential.principalId === input.principalId, ); - if (matches.length === 0) return { revoked: false }; + const activeGrants = + input.principalKind === 'session_guest' + ? this.#file.sessionGrants.filter((grant) => grant.principalId === input.principalId) + : []; + if (matches.length === 0 && activeGrants.length === 0) return { revoked: false }; const matchedIds = new Set(matches.map((credential) => credential.credentialId)); const revokedAt = new Date().toISOString(); @@ -299,7 +460,14 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { const { clientInstanceId: _clientInstanceId, ...revoked } = credential; return [{ ...revoked, status: 'revoked' as const, revokedAt }]; }); - await this.#commit(createAccessCredentialFile(credentials), [...matchedIds]); + await this.#commit( + createAccessCredentialFile( + credentials, + this.#file.sessionGrants.filter((grant) => !activeGrants.includes(grant)), + ), + [...matchedIds], + activeGrants, + ); return { revoked: true }; }); } @@ -355,7 +523,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { }; }); await this.#commit( - createAccessCredentialFile(credentials), + createAccessCredentialFile(credentials, this.#file.sessionGrants), [current, ...pendingForPrincipal].map((credential) => credential.credentialId), ); return { credentialId, revoked: true }; @@ -372,54 +540,69 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { if (!retained || retained.status === 'revoked') { throw new RuntimeHostAccessInputError('The current access credential is no longer active'); } - if (retained.status === 'active') { - if (retained.clientInstanceId && retained.clientInstanceId !== clientInstanceId) { - throw new RuntimeHostAccessInputError( - 'The pairing candidate was claimed by another Client', - ); - } - return { reconnectRequired: retained.clientInstanceId !== undefined }; - } - if (Date.parse(retained.expiresAt!) <= Date.now()) { - await this.#expirePending(); - throw new RuntimeHostAccessInputError('The pairing candidate has expired'); - } - const revoked = this.#file.credentials.filter( - (credential) => - credential.credentialId !== credentialId && - credential.status === 'active' && - credential.principalKind === retained.principalKind && - credential.principalId === retained.principalId, - ); - const finalized = createAccessCredentialFile( - this.#file.credentials - .filter((credential) => !revoked.includes(credential)) - .map((credential) => - credential === retained - ? activatePendingCredential(credential, clientInstanceId) - : credential, - ), - ); - await this.#commit( - finalized, - revoked.map((credential) => credential.credentialId), - ); - return { reconnectRequired: retained.bindClientInstanceOnFinalize === true }; + return this.#finalize(retained, clientInstanceId); }); } + async #finalize( + retained: StoredAccessCredential, + clientInstanceId: string, + ): Promise { + if (retained.status === 'active') { + if (retained.clientInstanceId && retained.clientInstanceId !== clientInstanceId) { + throw new RuntimeHostAccessInputError( + 'The pairing candidate was claimed by another Client', + ); + } + return { reconnectRequired: retained.clientInstanceId !== undefined }; + } + if (Date.parse(retained.expiresAt!) <= Date.now()) { + await this.#expirePending(); + throw new RuntimeHostAccessInputError('The pairing candidate has expired'); + } + const revoked = this.#file.credentials.filter( + (credential) => + credential.credentialId !== retained.credentialId && + credential.status === 'active' && + credential.principalKind === retained.principalKind && + credential.principalId === retained.principalId, + ); + const finalized = createAccessCredentialFile( + this.#file.credentials + .filter((credential) => !revoked.includes(credential)) + .map((credential) => + credential === retained + ? activatePendingCredential(credential, clientInstanceId) + : credential, + ), + this.#file.sessionGrants, + ); + await this.#commit( + finalized, + revoked.map((credential) => credential.credentialId), + ); + return { reconnectRequired: retained.bindClientInstanceOnFinalize === true }; + } + subscribeRevocations(listener: (credentialId: string) => void): () => void { if (this.#closed) return () => undefined; this.#revocationListeners.add(listener); return () => this.#revocationListeners.delete(listener); } + subscribeGrantRevocations(listener: (grant: SessionCollaborationGrant) => void): () => void { + if (this.#closed) return () => undefined; + this.#grantRevocationListeners.add(listener); + return () => this.#grantRevocationListeners.delete(listener); + } + close(): Promise { if (!this.#closed) { this.#closed = true; if (this.#expiryTimer) clearTimeout(this.#expiryTimer); this.#expiryTimer = undefined; this.#revocationListeners.clear(); + this.#grantRevocationListeners.clear(); } return this.#mutation; } @@ -439,6 +622,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { async #commit( file: AccessCredentialFile, revokedCredentialIds: readonly string[] = [], + revokedGrants: readonly SessionCollaborationGrant[] = [], ): Promise { let outcomeUnknown: RuntimeHostAccessCommitOutcomeUnknownError | undefined; try { @@ -450,6 +634,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { this.#file = file; this.#schedulePendingExpiry(); for (const credentialId of revokedCredentialIds) this.#publishRevocation(credentialId); + for (const grant of revokedGrants) this.#publishGrantRevocation(grant); if (outcomeUnknown) throw outcomeUnknown; } @@ -462,11 +647,21 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { this.#schedulePendingExpiry(); return; } + const expiredGuestPrincipals = new Set( + expired + .filter((credential) => credential.principalKind === 'session_guest') + .map((credential) => credential.principalId), + ); + const expiredGrants = this.#file.sessionGrants.filter((grant) => + expiredGuestPrincipals.has(grant.principalId), + ); await this.#commit( createAccessCredentialFile( this.#file.credentials.filter((credential) => !expired.includes(credential)), + this.#file.sessionGrants.filter((grant) => !expiredGrants.includes(grant)), ), expired.map((credential) => credential.credentialId), + expiredGrants, ); } @@ -506,6 +701,16 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { } } } + + #publishGrantRevocation(grant: SessionCollaborationGrant): void { + for (const listener of this.#grantRevocationListeners) { + try { + listener(grant); + } catch { + // The revocation is already durable; an observer cannot roll it back. + } + } + } } function activatePendingCredential( @@ -702,6 +907,73 @@ export async function finalizeAccessCredential( } } +export async function prepareCollaborationInvitation( + authority: RuntimeHostAccessAuthority | undefined, + rootId: string, + input: CollaborationInvitationPrepareInput, +): Promise> { + if (!authority) return collaborationUnavailable('collaboration.invitation.prepare'); + try { + return { ok: true, result: await authority.prepareCollaborationInvitation(rootId, input) }; + } catch (error) { + if (error instanceof RuntimeHostAccessInputError) { + return { ok: false, error: { code: 'invalid_request', message: error.message } }; + } + return accessPersistenceFailure( + error, + 'Collaboration invitation outcome is unknown', + 'Collaboration invitation could not be created', + ); + } +} + +export async function revokeCollaborationGrant( + authority: RuntimeHostAccessAuthority | undefined, + input: CollaborationGrantRevokeInput, +): Promise> { + if (!authority) return collaborationUnavailable('collaboration.grant.revoke'); + try { + return { ok: true, result: await authority.revokeCollaborationGrant(input) }; + } catch (error) { + return accessPersistenceFailure( + error, + 'Collaboration grant revocation outcome is unknown', + 'Collaboration grant could not be revoked', + ); + } +} + +export async function revokeCollaborationPrincipal( + authority: RuntimeHostAccessAuthority | undefined, + principalId: string, +): Promise> { + if (!authority) return collaborationUnavailable('collaboration.principal.revoke'); + try { + return { ok: true, result: await authority.revokeCollaborationPrincipal(principalId) }; + } catch (error) { + return accessPersistenceFailure( + error, + 'Collaboration Guest revocation outcome is unknown', + 'Collaboration Guest could not be revoked', + ); + } +} + +function collaborationUnavailable< + K extends + | 'collaboration.invitation.prepare' + | 'collaboration.grant.revoke' + | 'collaboration.principal.revoke', +>(operation: K): OperationOutcome { + return { + ok: false, + error: { + code: 'operation_unavailable', + message: 'Runtime Host collaboration authority is unavailable', + }, + } as OperationOutcome; +} + function accessPersistenceFailure(error: unknown, unknownMessage: string, failureMessage: string) { return { ok: false as const, diff --git a/packages/runtime-host/src/server/access-credential-metadata.ts b/packages/runtime-host/src/server/access-credential-metadata.ts index 91e4aa0ddf..d29e56c0c3 100644 --- a/packages/runtime-host/src/server/access-credential-metadata.ts +++ b/packages/runtime-host/src/server/access-credential-metadata.ts @@ -57,8 +57,9 @@ export async function readRuntimeHostAccessCredentialMetadata( return { credentials: file.credentials.flatMap((credential) => { if ( - credential.status !== 'active' && - !(credential.status === 'pending' && Date.parse(credential.expiresAt!) > now) + credential.principalKind === 'session_guest' || + (credential.status !== 'active' && + !(credential.status === 'pending' && Date.parse(credential.expiresAt!) > now)) ) { return []; } diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index 210d9899d0..4b18e86cfa 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -24,10 +24,11 @@ import { type AccessCredentialPrincipalKind, HOST_OPERATION_SPECS, operationAllowsRemoteOwner, + type SessionCollaborationGrant, type OperationKey, } from '../protocol/index.js'; -const ACCESS_FILE_SCHEMA_VERSION = 1; +const ACCESS_FILE_SCHEMA_VERSION = 2; const ACCESS_FILE_MAX_BYTES = 512 * 1024; const LEGACY_TRANSCRIPT_QUERY_GRANT = 'session.transcript.query'; const TRANSCRIPT_QUERY_REPLACEMENT_GRANTS = [ @@ -54,6 +55,10 @@ const RETIRED_OPERATION_GRANTS = new Set([ export const ACCESS_FILE_NAME = 'runtime-host-access.json'; +export const SESSION_GUEST_OPERATION_GRANTS = Object.freeze([ + 'host.status', +] as const satisfies readonly OperationKey[]); + export interface StoredAccessCredential { readonly credentialId: string; readonly credentialHash: string; @@ -73,6 +78,7 @@ export interface StoredAccessCredential { export interface AccessCredentialFile { readonly schemaVersion: typeof ACCESS_FILE_SCHEMA_VERSION; readonly credentials: readonly StoredAccessCredential[]; + readonly sessionGrants: readonly SessionCollaborationGrant[]; } export class RuntimeHostAccessInputError extends Error { @@ -98,8 +104,9 @@ export class RuntimeHostAccessCommitOutcomeUnknownError extends Error { export function createAccessCredentialFile( credentials: readonly StoredAccessCredential[], + sessionGrants: readonly SessionCollaborationGrant[] = [], ): AccessCredentialFile { - return { schemaVersion: ACCESS_FILE_SCHEMA_VERSION, credentials }; + return { schemaVersion: ACCESS_FILE_SCHEMA_VERSION, credentials, sessionGrants }; } export function issuedAccessGrants(grants: readonly OperationKey[]): readonly OperationKey[] { @@ -117,6 +124,7 @@ export function assertAccessCredentialFileCapacity(file: AccessCredentialFile): revokedAt: '9999-12-31T23:59:59.999Z', }, ), + file.sessionGrants, ); serializeAccessCredentialFile(fullyRevoked); } @@ -178,7 +186,7 @@ function serializeAccessCredentialFile(file: AccessCredentialFile): string { } function decodeAccessFile(value: unknown): AccessCredentialFile { - if (!isRecord(value) || value.schemaVersion !== ACCESS_FILE_SCHEMA_VERSION) { + if (!isRecord(value) || (value.schemaVersion !== 1 && value.schemaVersion !== 2)) { throw new Error('Unsupported Runtime Host access file'); } if (!Array.isArray(value.credentials)) throw new Error('Invalid Runtime Host access file'); @@ -194,7 +202,26 @@ function decodeAccessFile(value: unknown): AccessCredentialFile { if (new Set(pendingPrincipals).size !== pendingPrincipals.length) { throw new Error('Duplicate Runtime Host pending credential principal'); } - return createAccessCredentialFile(credentials); + const sessionGrants = + value.schemaVersion === 1 + ? [] + : Array.isArray(value.sessionGrants) + ? value.sessionGrants.map(decodeStoredSessionGrant) + : (() => { + throw new Error('Invalid Runtime Host access grants'); + })(); + if (new Set(sessionGrants.map((grant) => grant.grantId)).size !== sessionGrants.length) { + throw new Error('Duplicate Runtime Host Session grant identity'); + } + const sessionByGuest = new Map(); + for (const grant of sessionGrants) { + const existing = sessionByGuest.get(grant.principalId); + if (existing !== undefined && existing !== grant.sessionId) { + throw new Error('A Session Guest cannot be granted multiple Sessions'); + } + sessionByGuest.set(grant.principalId, grant.sessionId); + } + return createAccessCredentialFile(credentials, sessionGrants); } function decodeStoredCredential(value: unknown): StoredAccessCredential { @@ -205,7 +232,11 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { const principalId = requireStoredString(value.principalId, 'principalId'); if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(principalId)) throw new Error('Invalid principalId'); const principalKind = value.principalKind === undefined ? 'remote_owner' : value.principalKind; - if (principalKind !== 'remote_owner' && principalKind !== 'capability_provider') { + if ( + principalKind !== 'remote_owner' && + principalKind !== 'capability_provider' && + principalKind !== 'session_guest' + ) { throw new Error('Invalid principalKind'); } if (value.status !== 'pending' && value.status !== 'active' && value.status !== 'revoked') { @@ -218,10 +249,21 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { if (new Set(storedOperationGrants).size !== storedOperationGrants.length) { throw new Error('Duplicate Runtime Host access operation grant'); } + const migratedOperationGrants = validateStoredGrants( + migrateStoredOperationGrants(storedOperationGrants), + ); + if ( + principalKind === 'session_guest' && + migratedOperationGrants.some( + (grant) => !(SESSION_GUEST_OPERATION_GRANTS as readonly OperationKey[]).includes(grant), + ) + ) { + throw new Error('Session Guest credential has an invalid operation grant'); + } const operationGrants = Object.freeze( - validateStoredGrants(migrateStoredOperationGrants(storedOperationGrants)).filter( - operationAllowsRemoteOwner, - ), + principalKind === 'session_guest' + ? [...SESSION_GUEST_OPERATION_GRANTS] + : migratedOperationGrants.filter(operationAllowsRemoteOwner), ); if (!operationGrants.includes('host.status')) { throw new Error('Runtime Host access credential lacks its liveness grant'); @@ -282,6 +324,27 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { }; } +function decodeStoredSessionGrant(value: unknown): SessionCollaborationGrant { + if (!isRecord(value)) throw new Error('Invalid Runtime Host Session grant'); + if (value.kind !== 'session_observation' && value.kind !== 'session_turn_request') { + throw new Error('Invalid Runtime Host Session grant kind'); + } + const grantId = requireStoredString(value.grantId, 'grantId'); + const principalId = requireStoredString(value.principalId, 'principalId'); + const sessionId = requireStoredString(value.sessionId, 'sessionId'); + if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(principalId)) throw new Error('Invalid principalId'); + if (!/^[A-Za-z0-9_.:-]{1,256}$/u.test(grantId)) throw new Error('Invalid grantId'); + if (!/^[A-Za-z0-9_.:-]{1,256}$/u.test(sessionId)) throw new Error('Invalid sessionId'); + const createdAt = requireStoredTimestamp(value.createdAt, 'createdAt'); + return Object.freeze({ + kind: value.kind, + grantId, + principalId, + sessionId, + createdAt, + }); +} + function migrateStoredOperationGrants(grants: readonly string[]): readonly string[] { const migrated: string[] = []; const seen = new Set(); @@ -341,3 +404,9 @@ function requireStoredString(value: unknown, label: string): string { } return value; } + +function requireStoredTimestamp(value: unknown, label: string): string { + const timestamp = requireStoredString(value, label); + if (!Number.isFinite(Date.parse(timestamp))) throw new Error(`Invalid ${label}`); + return timestamp; +} diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts index 6b246bc48c..d7b4e67a57 100644 --- a/packages/runtime-host/src/server/host-kernel.ts +++ b/packages/runtime-host/src/server/host-kernel.ts @@ -60,12 +60,15 @@ import { import { issueAccessCredential, finalizeAccessCredential, + prepareCollaborationInvitation, prepareAccessCredential, prepareAccessCredentialRotation, replaceAccessCredential, revokeAccessCredential, revokeAccessPrincipal, revokeAccessCredentialRotation, + revokeCollaborationGrant, + revokeCollaborationPrincipal, type RuntimeHostAccessAuthority, } from './access-authority.js'; import type { RuntimeHostConnectionAuthority } from './connection-authority.js'; @@ -691,6 +694,28 @@ export class RuntimeHostKernel { context.clientInstanceId, ), ), + 'collaboration.invitation.prepare': async (input) => + this.#settleAccessCredentialMutation( + prepareCollaborationInvitation(this.#options.accessAuthority, this.rootId, input), + ), + 'collaboration.access.query': async (input) => + this.#options.accessAuthority + ? { ok: true, result: this.#options.accessAuthority.queryCollaborationAccess(input) } + : { + ok: false, + error: { + code: 'operation_unavailable', + message: 'Runtime Host collaboration authority is unavailable', + }, + }, + 'collaboration.grant.revoke': async (input) => + this.#settleAccessCredentialMutation( + revokeCollaborationGrant(this.#options.accessAuthority, input), + ), + 'collaboration.principal.revoke': async (input) => + this.#settleAccessCredentialMutation( + revokeCollaborationPrincipal(this.#options.accessAuthority, input.principalId), + ), }, createPeerMeshOperationHandlers(this.#options.peerMesh, { requestDrain: () => this.#requestDrain(), diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 77af9e17a5..552233e799 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -33,6 +33,7 @@ import { } from '../protocol/index.js'; import { HOST_BOOTSTRAP_OPERATION_SPECS } from '../protocol/host-status.js'; import { ACCESS_AUTHORITY_OPERATION_SPECS } from '../protocol/access-authority.js'; +import { SESSION_COLLABORATION_OPERATION_SPECS } from '../protocol/session-collaboration.js'; import { PEER_MESH_OPERATION_SPECS } from '../protocol/peer-mesh.js'; import { createPeerMeshOperationHandlers } from './peer-mesh-authority.js'; @@ -61,6 +62,7 @@ export type OperationHandlerMap = { export type HostCoreOperationKey = | keyof typeof HOST_BOOTSTRAP_OPERATION_SPECS | keyof typeof ACCESS_AUTHORITY_OPERATION_SPECS + | keyof typeof SESSION_COLLABORATION_OPERATION_SPECS | keyof typeof PEER_MESH_OPERATION_SPECS; export type DomainOperationKey = Exclude; export type TurnOperationKey = Extract< @@ -217,7 +219,7 @@ export type WorkHubCoordinationOperationHandlerMap = Pick< >; export type AccessAuthorityOperationHandlerMap = Pick< OperationHandlerMap, - keyof typeof ACCESS_AUTHORITY_OPERATION_SPECS + keyof typeof ACCESS_AUTHORITY_OPERATION_SPECS | keyof typeof SESSION_COLLABORATION_OPERATION_SPECS >; export type HostCoreUnavailableOperationHandlerMap = AccessAuthorityOperationHandlerMap & Pick; @@ -254,6 +256,7 @@ export function createUnavailableDomainOperationHandlers(): DomainOperationHandl if ( Object.hasOwn(HOST_BOOTSTRAP_OPERATION_SPECS, operation) || Object.hasOwn(ACCESS_AUTHORITY_OPERATION_SPECS, operation) || + Object.hasOwn(SESSION_COLLABORATION_OPERATION_SPECS, operation) || Object.hasOwn(PEER_MESH_OPERATION_SPECS, operation) ) { continue; @@ -333,6 +336,34 @@ export function createUnavailableAccessAuthorityOperationHandlers(): AccessAutho message: 'Runtime Host access credentials are unavailable', }, }), + 'collaboration.invitation.prepare': async () => ({ + ok: false, + error: { + code: 'operation_unavailable', + message: 'Runtime Host collaboration authority is unavailable', + }, + }), + 'collaboration.access.query': async () => ({ + ok: false, + error: { + code: 'operation_unavailable', + message: 'Runtime Host collaboration authority is unavailable', + }, + }), + 'collaboration.grant.revoke': async () => ({ + ok: false, + error: { + code: 'operation_unavailable', + message: 'Runtime Host collaboration authority is unavailable', + }, + }), + 'collaboration.principal.revoke': async () => ({ + ok: false, + error: { + code: 'operation_unavailable', + message: 'Runtime Host collaboration authority is unavailable', + }, + }), }; } diff --git a/packages/runtime-host/src/server/websocket-listener.ts b/packages/runtime-host/src/server/websocket-listener.ts index a954cfb3c7..5e084102bc 100644 --- a/packages/runtime-host/src/server/websocket-listener.ts +++ b/packages/runtime-host/src/server/websocket-listener.ts @@ -46,7 +46,7 @@ export interface StartRuntimeHostWebSocketListenerOptions { readonly tls?: RuntimeHostWebSocketTls; readonly allowInsecureRemote?: boolean; readonly allowedOrigins?: readonly string[]; - readonly accessAuthority: RuntimeHostAccessAuthority; + readonly accessAuthority: Pick; readonly isReady: () => boolean; readonly accept: (connection: RuntimeHostListenerConnection) => void; }