blob: 58164f50ebca6880e014e07455d26b0cae092a16 [file]
/*
* 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 { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { join } from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import { runtimeHostAccessCredentialHash } from '../access-credential-identity.js';
import {
type AccessCredentialIssueInput,
type AccessCredentialIssueResult,
type ClientCapabilityOwnerIdentity,
type AccessCredentialFinalizeResult,
type AccessCredentialPrepareInput,
type AccessCredentialPrepareResult,
type AccessCredentialReplaceInput,
type AccessCredentialReplaceResult,
type AccessCredentialRevokeInput,
type AccessCredentialRevokeResult,
type AccessCredentialRotationPrepareInput,
type AccessCredentialRotationPrepareResult,
type AccessCredentialRotationRevokeInput,
type AccessCredentialRotationRevokeResult,
type AccessPrincipalRevokeInput,
type AccessPrincipalRevokeResult,
type CollaborationAccessQueryInput,
type CollaborationAccessQueryResult,
type CollaborationGrantRevokeInput,
type CollaborationGrantRevokeResult,
type CollaborationInvitationPrepareInput,
type CollaborationInvitationPrepareResult,
type CollaborationPrincipalRevokeResult,
type CollaborationPrincipalRenameInput,
type CollaborationPrincipalRenameResult,
decodeCollaborationDisplayName,
type CollaborationTurnRequestAcknowledgeInput,
type CollaborationTurnRequestAcknowledgeResult,
type CollaborationTurnRequestCreateInput,
type CollaborationTurnRequestDecideInput,
type CollaborationTurnRequestDecideResult,
type CollaborationTurnRequestQueryInput,
type CollaborationTurnRequestQueryResult,
type CollaborationTurnRequestWithdrawInput,
type CollaborationTurnRequestWithdrawResult,
encodeCollaborationInvitationCode,
type SessionCollaborationGrant,
type SessionCollaborationGrantKind,
type SessionTurnAccessRequest,
} from '../protocol/index.js';
import {
createRuntimeHostConnectionAuthority,
type RuntimeHostConnectionAuthority,
} from './connection-authority.js';
import type { OperationOutcome } from '../protocol/operations.js';
import {
createAccessCredentialDelivery,
discardAccessCredentialDelivery,
purgeAccessCredentialDeliveries,
} from '../control/access-credential-delivery.js';
import {
ACCESS_FILE_NAME,
assertAccessCredentialFileCapacity,
CAPABILITY_PROVIDER_OPERATION_GRANTS,
createAccessCredentialFile,
effectiveOperationGrants,
issuedAccessGrants,
readAccessCredentialFile,
RuntimeHostAccessCommitOutcomeUnknownError,
RuntimeHostAccessInputError,
type AccessCredentialFile,
type StoredAccessCredential,
SESSION_GUEST_OPERATION_GRANTS,
writeAccessCredentialFile,
} from './access-credential-store.js';
const ACCESS_CREDENTIAL_PREFIX = 'maka_rh_';
const PENDING_CREDENTIAL_LIFETIME_MS = 15 * 60_000;
const TURN_ACCESS_REQUEST_ACTIVE_MAX = 4;
const CAPABILITY_PROVIDER_GRANTS = new Set<string>(CAPABILITY_PROVIDER_OPERATION_GRANTS);
function createNextAccessCredentialFile(
current: AccessCredentialFile,
credentials: readonly StoredAccessCredential[],
sessionGrants: readonly SessionCollaborationGrant[],
turnAccessRequests: readonly SessionTurnAccessRequest[] = current.turnAccessRequests,
): AccessCredentialFile {
return createAccessCredentialFile(credentials, sessionGrants, turnAccessRequests);
}
export interface RuntimeHostAccessAuthority {
authenticate(credential: string): RuntimeHostConnectionAuthority | undefined;
issue(input: AccessCredentialIssueInput): Promise<AccessCredentialIssueResult>;
replace(input: AccessCredentialReplaceInput): Promise<AccessCredentialReplaceResult>;
prepare(input: AccessCredentialPrepareInput): Promise<AccessCredentialPrepareResult>;
revoke(input: AccessCredentialRevokeInput): Promise<AccessCredentialRevokeResult>;
revokePrincipal(input: AccessPrincipalRevokeInput): Promise<AccessPrincipalRevokeResult>;
prepareRotation(
input: AccessCredentialRotationPrepareInput,
): Promise<AccessCredentialRotationPrepareResult>;
revokeRotation(
input: AccessCredentialRotationRevokeInput,
): Promise<AccessCredentialRotationRevokeResult>;
finalize(
credentialId: string,
clientInstanceId: string,
connectionAlreadyFinalized: boolean,
): Promise<AccessCredentialFinalizeResult>;
prepareCollaborationInvitation(
rootId: string,
input: CollaborationInvitationPrepareInput,
): Promise<CollaborationInvitationPrepareResult>;
queryCollaborationAccess(input: CollaborationAccessQueryInput): CollaborationAccessQueryResult;
revokeCollaborationGrant(
input: CollaborationGrantRevokeInput,
): Promise<CollaborationGrantRevokeResult>;
revokeCollaborationPrincipal(principalId: string): Promise<CollaborationPrincipalRevokeResult>;
renameCollaborationPrincipal(
input: CollaborationPrincipalRenameInput,
): Promise<CollaborationPrincipalRenameResult>;
createTurnAccessRequest(
principalId: string,
input: CollaborationTurnRequestCreateInput,
): Promise<SessionTurnAccessRequest>;
queryTurnAccessRequests(
principal: Pick<RuntimeHostConnectionAuthority, 'principalId' | 'principalKind'>,
input: CollaborationTurnRequestQueryInput,
): CollaborationTurnRequestQueryResult;
acknowledgeTurnAccessRequest(
principalId: string,
input: CollaborationTurnRequestAcknowledgeInput,
): Promise<CollaborationTurnRequestAcknowledgeResult>;
withdrawTurnAccessRequest(
principalId: string,
input: CollaborationTurnRequestWithdrawInput,
): Promise<CollaborationTurnRequestWithdrawResult>;
decideTurnAccessRequest(
principalId: string,
input: CollaborationTurnRequestDecideInput,
): Promise<CollaborationTurnRequestDecideResult>;
completeTurnAccessRequest(
requestId: string,
admission: 'started' | 'blocked' | 'failed',
): Promise<void>;
approvedTurnAccessRequests(): readonly SessionTurnAccessRequest[];
activeSessionGrant(
principalId: string,
sessionId: string,
kind: SessionCollaborationGrantKind,
): SessionCollaborationGrant | undefined;
activeSessionGrantForPrincipal(
principalId: string,
kind: SessionCollaborationGrantKind,
): SessionCollaborationGrant | undefined;
hasActiveBoundClientIdentity(principalId: string, clientInstanceId: string): boolean;
subscribeRevocations(listener: (credentialId: string) => void): () => void;
subscribeGrantRevocations(listener: (grant: SessionCollaborationGrant) => void): () => void;
subscribeApprovedTurnAccessRequests(
listener: (request: SessionTurnAccessRequest) => void,
): () => void;
close(): Promise<void>;
}
export async function openRuntimeHostAccessAuthority(
controlDirectory: string,
input: {
readonly writeFile?: typeof writeAccessCredentialFile;
} = {},
): Promise<RuntimeHostAccessAuthority> {
await purgeAccessCredentialDeliveries(controlDirectory);
const path = join(controlDirectory, ACCESS_FILE_NAME);
return new FileRuntimeHostAccessAuthority(
controlDirectory,
path,
await readAccessCredentialFile(path),
input.writeFile ?? writeAccessCredentialFile,
);
}
class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
readonly #controlDirectory: string;
readonly #path: string;
readonly #writeFile: typeof writeAccessCredentialFile;
#file: AccessCredentialFile;
#mutation = Promise.resolve();
#expiryTimer: NodeJS.Timeout | undefined;
#closed = false;
readonly #revocationListeners = new Set<(credentialId: string) => void>();
readonly #grantRevocationListeners = new Set<(grant: SessionCollaborationGrant) => void>();
readonly #approvedTurnAccessRequestListeners = new Set<
(request: SessionTurnAccessRequest) => void
>();
constructor(
controlDirectory: string,
path: string,
file: AccessCredentialFile,
writeFile: typeof writeAccessCredentialFile,
) {
this.#controlDirectory = controlDirectory;
this.#path = path;
this.#file = file;
this.#writeFile = writeFile;
this.#schedulePendingExpiry();
}
authenticate(credential: string): RuntimeHostConnectionAuthority | undefined {
if (this.#closed) return undefined;
const candidate = runtimeHostAccessCredentialHash(credential);
let match: StoredAccessCredential | undefined;
for (const stored of this.#file.credentials) {
const storedHash = Buffer.from(stored.credentialHash, 'hex');
const equal =
storedHash.byteLength === candidate.byteLength && timingSafeEqual(storedHash, candidate);
if (
equal &&
(stored.status === 'active' ||
(stored.status === 'pending' && Date.parse(stored.expiresAt!) > Date.now()))
) {
match = stored;
}
}
return match
? createRuntimeHostConnectionAuthority({
principalKind: match.principalKind,
principalId: match.principalId,
credentialId: match.credentialId,
operationGrants: match.bindClientInstanceOnFinalize
? ['host.status', 'access.credential.finalize']
: match.clientInstanceId
? [...effectiveOperationGrants(match), 'access.credential.finalize']
: effectiveOperationGrants(match),
canPublishClientCapabilities:
!match.bindClientInstanceOnFinalize && match.canPublishClientCapabilities,
canUseHostPaths: !match.bindClientInstanceOnFinalize && match.canUseHostPaths,
...(match.clientInstanceId ? { clientInstanceId: match.clientInstanceId } : {}),
...(match.capabilityOwner ? { capabilityOwner: match.capabilityOwner } : {}),
})
: undefined;
}
issue(input: AccessCredentialIssueInput): Promise<AccessCredentialIssueResult> {
return this.#issue(input, 'issue');
}
replace(input: AccessCredentialReplaceInput): Promise<AccessCredentialReplaceResult> {
return this.#issue(input, 'replace');
}
prepare(input: AccessCredentialPrepareInput): Promise<AccessCredentialPrepareResult> {
return this.#issue(input, 'prepare');
}
prepareCollaborationInvitation(
rootId: string,
input: CollaborationInvitationPrepareInput,
): Promise<CollaborationInvitationPrepareResult> {
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',
grants: 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 = createNextAccessCredentialFile(
this.#file,
[...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.displayName === undefined
? {}
: { displayName: credential.displayName }),
...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}),
},
]
: [];
},
);
return { principals, grants };
}
revokeCollaborationGrant(
input: CollaborationGrantRevokeInput,
): Promise<CollaborationGrantRevokeResult> {
return this.#mutate(async () => {
const current = this.#file.sessionGrants.find((grant) => grant.grantId === input.grantId);
if (!current) return { revoked: false };
await this.#commit(
createNextAccessCredentialFile(
this.#file,
this.#file.credentials,
this.#file.sessionGrants.filter((grant) => grant !== current),
current.kind === 'session_turn_request'
? removePendingTurnAccessRequests(
this.#file.turnAccessRequests,
(request) => request.grantId === current.grantId,
)
: this.#file.turnAccessRequests,
),
[],
[current],
);
return { revoked: true };
});
}
revokeCollaborationPrincipal(principalId: string): Promise<CollaborationPrincipalRevokeResult> {
return this.revokePrincipal({
principalKind: 'session_guest',
principalId,
});
}
renameCollaborationPrincipal(
input: CollaborationPrincipalRenameInput,
): Promise<CollaborationPrincipalRenameResult> {
const displayName = decodeCollaborationDisplayName(input.displayName);
return this.#mutate(async () => {
const matches = (credential: StoredAccessCredential) =>
credential.principalKind === 'session_guest' &&
credential.principalId === input.principalId &&
credential.status !== 'revoked';
if (!this.#file.credentials.some(matches)) return { renamed: false };
await this.#commit(
createNextAccessCredentialFile(
this.#file,
this.#file.credentials.map((credential) =>
matches(credential) ? { ...credential, displayName } : credential,
),
this.#file.sessionGrants,
),
[],
);
return { renamed: true };
});
}
createTurnAccessRequest(
principalId: string,
input: CollaborationTurnRequestCreateInput,
): Promise<SessionTurnAccessRequest> {
return this.#mutate(async () => {
const grant = this.activeSessionGrant(
principalId,
input.intent.sessionId,
'session_turn_request',
);
if (!grant) {
throw new RuntimeHostAccessInputError('This Guest cannot request a Turn in this Session');
}
const retainedRequests = this.#file.turnAccessRequests;
const existing = retainedRequests.find(
(request) =>
request.principalId === principalId &&
request.intent.sessionId === input.intent.sessionId &&
request.intent.turnId === input.intent.turnId,
);
if (existing) {
if (!isDeepStrictEqual(existing.intent, input.intent)) {
throw new RuntimeHostAccessInputError(
'A Turn access request already uses this Turn identity with a different intent',
);
}
return existing;
}
if (!('content' in input.intent)) {
const { sessionId, sourceTurnId } = input.intent;
const equivalentActive = retainedRequests.find(
(request) =>
request.principalId === principalId &&
isActiveTurnAccessRequest(request) &&
!('content' in request.intent) &&
request.intent.sessionId === sessionId &&
request.intent.sourceTurnId === sourceTurnId,
);
if (equivalentActive) return equivalentActive;
}
if (
retainedRequests.filter(isActiveTurnAccessRequest).length >= TURN_ACCESS_REQUEST_ACTIVE_MAX
) {
throw new RuntimeHostAccessInputError(
'Too many Turn access requests are awaiting an Owner decision or admission',
);
}
if (
retainedRequests.filter(
(request) => request.principalId === principalId && !isActiveTurnAccessRequest(request),
).length >= TURN_ACCESS_REQUEST_ACTIVE_MAX
) {
throw new RuntimeHostAccessInputError(
'Review earlier Turn access request results before creating another request',
);
}
const request: SessionTurnAccessRequest = {
requestId: randomUUID(),
principalId,
grantId: grant.grantId,
intent: input.intent,
createdAt: new Date().toISOString(),
state: { kind: 'pending' },
};
const nextFile = createAccessCredentialFile(
this.#file.credentials,
this.#file.sessionGrants,
[...retainedRequests, request],
);
assertAccessCredentialFileCapacity(nextFile);
await this.#commit(nextFile);
return request;
});
}
queryTurnAccessRequests(
principal: Pick<RuntimeHostConnectionAuthority, 'principalId' | 'principalKind'>,
input: CollaborationTurnRequestQueryInput,
): CollaborationTurnRequestQueryResult {
const guest = principal.principalKind === 'session_guest';
return {
canRequestTurns:
guest &&
input.sessionId !== undefined &&
this.activeSessionGrant(principal.principalId, input.sessionId, 'session_turn_request') !==
undefined,
requests: this.#file.turnAccessRequests.filter(
(request) =>
(!guest || request.principalId === principal.principalId) &&
(input.sessionId === undefined || request.intent.sessionId === input.sessionId),
),
};
}
acknowledgeTurnAccessRequest(
principalId: string,
input: CollaborationTurnRequestAcknowledgeInput,
): Promise<CollaborationTurnRequestAcknowledgeResult> {
return this.#mutate(async () => {
const current = this.#file.turnAccessRequests.find(
(request) => request.requestId === input.requestId && request.principalId === principalId,
);
if (
!current ||
current.state.kind === 'pending' ||
(current.state.kind === 'approved' && current.state.admission === 'pending')
) {
return { acknowledged: false };
}
await this.#commit(
createAccessCredentialFile(
this.#file.credentials,
this.#file.sessionGrants,
this.#file.turnAccessRequests.filter((request) => request !== current),
),
);
return { acknowledged: true };
});
}
withdrawTurnAccessRequest(
principalId: string,
input: CollaborationTurnRequestWithdrawInput,
): Promise<CollaborationTurnRequestWithdrawResult> {
return this.#mutate(async () => {
const current = this.#file.turnAccessRequests.find(
(request) => request.requestId === input.requestId && request.principalId === principalId,
);
if (!current || current.state.kind !== 'pending') return { withdrawn: false };
await this.#commit(
createAccessCredentialFile(
this.#file.credentials,
this.#file.sessionGrants,
this.#file.turnAccessRequests.filter((request) => request !== current),
),
);
return { withdrawn: true };
});
}
decideTurnAccessRequest(
principalId: string,
input: CollaborationTurnRequestDecideInput,
): Promise<CollaborationTurnRequestDecideResult> {
return this.#mutate(async () => {
const current = this.#file.turnAccessRequests.find(
(request) => request.requestId === input.requestId,
);
if (!current) return { kind: 'not_found' };
if (current.state.kind !== 'pending') {
return { kind: 'already_decided', request: current };
}
if (
input.decision === 'approve' &&
!this.activeSessionGrant(
current.principalId,
current.intent.sessionId,
'session_turn_request',
)
) {
throw new RuntimeHostAccessInputError('The Guest can no longer request this Turn');
}
const decidedAt = new Date().toISOString();
const request: SessionTurnAccessRequest = {
...current,
state:
input.decision === 'approve'
? {
kind: 'approved',
decidedAt,
decidedBy: principalId,
admission: 'pending',
}
: { kind: 'rejected', decidedAt, decidedBy: principalId },
};
await this.#commit(
createAccessCredentialFile(
this.#file.credentials,
this.#file.sessionGrants,
replaceTurnAccessRequest(this.#file.turnAccessRequests, current, request),
),
[],
[],
input.decision === 'approve' ? [request] : [],
);
return { kind: 'decided', request };
});
}
completeTurnAccessRequest(
requestId: string,
admission: 'started' | 'blocked' | 'failed',
): Promise<void> {
return this.#mutate(async () => {
const current = this.#file.turnAccessRequests.find(
(request) => request.requestId === requestId,
);
if (!current || current.state.kind !== 'approved' || current.state.admission !== 'pending') {
return;
}
const guestCanObserve = this.#file.credentials.some(
(credential) =>
credential.principalKind === 'session_guest' &&
credential.principalId === current.principalId &&
credential.status !== 'revoked',
);
const request: SessionTurnAccessRequest = {
...current,
state: {
...current.state,
admission,
},
};
await this.#commit(
createAccessCredentialFile(
this.#file.credentials,
this.#file.sessionGrants,
guestCanObserve
? replaceTurnAccessRequest(this.#file.turnAccessRequests, current, request)
: this.#file.turnAccessRequests.filter((candidate) => candidate !== current),
),
);
});
}
approvedTurnAccessRequests(): readonly SessionTurnAccessRequest[] {
return this.#file.turnAccessRequests.filter(
(request) => request.state.kind === 'approved' && request.state.admission === 'pending',
);
}
activeSessionGrant(
principalId: string,
sessionId: string,
kind: SessionCollaborationGrantKind,
): SessionCollaborationGrant | undefined {
return this.#file.sessionGrants.find(
(grant) =>
grant.principalId === principalId && grant.sessionId === sessionId && grant.kind === kind,
);
}
activeSessionGrantForPrincipal(
principalId: string,
kind: SessionCollaborationGrantKind,
): SessionCollaborationGrant | undefined {
return this.#file.sessionGrants.find(
(grant) => grant.principalId === principalId && grant.kind === kind,
);
}
hasActiveBoundClientIdentity(principalId: string, clientInstanceId: string): boolean {
return this.#file.credentials.some(
(credential) =>
credential.status === 'active' &&
credential.principalKind === 'remote_owner' &&
credential.principalId === principalId &&
credential.clientInstanceId === clientInstanceId,
);
}
prepareRotation(
input: AccessCredentialRotationPrepareInput,
): Promise<AccessCredentialRotationPrepareResult> {
return this.#mutate(async () => {
const current = this.#file.credentials.find(
(credential) =>
credential.credentialId === input.replacementOfCredentialId &&
credential.status === 'active',
);
if (!current) {
throw new RuntimeHostAccessInputError('The credential being rotated is no longer active');
}
if (current.principalKind !== 'remote_owner') {
throw new RuntimeHostAccessInputError('This credential cannot use owner rotation');
}
return this.#createCredential(
{
principalId: current.principalId,
principalKind: current.principalKind,
operationGrants: effectiveOperationGrants(current),
canPublishClientCapabilities: current.canPublishClientCapabilities,
canUseHostPaths: current.canUseHostPaths,
bindClientInstance: current.clientInstanceId !== undefined,
},
'prepare',
current.grants,
);
});
}
#issue(
input: AccessCredentialIssueInput | AccessCredentialPrepareInput,
mode: 'issue' | 'replace' | 'prepare',
): Promise<AccessCredentialIssueResult> {
return this.#mutate(() => this.#createCredential(input, mode));
}
async #createCredential(
input: AccessCredentialIssueInput | AccessCredentialPrepareInput,
mode: 'issue' | 'replace' | 'prepare',
// Rotation exchanges the secret and keeps the authority, so it hands over
// the predecessor's record verbatim — including keys this build cannot
// account for. Every other path records exactly what it just issued.
inheritedGrants?: readonly string[],
): Promise<AccessCredentialIssueResult> {
const operationGrants = issuedAccessGrants(input.operationGrants);
const grants = inheritedGrants ?? operationGrants;
assertCredentialAuthority(input, operationGrants);
const capabilityOwner = this.#resolveCapabilityOwner(
'capabilityOwnerCredentialId' in input ? input.capabilityOwnerCredentialId : undefined,
);
if (
mode === 'prepare' &&
(input.principalKind !== 'remote_owner' ||
!operationGrants.includes('access.credential.finalize'))
) {
throw new RuntimeHostAccessInputError(
'A pairing candidate must be a remote owner that can finalize its pairing',
);
}
const credentialId = randomUUID();
createRuntimeHostConnectionAuthority({
principalKind: input.principalKind,
principalId: input.principalId,
credentialId,
operationGrants,
canPublishClientCapabilities: input.canPublishClientCapabilities,
canUseHostPaths: input.canUseHostPaths,
...(capabilityOwner ? { capabilityOwner } : {}),
});
const credential = `${ACCESS_CREDENTIAL_PREFIX}${randomBytes(32).toString('base64url')}`;
const createdAt = new Date();
const stored: StoredAccessCredential = {
credentialId,
credentialHash: runtimeHostAccessCredentialHash(credential).toString('hex'),
principalId: input.principalId,
principalKind: input.principalKind,
status: mode === 'prepare' ? 'pending' : 'active',
grants,
canPublishClientCapabilities: input.canPublishClientCapabilities,
canUseHostPaths: input.canUseHostPaths,
...(capabilityOwner ? { capabilityOwner } : {}),
createdAt: createdAt.toISOString(),
...(mode === 'prepare'
? {
expiresAt: new Date(createdAt.getTime() + PENDING_CREDENTIAL_LIFETIME_MS).toISOString(),
...('bindClientInstance' in input && input.bindClientInstance
? { bindClientInstanceOnFinalize: true as const }
: {}),
}
: {}),
};
const replaced = this.#file.credentials.filter(
(candidate) =>
candidate.principalKind === input.principalKind &&
candidate.principalId === input.principalId &&
((mode === 'replace' && candidate.status !== 'revoked') ||
(mode === 'prepare' && candidate.status === 'pending')),
);
const retained =
replaced.length === 0
? this.#file.credentials
: this.#file.credentials.filter((candidate) => !replaced.includes(candidate));
const nextFile = createNextAccessCredentialFile(
this.#file,
[...retained, stored],
this.#file.sessionGrants,
);
assertAccessCredentialFileCapacity(nextFile);
const deliveryId = await createAccessCredentialDelivery(
this.#controlDirectory,
credentialId,
credential,
);
try {
await this.#commit(
nextFile,
replaced.map((credential) => credential.credentialId),
);
} catch (error) {
await discardAccessCredentialDelivery(this.#controlDirectory, deliveryId);
throw error;
}
return {
credentialId,
deliveryId,
principalId: stored.principalId,
principalKind: input.principalKind,
operationGrants,
canPublishClientCapabilities: stored.canPublishClientCapabilities,
canUseHostPaths: stored.canUseHostPaths,
...(stored.capabilityOwner ? { capabilityOwner: stored.capabilityOwner } : {}),
};
}
#resolveCapabilityOwner(
credentialId: string | undefined,
): ClientCapabilityOwnerIdentity | undefined {
if (!credentialId) return undefined;
const owner = this.#file.credentials.find(
(credential) => credential.credentialId === credentialId && credential.status === 'active',
);
if (!owner || owner.principalKind !== 'remote_owner') {
throw new RuntimeHostAccessInputError(
'A capability provider owner must be one active remote-owner credential',
);
}
if (!owner.clientInstanceId) {
throw new RuntimeHostAccessInputError(
'A capability provider owner credential must be bound to one Client identity',
);
}
return Object.freeze({
principalId: owner.principalId,
clientInstanceId: owner.clientInstanceId,
});
}
revoke(input: AccessCredentialRevokeInput): Promise<AccessCredentialRevokeResult> {
return this.#mutate(async () => {
return this.#revoke(input.credentialId);
});
}
revokePrincipal(input: AccessPrincipalRevokeInput): Promise<AccessPrincipalRevokeResult> {
return this.#mutate(async () => {
const matches = this.#file.credentials.filter(
(credential) =>
credential.status !== 'revoked' &&
credential.principalKind === input.principalKind &&
credential.principalId === input.principalId,
);
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();
const credentials = this.#file.credentials.flatMap((credential) => {
if (!matchedIds.has(credential.credentialId)) return [credential];
if (credential.status === 'pending') return [];
const { clientInstanceId: _clientInstanceId, ...revoked } = credential;
return [{ ...revoked, status: 'revoked' as const, revokedAt }];
});
await this.#commit(
createNextAccessCredentialFile(
this.#file,
credentials,
this.#file.sessionGrants.filter((grant) => !activeGrants.includes(grant)),
input.principalKind === 'session_guest'
? retirePrincipalTurnAccessRequests(this.#file.turnAccessRequests, input.principalId)
: this.#file.turnAccessRequests,
),
[...matchedIds],
activeGrants,
);
return { revoked: true };
});
}
revokeRotation(
input: AccessCredentialRotationRevokeInput,
): Promise<AccessCredentialRotationRevokeResult> {
return this.#mutate(async () => {
const requiredActiveCredential = this.#file.credentials.find(
(credential) => credential.credentialId === input.requiredActiveCredentialId,
);
if (!requiredActiveCredential || requiredActiveCredential.status !== 'active') {
throw new RuntimeHostAccessInputError(
'The required credential is no longer active on this Runtime Host',
);
}
if (input.credentialId === input.requiredActiveCredentialId) {
throw new RuntimeHostAccessInputError('A credential cannot revoke itself');
}
return this.#revoke(input.credentialId);
});
}
async #revoke(credentialId: string): Promise<AccessCredentialRevokeResult> {
const index = this.#file.credentials.findIndex(
(credential) => credential.credentialId === credentialId,
);
if (index === -1 || this.#file.credentials[index]?.status === 'revoked') {
return { credentialId, revoked: false };
}
const current = this.#file.credentials[index]!;
const pendingForPrincipal =
current.status === 'active'
? this.#file.credentials.filter(
(credential) =>
credential.status === 'pending' &&
credential.principalKind === current.principalKind &&
credential.principalId === current.principalId,
)
: [];
const credentials =
current.status === 'pending'
? this.#file.credentials.filter((credential) => credential !== current)
: this.#file.credentials
.filter((credential) => !pendingForPrincipal.includes(credential))
.map((credential) => {
if (credential !== current) return credential;
const { clientInstanceId: _clientInstanceId, ...revoked } = credential;
return {
...revoked,
status: 'revoked' as const,
revokedAt: new Date().toISOString(),
};
});
await this.#commit(
createNextAccessCredentialFile(this.#file, credentials, this.#file.sessionGrants),
[current, ...pendingForPrincipal].map((credential) => credential.credentialId),
);
return { credentialId, revoked: true };
}
finalize(
credentialId: string,
clientInstanceId: string,
connectionAlreadyFinalized: boolean,
): Promise<AccessCredentialFinalizeResult> {
return this.#mutate(async () => {
const retained = this.#file.credentials.find(
(credential) => credential.credentialId === credentialId,
);
if (!retained || retained.status === 'revoked') {
throw new RuntimeHostAccessInputError('The current access credential is no longer active');
}
return this.#finalize(retained, clientInstanceId, connectionAlreadyFinalized);
});
}
async #finalize(
retained: StoredAccessCredential,
clientInstanceId: string,
connectionAlreadyFinalized: boolean,
): Promise<AccessCredentialFinalizeResult> {
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 && !connectionAlreadyFinalized,
};
}
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 = createNextAccessCredentialFile(
this.#file,
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);
}
subscribeApprovedTurnAccessRequests(
listener: (request: SessionTurnAccessRequest) => void,
): () => void {
if (this.#closed) return () => undefined;
this.#approvedTurnAccessRequestListeners.add(listener);
return () => this.#approvedTurnAccessRequestListeners.delete(listener);
}
close(): Promise<void> {
if (!this.#closed) {
this.#closed = true;
if (this.#expiryTimer) clearTimeout(this.#expiryTimer);
this.#expiryTimer = undefined;
this.#revocationListeners.clear();
this.#grantRevocationListeners.clear();
this.#approvedTurnAccessRequestListeners.clear();
}
return this.#mutation;
}
#mutate<T>(operation: () => Promise<T>): Promise<T> {
if (this.#closed) {
return Promise.reject(new Error('Runtime Host access authority is closed'));
}
const result = this.#mutation.then(operation, operation);
this.#mutation = result.then(
() => undefined,
() => undefined,
);
return result;
}
async #commit(
file: AccessCredentialFile,
revokedCredentialIds: readonly string[] = [],
revokedGrants: readonly SessionCollaborationGrant[] = [],
approvedTurnAccessRequests: readonly SessionTurnAccessRequest[] = [],
): Promise<void> {
let outcomeUnknown: RuntimeHostAccessCommitOutcomeUnknownError | undefined;
try {
await this.#writeFile(this.#path, file);
} catch (error) {
if (!(error instanceof RuntimeHostAccessCommitOutcomeUnknownError)) throw error;
outcomeUnknown = error;
}
this.#file = file;
this.#schedulePendingExpiry();
for (const credentialId of revokedCredentialIds) this.#publishRevocation(credentialId);
for (const grant of revokedGrants) this.#publishGrantRevocation(grant);
if (!outcomeUnknown) {
for (const request of approvedTurnAccessRequests) {
for (const listener of this.#approvedTurnAccessRequestListeners) listener(request);
}
}
if (outcomeUnknown) throw outcomeUnknown;
}
async #expirePending(): Promise<void> {
const now = Date.now();
const expired = this.#file.credentials.filter(
(credential) => credential.status === 'pending' && Date.parse(credential.expiresAt!) <= now,
);
if (expired.length === 0) {
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(
createNextAccessCredentialFile(
this.#file,
this.#file.credentials.filter((credential) => !expired.includes(credential)),
this.#file.sessionGrants.filter((grant) => !expiredGrants.includes(grant)),
),
expired.map((credential) => credential.credentialId),
expiredGrants,
);
}
#schedulePendingExpiry(retryMs?: number): void {
if (this.#expiryTimer) clearTimeout(this.#expiryTimer);
if (this.#closed) {
this.#expiryTimer = undefined;
return;
}
const nextExpiry = this.#file.credentials.reduce<number | undefined>((earliest, credential) => {
if (credential.status !== 'pending') return earliest;
const expiresAt = Date.parse(credential.expiresAt!);
return earliest === undefined || expiresAt < earliest ? expiresAt : earliest;
}, undefined);
if (nextExpiry === undefined) {
this.#expiryTimer = undefined;
return;
}
this.#expiryTimer = setTimeout(
() => {
this.#expiryTimer = undefined;
void this.#mutate(() => this.#expirePending()).catch(() => {
if (!this.#closed) this.#schedulePendingExpiry(1_000);
});
},
retryMs ?? Math.max(0, nextExpiry - Date.now()),
);
this.#expiryTimer.unref();
}
#publishRevocation(credentialId: string): void {
for (const listener of this.#revocationListeners) {
try {
listener(credentialId);
} catch {
// Revocation is already durable; an observer cannot roll it back.
}
}
}
#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 isActiveTurnAccessRequest(request: SessionTurnAccessRequest): boolean {
return (
request.state.kind === 'pending' ||
(request.state.kind === 'approved' && request.state.admission === 'pending')
);
}
function replaceTurnAccessRequest(
requests: readonly SessionTurnAccessRequest[],
current: SessionTurnAccessRequest,
replacement: SessionTurnAccessRequest,
): readonly SessionTurnAccessRequest[] {
return [...requests.filter((request) => request !== current), replacement];
}
function removePendingTurnAccessRequests(
requests: readonly SessionTurnAccessRequest[],
matches: (request: SessionTurnAccessRequest) => boolean,
): readonly SessionTurnAccessRequest[] {
return requests.filter((request) => request.state.kind !== 'pending' || !matches(request));
}
function retirePrincipalTurnAccessRequests(
requests: readonly SessionTurnAccessRequest[],
principalId: string,
): readonly SessionTurnAccessRequest[] {
return requests.filter(
(request) =>
request.principalId !== principalId ||
(request.state.kind === 'approved' && request.state.admission === 'pending'),
);
}
function activatePendingCredential(
credential: StoredAccessCredential,
clientInstanceId: string,
): StoredAccessCredential {
const { expiresAt: _expiresAt, bindClientInstanceOnFinalize, ...retained } = credential;
return {
...retained,
status: 'active',
...(bindClientInstanceOnFinalize ? { clientInstanceId } : {}),
};
}
function assertCredentialAuthority(
input: AccessCredentialIssueInput,
operationGrants: readonly string[],
): void {
if (input.principalKind !== 'capability_provider') {
if (input.capabilityOwnerCredentialId) {
throw new RuntimeHostAccessInputError(
'Only a capability provider credential may declare a Client owner',
);
}
return;
}
if (!input.canPublishClientCapabilities || input.canUseHostPaths) {
throw new RuntimeHostAccessInputError(
'A capability provider must publish Client Capabilities without Host path authority',
);
}
if (
operationGrants.length !== CAPABILITY_PROVIDER_GRANTS.size ||
operationGrants.some((grant) => !CAPABILITY_PROVIDER_GRANTS.has(grant))
) {
throw new RuntimeHostAccessInputError(
'A capability provider credential may grant only Client Capability publication operations',
);
}
}
export async function issueAccessCredential(
authority: RuntimeHostAccessAuthority | undefined,
input: AccessCredentialIssueInput,
): Promise<OperationOutcome<'access.credential.issue'>> {
if (!authority) return unavailable('issue');
try {
return { ok: true, result: await authority.issue(input) };
} catch (error) {
if (error instanceof RuntimeHostAccessInputError) {
return {
ok: false,
error: { code: 'invalid_request', message: error.message },
};
}
return accessPersistenceFailure(
error,
'Access credential issuance outcome is unknown',
'Access credential could not be issued',
);
}
}
export async function replaceAccessCredential(
authority: RuntimeHostAccessAuthority | undefined,
input: AccessCredentialReplaceInput,
): Promise<OperationOutcome<'access.credential.replace'>> {
if (!authority) return unavailable('replace');
try {
return { ok: true, result: await authority.replace(input) };
} catch (error) {
if (error instanceof RuntimeHostAccessInputError) {
return {
ok: false,
error: { code: 'invalid_request', message: error.message },
};
}
return accessPersistenceFailure(
error,
'Access credential replacement outcome is unknown',
'Access credential could not be replaced',
);
}
}
export async function prepareAccessCredential(
authority: RuntimeHostAccessAuthority | undefined,
input: AccessCredentialPrepareInput,
): Promise<OperationOutcome<'access.credential.prepare'>> {
if (!authority) return unavailable('prepare');
try {
return { ok: true, result: await authority.prepare(input) };
} catch (error) {
if (error instanceof RuntimeHostAccessInputError) {
return {
ok: false,
error: { code: 'invalid_request', message: error.message },
};
}
return accessPersistenceFailure(
error,
'Access credential pairing preparation outcome is unknown',
'Access credential pairing could not begin',
);
}
}
export async function prepareAccessCredentialRotation(
authority: RuntimeHostAccessAuthority | undefined,
input: AccessCredentialRotationPrepareInput,
): Promise<OperationOutcome<'access.credential.rotation.prepare'>> {
if (!authority) return unavailable('rotation.prepare');
try {
return { ok: true, result: await authority.prepareRotation(input) };
} catch (error) {
if (error instanceof RuntimeHostAccessInputError) {
return {
ok: false,
error: { code: 'invalid_request', message: error.message },
};
}
return accessPersistenceFailure(
error,
'Access credential rotation preparation outcome is unknown',
'Access credential rotation could not begin',
);
}
}
export async function revokeAccessCredential(
authority: RuntimeHostAccessAuthority | undefined,
input: AccessCredentialRevokeInput,
): Promise<OperationOutcome<'access.credential.revoke'>> {
if (!authority) return unavailable('revoke');
try {
return { ok: true, result: await authority.revoke(input) };
} catch (error) {
if (error instanceof RuntimeHostAccessInputError) {
return {
ok: false,
error: { code: 'invalid_request', message: error.message },
};
}
return accessPersistenceFailure(
error,
'Access credential revocation outcome is unknown',
'Access credential could not be revoked',
);
}
}
export async function revokeAccessPrincipal(
authority: RuntimeHostAccessAuthority | undefined,
input: AccessPrincipalRevokeInput,
): Promise<OperationOutcome<'access.principal.revoke'>> {
if (!authority) return unavailable('principal.revoke');
try {
return { ok: true, result: await authority.revokePrincipal(input) };
} catch (error) {
return accessPersistenceFailure(
error,
'Access principal revocation outcome is unknown',
'Access principal could not be revoked',
);
}
}
export async function revokeAccessCredentialRotation(
authority: RuntimeHostAccessAuthority | undefined,
input: AccessCredentialRotationRevokeInput,
): Promise<OperationOutcome<'access.credential.rotation.revoke'>> {
if (!authority) return unavailable('rotation.revoke');
try {
return { ok: true, result: await authority.revokeRotation(input) };
} catch (error) {
if (error instanceof RuntimeHostAccessInputError) {
return {
ok: false,
error: { code: 'invalid_request', message: error.message },
};
}
return accessPersistenceFailure(
error,
'Access credential rotation revocation outcome is unknown',
'Access credential could not be revoked',
);
}
}
export async function finalizeAccessCredential(
authority: RuntimeHostAccessAuthority | undefined,
credentialId: string | undefined,
clientInstanceId: string | undefined,
credentialClientInstanceId: string | undefined,
): Promise<OperationOutcome<'access.credential.finalize'>> {
if (!authority) return unavailable('finalize');
if (!credentialId) {
return {
ok: false,
error: {
code: 'invalid_request',
message: 'A remote access credential is required',
},
};
}
if (!clientInstanceId) {
return {
ok: false,
error: {
code: 'invalid_request',
message: 'A Client identity is required',
},
};
}
try {
return {
ok: true,
result: await authority.finalize(
credentialId,
clientInstanceId,
credentialClientInstanceId === clientInstanceId,
),
};
} catch (error) {
if (error instanceof RuntimeHostAccessInputError) {
return {
ok: false,
error: { code: 'invalid_request', message: error.message },
};
}
return accessPersistenceFailure(
error,
'Access credential pairing finalization outcome is unknown',
'Access credential pairing could not be finalized',
);
}
}
export async function prepareCollaborationInvitation(
authority: RuntimeHostAccessAuthority | undefined,
rootId: string,
input: CollaborationInvitationPrepareInput,
): Promise<OperationOutcome<'collaboration.invitation.prepare'>> {
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 createCollaborationTurnRequest(
authority: RuntimeHostAccessAuthority | undefined,
principalId: string,
input: CollaborationTurnRequestCreateInput,
): Promise<OperationOutcome<'collaboration.turn-request.create'>> {
if (!authority) return collaborationUnavailable('collaboration.turn-request.create');
try {
return {
ok: true,
result: await authority.createTurnAccessRequest(principalId, input),
};
} catch (error) {
if (error instanceof RuntimeHostAccessInputError) {
return {
ok: false,
error: { code: 'invalid_request', message: error.message },
};
}
return accessPersistenceFailure(
error,
'Turn access request outcome is unknown',
'Turn access request could not be created',
);
}
}
export function queryCollaborationTurnRequests(
authority: RuntimeHostAccessAuthority | undefined,
principal: {
readonly principalId: string;
readonly principalKind: RuntimeHostConnectionAuthority['principalKind'] | undefined;
},
input: CollaborationTurnRequestQueryInput,
): OperationOutcome<'collaboration.turn-request.query'> {
return authority && principal.principalKind
? {
ok: true,
result: authority.queryTurnAccessRequests(
{
principalId: principal.principalId,
principalKind: principal.principalKind,
},
input,
),
}
: collaborationUnavailable('collaboration.turn-request.query');
}
export async function acknowledgeCollaborationTurnRequest(
authority: RuntimeHostAccessAuthority | undefined,
principalId: string,
input: CollaborationTurnRequestAcknowledgeInput,
): Promise<OperationOutcome<'collaboration.turn-request.acknowledge'>> {
if (!authority) return collaborationUnavailable('collaboration.turn-request.acknowledge');
try {
return {
ok: true,
result: await authority.acknowledgeTurnAccessRequest(principalId, input),
};
} catch (error) {
return accessPersistenceFailure(
error,
'Turn access acknowledgement outcome is unknown',
'Turn access request could not be acknowledged',
);
}
}
export async function withdrawCollaborationTurnRequest(
authority: RuntimeHostAccessAuthority | undefined,
principalId: string,
input: CollaborationTurnRequestWithdrawInput,
): Promise<OperationOutcome<'collaboration.turn-request.withdraw'>> {
if (!authority) return collaborationUnavailable('collaboration.turn-request.withdraw');
try {
return {
ok: true,
result: await authority.withdrawTurnAccessRequest(principalId, input),
};
} catch (error) {
return accessPersistenceFailure(
error,
'Turn access withdrawal outcome is unknown',
'Turn access request could not be withdrawn',
);
}
}
export async function decideCollaborationTurnRequest(
authority: RuntimeHostAccessAuthority | undefined,
principalId: string,
input: CollaborationTurnRequestDecideInput,
): Promise<OperationOutcome<'collaboration.turn-request.decide'>> {
if (!authority) return collaborationUnavailable('collaboration.turn-request.decide');
try {
return {
ok: true,
result: await authority.decideTurnAccessRequest(principalId, input),
};
} catch (error) {
if (error instanceof RuntimeHostAccessInputError) {
return {
ok: false,
error: { code: 'invalid_request', message: error.message },
};
}
return accessPersistenceFailure(
error,
'Turn access decision outcome is unknown',
'Turn access request could not be decided',
);
}
}
export async function revokeCollaborationGrant(
authority: RuntimeHostAccessAuthority | undefined,
input: CollaborationGrantRevokeInput,
): Promise<OperationOutcome<'collaboration.grant.revoke'>> {
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<OperationOutcome<'collaboration.principal.revoke'>> {
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',
);
}
}
export async function renameCollaborationPrincipal(
authority: RuntimeHostAccessAuthority | undefined,
input: CollaborationPrincipalRenameInput,
): Promise<OperationOutcome<'collaboration.principal.rename'>> {
if (!authority) return collaborationUnavailable('collaboration.principal.rename');
try {
return { ok: true, result: await authority.renameCollaborationPrincipal(input) };
} catch (error) {
return accessPersistenceFailure(
error,
'Guest alias commit outcome is unknown',
'Guest alias could not be saved',
);
}
}
function collaborationUnavailable<
K extends
| 'collaboration.invitation.prepare'
| 'collaboration.grant.revoke'
| 'collaboration.principal.revoke'
| 'collaboration.principal.rename'
| 'collaboration.turn-request.create'
| 'collaboration.turn-request.acknowledge'
| 'collaboration.turn-request.withdraw'
| 'collaboration.turn-request.decide'
| 'collaboration.turn-request.query',
>(operation: K): OperationOutcome<K> {
return {
ok: false,
error: {
code: 'operation_unavailable',
message: 'Runtime Host collaboration authority is unavailable',
},
} as OperationOutcome<K>;
}
function accessPersistenceFailure(error: unknown, unknownMessage: string, failureMessage: string) {
return {
ok: false as const,
error:
error instanceof RuntimeHostAccessCommitOutcomeUnknownError
? { code: 'commit_outcome_unknown' as const, message: unknownMessage }
: { code: 'persistence_failed' as const, message: failureMessage },
};
}
function unavailable(operation: 'issue'): OperationOutcome<'access.credential.issue'>;
function unavailable(operation: 'replace'): OperationOutcome<'access.credential.replace'>;
function unavailable(operation: 'prepare'): OperationOutcome<'access.credential.prepare'>;
function unavailable(operation: 'revoke'): OperationOutcome<'access.credential.revoke'>;
function unavailable(operation: 'principal.revoke'): OperationOutcome<'access.principal.revoke'>;
function unavailable(
operation: 'rotation.prepare',
): OperationOutcome<'access.credential.rotation.prepare'>;
function unavailable(
operation: 'rotation.revoke',
): OperationOutcome<'access.credential.rotation.revoke'>;
function unavailable(operation: 'finalize'): OperationOutcome<'access.credential.finalize'>;
function unavailable(
_operation:
| 'issue'
| 'replace'
| 'prepare'
| 'revoke'
| 'principal.revoke'
| 'rotation.prepare'
| 'rotation.revoke'
| 'finalize',
):
| OperationOutcome<'access.credential.issue'>
| OperationOutcome<'access.credential.replace'>
| OperationOutcome<'access.credential.prepare'>
| OperationOutcome<'access.credential.revoke'>
| OperationOutcome<'access.principal.revoke'>
| OperationOutcome<'access.credential.rotation.prepare'>
| OperationOutcome<'access.credential.rotation.revoke'>
| OperationOutcome<'access.credential.finalize'> {
return {
ok: false,
error: {
code: 'operation_unavailable',
message: 'Runtime Host access credentials are unavailable in this composition',
},
};
}