blob: ec43fa0ca9aef2b12c4b6c913fe6bfb79551799d [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 {
requireEntityId,
requireExactRecord,
requireId,
requireRecord,
requireShapedRecord,
requireUtf8String,
} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
import { decodeTurnStartInput } from './turn.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 displayName?: string;
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;
}
export interface CollaborationPrincipalRenameInput {
readonly principalId: string;
readonly displayName: string;
}
export interface CollaborationPrincipalRenameResult {
readonly renamed: boolean;
}
export type SessionTurnAccessRequestState =
| { readonly kind: 'pending' }
| {
readonly kind: 'rejected';
readonly decidedAt: string;
readonly decidedBy: string;
}
| {
readonly kind: 'approved';
readonly decidedAt: string;
readonly decidedBy: string;
readonly admission: 'pending' | 'started' | 'blocked' | 'failed';
};
export interface SessionTurnStartRequestIntent {
readonly sessionId: string;
readonly turnId: string;
readonly content: { readonly text: string };
}
export interface SessionTurnRegenerateRequestIntent {
readonly sessionId: string;
readonly turnId: string;
readonly sourceTurnId: string;
}
export type SessionTurnRequestIntent =
| SessionTurnStartRequestIntent
| SessionTurnRegenerateRequestIntent;
export interface SessionTurnAccessRequest {
readonly requestId: string;
readonly principalId: string;
readonly grantId: string;
readonly intent: SessionTurnRequestIntent;
readonly createdAt: string;
readonly state: SessionTurnAccessRequestState;
}
export interface CollaborationTurnRequestCreateInput {
readonly intent: SessionTurnRequestIntent;
}
export interface CollaborationTurnRequestQueryInput {
/** Omit to query every request visible to the authenticated principal. */
readonly sessionId?: string;
}
export interface CollaborationTurnRequestQueryResult {
readonly canRequestTurns: boolean;
readonly requests: readonly SessionTurnAccessRequest[];
}
export interface CollaborationTurnRequestDecideInput {
readonly requestId: string;
readonly decision: 'approve' | 'reject';
}
export type CollaborationTurnRequestDecideResult =
| { readonly kind: 'not_found' }
| {
readonly kind: 'decided' | 'already_decided';
readonly request: SessionTurnAccessRequest;
};
export interface CollaborationTurnRequestAcknowledgeInput {
readonly requestId: string;
}
export interface CollaborationTurnRequestAcknowledgeResult {
readonly acknowledged: boolean;
}
export interface CollaborationTurnRequestWithdrawInput {
readonly requestId: string;
}
export interface CollaborationTurnRequestWithdrawResult {
readonly withdrawn: 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.rename': defineOperation<
CollaborationPrincipalRenameInput,
CollaborationPrincipalRenameResult,
(typeof COLLABORATION_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: COLLABORATION_ERRORS,
decodeInput(value) {
const record = requireExactRecord(value, 'Guest alias', ['principalId', 'displayName']);
return {
principalId: decodePrincipalId(record.principalId),
displayName: decodeCollaborationDisplayName(record.displayName),
};
},
decodeOutput(value) {
const record = requireExactRecord(value, 'Guest alias result', ['renamed']);
if (typeof record.renamed !== 'boolean')
throw invalidProtocolFrame('Invalid Guest alias result');
return { renamed: record.renamed };
},
}),
'collaboration.principal.revoke': defineOperation<
CollaborationPrincipalRevokeInput,
CollaborationPrincipalRevokeResult,
(typeof COLLABORATION_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: COLLABORATION_ERRORS,
decodeInput: decodeCollaborationPrincipalRevokeInput,
decodeOutput: decodeCollaborationPrincipalRevokeResult,
}),
'collaboration.turn-request.create': defineOperation<
CollaborationTurnRequestCreateInput,
SessionTurnAccessRequest,
(typeof COLLABORATION_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: COLLABORATION_ERRORS,
decodeInput: decodeCollaborationTurnRequestCreateInput,
decodeOutput: decodeSessionTurnAccessRequest,
}),
'collaboration.turn-request.query': defineOperation<
CollaborationTurnRequestQueryInput,
CollaborationTurnRequestQueryResult,
(typeof COLLABORATION_ERRORS)[number]
>({
mode: 'query',
availability: 'ready',
errors: COLLABORATION_ERRORS,
decodeInput: decodeCollaborationTurnRequestQueryInput,
decodeOutput: decodeCollaborationTurnRequestQueryResult,
}),
'collaboration.turn-request.acknowledge': defineOperation<
CollaborationTurnRequestAcknowledgeInput,
CollaborationTurnRequestAcknowledgeResult,
(typeof COLLABORATION_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: COLLABORATION_ERRORS,
decodeInput: decodeCollaborationTurnRequestAcknowledgeInput,
decodeOutput: decodeCollaborationTurnRequestAcknowledgeResult,
}),
'collaboration.turn-request.withdraw': defineOperation<
CollaborationTurnRequestWithdrawInput,
CollaborationTurnRequestWithdrawResult,
(typeof COLLABORATION_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: COLLABORATION_ERRORS,
decodeInput: decodeCollaborationTurnRequestWithdrawInput,
decodeOutput: decodeCollaborationTurnRequestWithdrawResult,
}),
'collaboration.turn-request.decide': defineOperation<
CollaborationTurnRequestDecideInput,
CollaborationTurnRequestDecideResult,
(typeof COLLABORATION_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: COLLABORATION_ERRORS,
decodeInput: decodeCollaborationTurnRequestDecideInput,
decodeOutput: decodeCollaborationTurnRequestDecideResult,
}),
} 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', 'displayName'],
);
if (record.status !== 'pending' && record.status !== 'active') {
throw invalidProtocolFrame('Invalid Session Guest principal status');
}
return {
principalId: decodePrincipalId(record.principalId),
status: record.status,
...(record.displayName === undefined
? {}
: { displayName: decodeCollaborationDisplayName(record.displayName) }),
createdAt: decodeIsoTimestamp(record.createdAt, 'createdAt'),
...(record.expiresAt === undefined
? {}
: { expiresAt: decodeIsoTimestamp(record.expiresAt, 'expiresAt') }),
};
}
export function decodeCollaborationDisplayName(value: unknown): string {
const name = requireUtf8String(value, 'Guest alias', 256).trim();
if (!name) throw invalidProtocolFrame('Guest alias must not be blank');
return name;
}
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 };
}
function decodeCollaborationTurnRequestCreateInput(
value: unknown,
): CollaborationTurnRequestCreateInput {
const record = requireExactRecord(value, 'collaboration Turn request input', ['intent']);
return { intent: decodeSessionTurnRequestIntent(record.intent) };
}
function decodeCollaborationTurnRequestQueryInput(
value: unknown,
): CollaborationTurnRequestQueryInput {
const record = requireShapedRecord(value, 'collaboration Turn request query', [], ['sessionId']);
return record.sessionId === undefined
? {}
: { sessionId: requireEntityId(record.sessionId, 'sessionId') };
}
function decodeCollaborationTurnRequestQueryResult(
value: unknown,
): CollaborationTurnRequestQueryResult {
const record = requireExactRecord(value, 'collaboration Turn request query result', [
'canRequestTurns',
'requests',
]);
if (!Array.isArray(record.requests)) {
throw invalidProtocolFrame('Invalid collaboration Turn request query result');
}
if (typeof record.canRequestTurns !== 'boolean') {
throw invalidProtocolFrame('Invalid collaboration Turn request authority');
}
return {
canRequestTurns: record.canRequestTurns,
requests: record.requests.map(decodeSessionTurnAccessRequest),
};
}
function decodeCollaborationTurnRequestAcknowledgeInput(
value: unknown,
): CollaborationTurnRequestAcknowledgeInput {
const record = requireExactRecord(value, 'collaboration Turn request acknowledgement input', [
'requestId',
]);
return { requestId: requireId(record.requestId, 'requestId') };
}
function decodeCollaborationTurnRequestAcknowledgeResult(
value: unknown,
): CollaborationTurnRequestAcknowledgeResult {
const record = requireExactRecord(value, 'collaboration Turn request acknowledgement result', [
'acknowledged',
]);
if (typeof record.acknowledged !== 'boolean') {
throw invalidProtocolFrame('Invalid collaboration Turn request acknowledgement result');
}
return { acknowledged: record.acknowledged };
}
function decodeCollaborationTurnRequestWithdrawInput(
value: unknown,
): CollaborationTurnRequestWithdrawInput {
const record = requireExactRecord(value, 'collaboration Turn request withdrawal input', [
'requestId',
]);
return { requestId: requireId(record.requestId, 'requestId') };
}
function decodeCollaborationTurnRequestWithdrawResult(
value: unknown,
): CollaborationTurnRequestWithdrawResult {
const record = requireExactRecord(value, 'collaboration Turn request withdrawal result', [
'withdrawn',
]);
if (typeof record.withdrawn !== 'boolean') {
throw invalidProtocolFrame('Invalid collaboration Turn request withdrawal result');
}
return { withdrawn: record.withdrawn };
}
function decodeCollaborationTurnRequestDecideInput(
value: unknown,
): CollaborationTurnRequestDecideInput {
const record = requireExactRecord(value, 'collaboration Turn request decision', [
'requestId',
'decision',
]);
if (record.decision !== 'approve' && record.decision !== 'reject') {
throw invalidProtocolFrame('Invalid collaboration Turn request decision');
}
return {
requestId: requireId(record.requestId, 'requestId'),
decision: record.decision,
};
}
function decodeCollaborationTurnRequestDecideResult(
value: unknown,
): CollaborationTurnRequestDecideResult {
const candidate = requireRecord(value, 'collaboration Turn request decision result');
const record = requireExactRecord(
value,
'collaboration Turn request decision result',
candidate.kind === 'not_found' ? ['kind'] : ['kind', 'request'],
);
if (
record.kind !== 'decided' &&
record.kind !== 'already_decided' &&
record.kind !== 'not_found'
) {
throw invalidProtocolFrame('Invalid collaboration Turn request decision result');
}
return record.kind === 'not_found'
? { kind: record.kind }
: { kind: record.kind, request: decodeSessionTurnAccessRequest(record.request) };
}
export function decodeSessionTurnAccessRequest(value: unknown): SessionTurnAccessRequest {
const record = requireExactRecord(value, 'Session Turn access request', [
'requestId',
'principalId',
'grantId',
'intent',
'createdAt',
'state',
]);
return {
requestId: requireId(record.requestId, 'requestId'),
principalId: decodePrincipalId(record.principalId),
grantId: requireId(record.grantId, 'grantId'),
intent: decodeSessionTurnRequestIntent(record.intent),
createdAt: decodeIsoTimestamp(record.createdAt, 'createdAt'),
state: decodeSessionTurnAccessRequestState(record.state),
};
}
function decodeSessionTurnRequestIntent(value: unknown): SessionTurnRequestIntent {
const candidate = requireRecord(value, 'Session Turn request intent');
if (!Object.hasOwn(candidate, 'content')) {
const record = requireExactRecord(value, 'Session Turn regenerate request intent', [
'sessionId',
'turnId',
'sourceTurnId',
]);
return {
sessionId: requireEntityId(record.sessionId, 'sessionId'),
turnId: requireEntityId(record.turnId, 'turnId'),
sourceTurnId: requireEntityId(record.sourceTurnId, 'sourceTurnId'),
};
}
const record = requireExactRecord(value, 'Session Turn start request intent', [
'sessionId',
'turnId',
'content',
]);
const content = requireExactRecord(record.content, 'Session Turn request content', ['text']);
const decoded = decodeTurnStartInput({
sessionId: record.sessionId,
turnId: record.turnId,
content: { text: content.text },
});
return {
sessionId: decoded.sessionId,
turnId: decoded.turnId,
content: { text: decoded.content.text },
};
}
function decodeSessionTurnAccessRequestState(value: unknown): SessionTurnAccessRequestState {
const kind = requireShapedRecord(
value,
'Session Turn access request state',
['kind'],
['decidedAt', 'decidedBy', 'admission'],
).kind;
if (kind === 'pending') {
requireExactRecord(value, 'pending Session Turn access request', ['kind']);
return { kind };
}
if (kind === 'rejected') {
const record = requireExactRecord(value, 'rejected Session Turn access request', [
'kind',
'decidedAt',
'decidedBy',
]);
return {
kind,
decidedAt: decodeIsoTimestamp(record.decidedAt, 'decidedAt'),
decidedBy: decodePrincipalId(record.decidedBy),
};
}
if (kind !== 'approved') {
throw invalidProtocolFrame('Invalid Session Turn access request state');
}
const candidate = requireExactRecord(value, 'approved Session Turn access request', [
'kind',
'decidedAt',
'decidedBy',
'admission',
]);
if (
candidate.admission !== 'pending' &&
candidate.admission !== 'started' &&
candidate.admission !== 'blocked' &&
candidate.admission !== 'failed'
) {
throw invalidProtocolFrame('Invalid Session Turn access request admission');
}
return {
kind,
decidedAt: decodeIsoTimestamp(candidate.decidedAt, 'decidedAt'),
decidedBy: decodePrincipalId(candidate.decidedBy),
admission: candidate.admission,
};
}
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;
}