@ours.network/fleet 1.2.0-nightly.45 → 1.2.0-nightly.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/dist/application/role-removal-service.js +2 -0
- package/dist/application/session-control.d.ts +12 -0
- package/dist/application/session-control.js +19 -1
- package/dist/build-info.json +4 -4
- package/dist/cli.js +73 -12
- package/dist/notifications/fleet-producer.d.ts +1 -0
- package/dist/notifications/fleet-producer.js +82 -3
- package/dist/notifications/outbox.d.ts +4 -0
- package/dist/notifications/outbox.js +9 -0
- package/dist/notifications/target-cleanup.d.ts +8 -0
- package/dist/notifications/target-cleanup.js +41 -0
- package/dist/ops.js +2 -0
- package/dist/session/acp.d.ts +2 -0
- package/dist/session/acp.js +10 -0
- package/dist/session/arbiter.d.ts +2 -0
- package/dist/session/arbiter.js +8 -0
- package/dist/session/codex-app-server.d.ts +2 -0
- package/dist/session/codex-app-server.js +10 -0
- package/dist/session/control.d.ts +3 -1
- package/dist/session/control.js +26 -2
- package/dist/session/conversation-types.d.ts +2 -0
- package/dist/session/types.d.ts +3 -0
- package/dist/tunnel-setup-input.d.ts +11 -0
- package/dist/tunnel-setup-input.js +42 -0
- package/dist/web/server.js +83 -2
- package/dist/web/workspace-devices.d.ts +2 -0
- package/dist/web/workspace-devices.js +13 -0
- package/dist/workspace-enrollment.d.ts +30 -2
- package/dist/workspace-enrollment.js +205 -12
- package/dist/workspace-replacement.d.ts +66 -0
- package/dist/workspace-replacement.js +220 -0
- package/docs/live-voice.md +13 -0
- package/package.json +6 -5
|
@@ -227,6 +227,14 @@ export class CodexAppServerSession {
|
|
|
227
227
|
throw error;
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
+
liveVoiceSupported = true;
|
|
231
|
+
async interruptPrompt(generation, promptId) {
|
|
232
|
+
if (generation !== this.sessionGeneration)
|
|
233
|
+
throw new Error('session_changed: Agent session changed.');
|
|
234
|
+
if (this.activeTurn?.promptId !== promptId)
|
|
235
|
+
return { state: 'settled' };
|
|
236
|
+
return this.interrupt('local-console');
|
|
237
|
+
}
|
|
230
238
|
async interrupt(source = 'local-console') {
|
|
231
239
|
const active = this.activeTurn;
|
|
232
240
|
if (!active || !this.threadId)
|
|
@@ -376,6 +384,8 @@ export class CodexAppServerSession {
|
|
|
376
384
|
return existing;
|
|
377
385
|
if (command.expectedSessionGeneration !== undefined && command.expectedSessionGeneration !== this.conversationSnapshot().sessionGeneration)
|
|
378
386
|
throw new Error('session_changed: Agent session changed. Record a new voice message.');
|
|
387
|
+
if (command.requireIdle && (this.queueDepth > 0 || this.conversationSnapshot().readiness !== 'idle'))
|
|
388
|
+
throw new Error('voice_busy: Agent is already working. Wait for the current turn.');
|
|
379
389
|
const queued = await this.queuePrompt(command.text, {
|
|
380
390
|
origin: { kind: 'owner-admin-console', commandId: command.commandId },
|
|
381
391
|
actor: { browserSession: command.actorBrowserSession },
|
|
@@ -11,11 +11,13 @@ export interface ControlRequest {
|
|
|
11
11
|
version: 1 | 2 | 3;
|
|
12
12
|
id: string;
|
|
13
13
|
token: string;
|
|
14
|
-
command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since' | 'owner_channel_manage' | 'loop_status' | 'loop_run_now' | 'loop_disable' | 'loop_enable' | 'reload_config' | 'conversation_page' | 'conversation_follow' | 'submit_prompt_v2' | 'submit_voice_prompt' | 'interrupt_v2' | 'layout_control' | 'agent_contacts' | 'agent_history' | 'respond_permission_v2' | 'fleet_spawn' | 'fleet_audit_begin' | 'fleet_audit_present' | 'fleet_audit_finish';
|
|
14
|
+
command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since' | 'owner_channel_manage' | 'loop_status' | 'loop_run_now' | 'loop_disable' | 'loop_enable' | 'reload_config' | 'conversation_page' | 'conversation_follow' | 'submit_prompt_v2' | 'submit_voice_prompt' | 'interrupt_v2' | 'interrupt_voice_v2' | 'layout_control' | 'agent_contacts' | 'agent_history' | 'respond_permission_v2' | 'fleet_spawn' | 'fleet_audit_begin' | 'fleet_audit_present' | 'fleet_audit_finish';
|
|
15
15
|
layout?: LayoutControlRequest;
|
|
16
16
|
agentHistory?: AgentHistoryQuery;
|
|
17
17
|
text?: string;
|
|
18
18
|
expectedSessionGeneration?: string;
|
|
19
|
+
requireIdle?: boolean;
|
|
20
|
+
promptId?: string;
|
|
19
21
|
permissionId?: string;
|
|
20
22
|
optionId?: string;
|
|
21
23
|
since?: number;
|
package/dist/session/control.js
CHANGED
|
@@ -9,7 +9,7 @@ import { interruptSession, queueSessionPrompt, respondSessionPermission, respond
|
|
|
9
9
|
const MAX_LINE_BYTES = 64 * 1024;
|
|
10
10
|
/** Commands that require protocol version 3. */
|
|
11
11
|
const V3_COMMANDS = new Set([
|
|
12
|
-
'conversation_page', 'conversation_follow', 'submit_prompt_v2', 'submit_voice_prompt', 'interrupt_v2',
|
|
12
|
+
'conversation_page', 'conversation_follow', 'submit_prompt_v2', 'submit_voice_prompt', 'interrupt_v2', 'interrupt_voice_v2',
|
|
13
13
|
'respond_permission_v2',
|
|
14
14
|
]);
|
|
15
15
|
/** The one retained-range projection shared by polling and live-follow admission. */
|
|
@@ -243,6 +243,7 @@ export class RoleControlServer {
|
|
|
243
243
|
...(this.correspondence ? ['agent_correspondence'] : []),
|
|
244
244
|
...(this.session.conversationPage ? ['conversation_v3'] : []),
|
|
245
245
|
...(this.session.submitPromptBrowser ? ['generation_bound_prompts'] : []),
|
|
246
|
+
...(this.session.liveVoiceSupported ? ['idle_bound_prompts', 'targeted_voice_interrupt'] : []),
|
|
246
247
|
...(this.session.capabilities?.steering ? ['steering'] : []),
|
|
247
248
|
...(this.session.capabilities?.permissions ? ['permissions'] : []),
|
|
248
249
|
...(this.session.capabilities?.messagePhases ? ['message_phases'] : []),
|
|
@@ -466,7 +467,7 @@ export class RoleControlServer {
|
|
|
466
467
|
const receipt = await this.session.submitPromptBrowser({
|
|
467
468
|
commandId: request.commandId, text: request.text,
|
|
468
469
|
source: 'owner_admin_console', actorBrowserSession: request.actor,
|
|
469
|
-
expectedSessionGeneration: request.expectedSessionGeneration,
|
|
470
|
+
expectedSessionGeneration: request.expectedSessionGeneration, requireIdle: request.requireIdle === true,
|
|
470
471
|
});
|
|
471
472
|
this.write(socket, { version: 1, id: request.id, ok: true, result: receipt });
|
|
472
473
|
}
|
|
@@ -477,6 +478,29 @@ export class RoleControlServer {
|
|
|
477
478
|
}
|
|
478
479
|
return;
|
|
479
480
|
}
|
|
481
|
+
case 'interrupt_voice_v2': {
|
|
482
|
+
this.requireConversation(request);
|
|
483
|
+
if (!request.commandId?.trim() || !request.expectedSessionGeneration?.trim() || !request.promptId?.trim())
|
|
484
|
+
throw new SessionControlError('rejected', 'commandId, expectedSessionGeneration and promptId are required');
|
|
485
|
+
if (!this.session.interruptPrompt)
|
|
486
|
+
throw new SessionControlError('rejected', 'targeted voice interrupt unavailable');
|
|
487
|
+
const key = 'voice:' + request.commandId;
|
|
488
|
+
const signature = JSON.stringify([request.expectedSessionGeneration, request.promptId]);
|
|
489
|
+
const existing = this.interruptCommands.get(key);
|
|
490
|
+
if (existing) {
|
|
491
|
+
if (existing.signature !== signature)
|
|
492
|
+
throw new SessionControlError('rejected', 'idempotency_conflict: interrupt target changed');
|
|
493
|
+
this.write(socket, { version: 1, id: request.id, ok: true, result: existing.receipt });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const outcome = await this.session.interruptPrompt(request.expectedSessionGeneration, request.promptId);
|
|
497
|
+
const receipt = { accepted: true, commandId: request.commandId, ...outcome };
|
|
498
|
+
this.interruptCommands.set(key, { signature, receipt });
|
|
499
|
+
if (this.interruptCommands.size > 200)
|
|
500
|
+
this.interruptCommands.delete(this.interruptCommands.keys().next().value);
|
|
501
|
+
this.write(socket, { version: 1, id: request.id, ok: true, result: receipt });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
480
504
|
case 'interrupt_v2': {
|
|
481
505
|
this.requireConversation(request);
|
|
482
506
|
if (!request.commandId?.trim())
|
|
@@ -264,6 +264,8 @@ export interface ConversationEventV1 {
|
|
|
264
264
|
}
|
|
265
265
|
export interface SubmitPromptCommand {
|
|
266
266
|
expectedSessionGeneration?: string;
|
|
267
|
+
/** Live speech must not silently queue behind an unrelated turn. */
|
|
268
|
+
requireIdle?: boolean;
|
|
267
269
|
/** Idempotency-Key / clientRequestId. Reuse with a different body is a conflict. */
|
|
268
270
|
commandId: string;
|
|
269
271
|
text: string;
|
package/dist/session/types.d.ts
CHANGED
|
@@ -319,6 +319,9 @@ export interface AgentSession {
|
|
|
319
319
|
* keep compiling. Read it through `interruptOutcome`, never directly.
|
|
320
320
|
*/
|
|
321
321
|
interrupt(source?: TurnCancellationSource): Promise<InterruptResult>;
|
|
322
|
+
/** Atomically bind cancellation to one voice-owned turn. */
|
|
323
|
+
readonly liveVoiceSupported?: boolean;
|
|
324
|
+
interruptPrompt?(generation: string, promptId: string): Promise<InterruptResult>;
|
|
322
325
|
respondPermission(permissionId: string, optionId: string): boolean;
|
|
323
326
|
/** Generation-bound browser decision; stale/settled/invalid all fail closed. */
|
|
324
327
|
respondPermissionV2?(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
|
|
@@ -6,3 +6,14 @@ export declare function readTunnelSetupStdin(input?: AsyncIterable<Buffer | stri
|
|
|
6
6
|
export declare function readTunnelSetupArgument(input: string): string;
|
|
7
7
|
/** Redeem the grant by HTTPS POST; long-lived connector credentials remain off argv and URLs. */
|
|
8
8
|
export declare function redeemTunnelSetup(input: string, request?: typeof fetch): Promise<WorkspacePayload>;
|
|
9
|
+
/** Inspect only public destination metadata; this never consumes a one-use grant. */
|
|
10
|
+
export declare function inspectTunnelSetup(input: string, request?: typeof fetch): Promise<{
|
|
11
|
+
workspaceId: string;
|
|
12
|
+
serverCid: string;
|
|
13
|
+
appOrigin: string;
|
|
14
|
+
} | undefined>;
|
|
15
|
+
export declare function verifyTunnelSetupTarget(target: {
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
serverCid: string;
|
|
18
|
+
appOrigin: string;
|
|
19
|
+
} | undefined, payload: WorkspacePayload): void;
|
|
@@ -80,3 +80,45 @@ export async function redeemTunnelSetup(input, request = fetch) {
|
|
|
80
80
|
throw Error('Tunnel setup response origin mismatch');
|
|
81
81
|
return payload;
|
|
82
82
|
}
|
|
83
|
+
/** Inspect only public destination metadata; this never consumes a one-use grant. */
|
|
84
|
+
export async function inspectTunnelSetup(input, request = fetch) {
|
|
85
|
+
let version;
|
|
86
|
+
try {
|
|
87
|
+
version = JSON.parse(Buffer.from(input.trim(), 'base64url').toString()).version;
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
if (version === 1) {
|
|
91
|
+
const p = decodeWorkspacePayload(input);
|
|
92
|
+
return { workspaceId: p.challenge.workspaceId, serverCid: p.serverCid, appOrigin: p.appOrigin };
|
|
93
|
+
}
|
|
94
|
+
const grant = decodeTunnelSetupGrant(input);
|
|
95
|
+
let response;
|
|
96
|
+
try {
|
|
97
|
+
response = await request(grant.appOrigin + '/account-api/workspace-install-inspect', { method: 'POST', redirect: 'error', credentials: 'omit', signal: AbortSignal.timeout(15000), headers: { Origin: grant.appOrigin, 'Content-Type': 'application/json' }, body: JSON.stringify({ code: grant.code }) });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw Error('Could not inspect the account setup command; existing setup and grant preserved');
|
|
101
|
+
}
|
|
102
|
+
if (response.status === 404 || response.status === 405) {
|
|
103
|
+
await response.body?.cancel();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
await response.body?.cancel();
|
|
108
|
+
throw Error('Setup command inspection failed; existing setup and grant preserved');
|
|
109
|
+
}
|
|
110
|
+
let target;
|
|
111
|
+
try {
|
|
112
|
+
target = await response.json();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw Error('Invalid setup command inspection response');
|
|
116
|
+
}
|
|
117
|
+
if (!target || Object.keys(target).sort().join(',') !== 'serverCid,workspaceId' || typeof target.workspaceId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(target.workspaceId) || typeof target.serverCid !== 'string' || !/^[a-f0-9]{64}$/i.test(target.serverCid))
|
|
118
|
+
throw Error('Invalid setup command inspection response');
|
|
119
|
+
return { ...target, appOrigin: grant.appOrigin };
|
|
120
|
+
}
|
|
121
|
+
export function verifyTunnelSetupTarget(target, payload) {
|
|
122
|
+
if (target && (target.workspaceId !== payload.challenge.workspaceId || target.serverCid.toUpperCase() !== payload.serverCid.toUpperCase() || target.appOrigin !== payload.appOrigin))
|
|
123
|
+
throw Error('Redeemed setup command differs from inspected destination; existing setup preserved');
|
|
124
|
+
}
|
package/dist/web/server.js
CHANGED
|
@@ -691,6 +691,11 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
691
691
|
const detail = await services.query.detail(request.params.id);
|
|
692
692
|
return { ...detail, chatIdle: detail.role.lifetime === 'temporary' ? readChatIdle(agentDir(request.params.id, true)) ?? null : null };
|
|
693
693
|
});
|
|
694
|
+
app.get('/api/v1/roles/:id/voice-capabilities', async (request) => {
|
|
695
|
+
auth.authenticate(request);
|
|
696
|
+
const descriptor = await (await services.session(request.params.id)).describe();
|
|
697
|
+
return { supported: ['generation_bound_prompts', 'idle_bound_prompts', 'targeted_voice_interrupt'].every(f => descriptor.features.includes(f)) };
|
|
698
|
+
});
|
|
694
699
|
app.get('/api/v1/roles/:id/removal-preview', async (request) => {
|
|
695
700
|
auth.authenticate(request);
|
|
696
701
|
if (!ROLE_NAME_RE.test(request.params.id))
|
|
@@ -781,6 +786,8 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
781
786
|
const body = request.body;
|
|
782
787
|
if (body?.expectedSessionGeneration !== undefined && (typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim()))
|
|
783
788
|
throw new FleetError('invalid_request', 'expectedSessionGeneration must be a nonempty string');
|
|
789
|
+
if (body?.requireIdle !== undefined && typeof body.requireIdle !== 'boolean')
|
|
790
|
+
throw new FleetError('invalid_request', 'requireIdle must be boolean');
|
|
784
791
|
let text = String(body?.text ?? '');
|
|
785
792
|
if (body?.attachments !== undefined) {
|
|
786
793
|
if (typeof body.commandId !== 'string' || !body.commandId.trim())
|
|
@@ -807,7 +814,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
807
814
|
const admittedCommandId = commandId ?? randomBytes(16).toString('hex');
|
|
808
815
|
const receipt = await control.submitPromptV2({
|
|
809
816
|
commandId: admittedCommandId, text, source: 'owner_admin_console',
|
|
810
|
-
expectedSessionGeneration: body.expectedSessionGeneration,
|
|
817
|
+
expectedSessionGeneration: body.expectedSessionGeneration, requireIdle: body.requireIdle === true,
|
|
811
818
|
actorBrowserSession: createHmac('sha256', digestKey).update(session.id).digest('hex').slice(0, 24),
|
|
812
819
|
});
|
|
813
820
|
await audit.record({
|
|
@@ -819,7 +826,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
819
826
|
reply.code(202);
|
|
820
827
|
return receipt;
|
|
821
828
|
}
|
|
822
|
-
if (body.expectedSessionGeneration !== undefined)
|
|
829
|
+
if (body.expectedSessionGeneration !== undefined || body.requireIdle)
|
|
823
830
|
throw new FleetError('capability_unavailable', 'session-bound voice input is unavailable for this role');
|
|
824
831
|
const receipt = await control.sendText(text);
|
|
825
832
|
await audit.record({
|
|
@@ -836,6 +843,16 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
836
843
|
const commandId = typeof body?.commandId === 'string' && body.commandId.trim()
|
|
837
844
|
? body.commandId : undefined;
|
|
838
845
|
const control = await services.session(request.params.id);
|
|
846
|
+
if (body?.expectedSessionGeneration !== undefined || body?.promptId !== undefined) {
|
|
847
|
+
if (!commandId || typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim() || typeof body.promptId !== 'string' || !body.promptId.trim())
|
|
848
|
+
throw new FleetError('invalid_request', 'Targeted interrupt requires commandId, expectedSessionGeneration and promptId');
|
|
849
|
+
if (!control.interruptPromptV2)
|
|
850
|
+
throw new FleetError('capability_unavailable', 'Targeted voice interrupt is unavailable');
|
|
851
|
+
const receipt = await control.interruptPromptV2({ commandId, expectedSessionGeneration: body.expectedSessionGeneration, promptId: body.promptId });
|
|
852
|
+
await audit.record({ requestId: request.id, browser: session.id, roleId: request.params.id, action: 'session.interrupt_voice', result: 'accepted' });
|
|
853
|
+
reply.code(202);
|
|
854
|
+
return receipt;
|
|
855
|
+
}
|
|
839
856
|
if (commandId && control.interruptV2) {
|
|
840
857
|
const receipt = await control.interruptV2(commandId);
|
|
841
858
|
await audit.record({
|
|
@@ -920,6 +937,70 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
920
937
|
throw new FleetError('role_not_found', 'creation action not found');
|
|
921
938
|
return action;
|
|
922
939
|
});
|
|
940
|
+
// A fetch stream works through the same authenticated workspace transport as
|
|
941
|
+
// ordinary requests, and owns one ACP controller until its browser disconnects.
|
|
942
|
+
app.post('/api/v1/roles/:id/live-presence', async (request, reply) => {
|
|
943
|
+
const browser = auth.authenticate(request, true);
|
|
944
|
+
const generation = request.body?.expectedSessionGeneration;
|
|
945
|
+
if (typeof generation !== 'string' || !generation.trim())
|
|
946
|
+
throw new FleetError('invalid_request', 'expectedSessionGeneration is required');
|
|
947
|
+
const control = await services.session(request.params.id);
|
|
948
|
+
if (!control.followConversation || !control.conversationPage)
|
|
949
|
+
throw new FleetError('capability_unavailable', 'Live voice requires a conversation controller');
|
|
950
|
+
const page = await control.conversationPage({ limit: 1 });
|
|
951
|
+
if (page.snapshot.sessionGeneration !== generation)
|
|
952
|
+
throw new FleetError('stale_state', 'Agent session changed. Start live voice again.');
|
|
953
|
+
// Session/page discovery can await I/O; recheck revocation before attaching.
|
|
954
|
+
auth.authenticate(request, true);
|
|
955
|
+
if (reply.raw.destroyed)
|
|
956
|
+
return;
|
|
957
|
+
reply.hijack();
|
|
958
|
+
for (const [name, value] of Object.entries(reply.getHeaders()))
|
|
959
|
+
if (value !== undefined)
|
|
960
|
+
reply.raw.setHeader(name, Array.isArray(value) ? value : String(value));
|
|
961
|
+
reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' });
|
|
962
|
+
let ended = false;
|
|
963
|
+
let follow;
|
|
964
|
+
let heartbeat;
|
|
965
|
+
let unbind;
|
|
966
|
+
const close = () => { if (ended)
|
|
967
|
+
return; ended = true; unbind?.(); if (heartbeat)
|
|
968
|
+
clearInterval(heartbeat); follow?.close(); reply.raw.end(); };
|
|
969
|
+
const write = (value) => { if (!ended && !reply.raw.write(JSON.stringify(value) + '\n'))
|
|
970
|
+
close(); };
|
|
971
|
+
reply.raw.once('close', close);
|
|
972
|
+
unbind = auth.bindTransport(browser.id, close);
|
|
973
|
+
try {
|
|
974
|
+
follow = await control.followConversation({
|
|
975
|
+
after: page.nextCursor,
|
|
976
|
+
onPage: current => { if (current.snapshot.sessionGeneration !== generation) {
|
|
977
|
+
write({ error: 'Agent session changed. Start live voice again.' });
|
|
978
|
+
close();
|
|
979
|
+
}
|
|
980
|
+
else
|
|
981
|
+
write({ ready: true }); },
|
|
982
|
+
onEvent: event => { if (event.sessionGeneration !== generation) {
|
|
983
|
+
write({ error: 'Agent session changed. Start live voice again.' });
|
|
984
|
+
close();
|
|
985
|
+
} },
|
|
986
|
+
onClose: () => close(),
|
|
987
|
+
});
|
|
988
|
+
if (ended)
|
|
989
|
+
follow.close();
|
|
990
|
+
else
|
|
991
|
+
heartbeat = setInterval(() => { try {
|
|
992
|
+
auth.authenticate(request);
|
|
993
|
+
write({ heartbeat: true });
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
close();
|
|
997
|
+
} }, 10_000);
|
|
998
|
+
}
|
|
999
|
+
catch {
|
|
1000
|
+
write({ error: 'Agent controller connection failed.' });
|
|
1001
|
+
close();
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
923
1004
|
app.post('/api/v1/ws-tickets', async (request) => {
|
|
924
1005
|
const body = request.body;
|
|
925
1006
|
if (!body?.purpose || !['events', 'conversation'].includes(body.purpose))
|
|
@@ -25,5 +25,7 @@ export declare class WorkspaceDeviceStore {
|
|
|
25
25
|
valid(id: string): boolean;
|
|
26
26
|
list(): WorkspaceDevice[];
|
|
27
27
|
revoke(id: string): void;
|
|
28
|
+
/** Retire account links without changing the installation's stable host ID or other data. */
|
|
29
|
+
revokeAll(): void;
|
|
28
30
|
close(): void;
|
|
29
31
|
}
|
|
@@ -80,5 +80,18 @@ export class WorkspaceDeviceStore {
|
|
|
80
80
|
valid(id) { return Boolean(this.db.prepare('SELECT id FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(id, this.now())); }
|
|
81
81
|
list() { return this.db.prepare('SELECT id,label,createdAt,lastUsedAt,expiresAt,revokedAt FROM devices ORDER BY createdAt DESC LIMIT 256').all().map(row => ({ id: String(row.id), label: String(row.label), createdAt: Number(row.createdAt), lastUsedAt: Number(row.lastUsedAt), expiresAt: Number(row.expiresAt), revokedAt: row.revokedAt === null ? null : Number(row.revokedAt) })); }
|
|
82
82
|
revoke(id) { this.db.prepare('UPDATE devices SET revokedAt=? WHERE id=? AND revokedAt IS NULL').run(this.now(), id); this.db.prepare('DELETE FROM enrollments WHERE issuer=?').run(id); }
|
|
83
|
+
/** Retire account links without changing the installation's stable host ID or other data. */
|
|
84
|
+
revokeAll() {
|
|
85
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
86
|
+
try {
|
|
87
|
+
this.db.prepare('UPDATE devices SET revokedAt=? WHERE revokedAt IS NULL').run(this.now());
|
|
88
|
+
this.db.exec('DELETE FROM enrollments');
|
|
89
|
+
this.db.exec('COMMIT');
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
this.db.exec('ROLLBACK');
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
83
96
|
close() { this.db.close(); }
|
|
84
97
|
}
|
|
@@ -6,7 +6,7 @@ export interface WorkspacePayload {
|
|
|
6
6
|
rootName: string;
|
|
7
7
|
name: string;
|
|
8
8
|
surname: string;
|
|
9
|
-
connectorToken
|
|
9
|
+
connectorToken?: string;
|
|
10
10
|
invitation: string;
|
|
11
11
|
serverCid: string;
|
|
12
12
|
challenge: {
|
|
@@ -17,7 +17,11 @@ export interface WorkspacePayload {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
export declare function readWorkspacePayload(file: string): WorkspacePayload;
|
|
20
|
-
export declare function decodeWorkspacePayload(input: string
|
|
20
|
+
export declare function decodeWorkspacePayload(input: string, options?: {
|
|
21
|
+
allowExpired?: boolean;
|
|
22
|
+
}): WorkspacePayload;
|
|
23
|
+
/** Credential-free renewal is allowed only on the host already holding this private connector. */
|
|
24
|
+
export declare function checkWorkspaceConnector(payload: WorkspacePayload): void;
|
|
21
25
|
type DaemonClient = Pick<OursClient, 'listIdentities' | 'chooseIdentity' | 'addContact' | 'listContacts' | 'sendCommand' | 'removeContact' | 'releaseLease' | 'close'>;
|
|
22
26
|
/** How tunnel setup reaches this host's daemon; replaced only by tests. */
|
|
23
27
|
export type AttachDaemonClient = (options: AttachOursClientOptions) => Promise<DaemonClient>;
|
|
@@ -29,6 +33,7 @@ export declare function checkWorkspaceConfiguration(configuration?: string, atta
|
|
|
29
33
|
export declare function enrollWorkspace(payload: WorkspacePayload, configuration?: string, options?: {
|
|
30
34
|
migrateAppOrigin?: boolean;
|
|
31
35
|
preserveProfile?: boolean;
|
|
36
|
+
replacingWorkspace?: string;
|
|
32
37
|
attach?: AttachDaemonClient;
|
|
33
38
|
contactWaitMs?: number;
|
|
34
39
|
afterInvitation?: () => void;
|
|
@@ -51,4 +56,27 @@ export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, '
|
|
|
51
56
|
* so the setup can be finished later. A removal that fails is reported and does not undo the setup.
|
|
52
57
|
*/
|
|
53
58
|
export declare function confirmWorkspaceTarget(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number, cleanupFailed?: (error: unknown) => void): Promise<void>;
|
|
59
|
+
/** Retire exactly the original registration using the same signed Human root that bound it. */
|
|
60
|
+
export declare function unregisterWorkspace(previous: {
|
|
61
|
+
workspaceId: string;
|
|
62
|
+
hostWorkspaceId: string;
|
|
63
|
+
appOrigin: string;
|
|
64
|
+
serverCid: string;
|
|
65
|
+
proofRootCid?: string;
|
|
66
|
+
}, payload: WorkspacePayload, operationNonce: string, attach?: AttachDaemonClient, options?: {
|
|
67
|
+
request?: typeof fetch;
|
|
68
|
+
waitMs?: number;
|
|
69
|
+
checkOnly?: boolean;
|
|
70
|
+
beforeSend?: () => void;
|
|
71
|
+
operationExpiresAt?: number;
|
|
72
|
+
onReceipt?: (status: {
|
|
73
|
+
deleted?: boolean;
|
|
74
|
+
retired?: boolean;
|
|
75
|
+
absent?: boolean;
|
|
76
|
+
rejected?: boolean;
|
|
77
|
+
expired?: boolean;
|
|
78
|
+
deadlineEnforced?: boolean;
|
|
79
|
+
oldBindingPresent?: boolean;
|
|
80
|
+
}) => void;
|
|
81
|
+
}): Promise<string>;
|
|
54
82
|
export {};
|