@ours.network/fleet 1.2.0-nightly.24 → 1.2.0-nightly.26

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 CHANGED
@@ -1820,3 +1820,8 @@ See [predefined Brain models and runtime requirements](presets/README.md) for th
1820
1820
 
1821
1821
  Room layouts describe room composition with shared or fresh participant sessions and independent room closure.
1822
1822
  See [Room layouts](docs/room-layouts.md) for YAML examples and CLI commands.
1823
+
1824
+
1825
+ ### Dedicated notification service
1826
+
1827
+ Configure the web backend with `OURS_NOTIFICATIONS_ORIGIN` and a Fleet-scoped `OURS_NOTIFICATIONS_PRODUCER_TOKEN`. It reads each local supervisor ledger incrementally with persistent checkpoints under web state, correlating owner browser/channel prompts with terminal completion. Monitor/startup turns do not notify. First installation skips existing history; no agent restart or conversation controller is needed. Persist web state across deployment/restarts. Reads are bounded to 256 KiB per role per tick, and oversized model/media records are skipped across chunks. Expose `/notifications` through the generic authenticated prefix gateway with a server-side user token and `stripBrowserContext: true`; producer tokens stay server-side. All browser sessions of this single-owner console map to one stable notification account. See [ours-notifications](https://github.com/adapt-toolkit/ours-notifications) for configuration/API/release details.
@@ -0,0 +1,2 @@
1
+ export declare const accountOrigins: readonly ["https://app.ours.network", "https://app.ours-tunnel.com"];
2
+ export declare function validateAccountOrigin(value: unknown): string;
@@ -0,0 +1,6 @@
1
+ export const accountOrigins = ['https://app.ours.network', 'https://app.ours-tunnel.com'];
2
+ export function validateAccountOrigin(value) {
3
+ if (typeof value !== 'string' || !accountOrigins.some(origin => origin === value))
4
+ throw Error('Unsupported account origin');
5
+ return value;
6
+ }
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.24",
3
- "buildId": "80f2457d96f4",
4
- "commit": "b9009b9a68c0c11536c33e239eae38144a491233",
2
+ "version": "1.2.0-nightly.26",
3
+ "buildId": "7b8a6a05d7bf",
4
+ "commit": "99c715eed295baa8fff6bff9f42f868d1f7246f5",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-30T12:55:21.818Z",
6
+ "builtAt": "2026-10-01T06:37:30.669Z",
7
7
  "capabilities": [
8
8
  "cowork.http-management-v1",
9
9
  "monitor.interrupt.after_tool"
package/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import QRCode from 'qrcode';
3
+ import { readWorkspacePayload, enrollWorkspace } from './workspace-enrollment.js';
2
4
  import { waitForRoleDaemon } from './startup-readiness.js';
3
5
  import { SupervisorOursTools } from './application/supervisor-ours-tools.js';
4
6
  import { runTempSupervisor, TEMP_RECYCLE_EXIT } from './temp-supervisor-recovery.js';
@@ -39,7 +41,7 @@ import { controlRequest, controlSocketPath, followControl, livenessNote, } from
39
41
  import { SessionControlError } from './session/types.js';
40
42
  import { readScheduledLoops, storedLoopHealth } from './loops/state.js';
41
43
  import { startWebConsole } from './web/runtime.js';
42
- import { requestWebControl } from './web/control.js';
44
+ import { requestWebControl, webControlPath } from './web/control.js';
43
45
  import { WebServiceManager } from './web/service.js';
44
46
  import { WebAccessStore, passwordAccess, validatePublicOrigin } from './web/access.js';
45
47
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from './fleet-proxy.js';
@@ -1527,6 +1529,46 @@ webCommand.command('open').description('securely open or re-pair a browser with
1527
1529
  die(e);
1528
1530
  }
1529
1531
  });
1532
+ cOpt(program.command('workspace-enroll').description('enroll this host using a private account setup file'))
1533
+ .requiredOption('--file <path>', 'owned private payload file')
1534
+ .option('--migrate-app-origin', 'explicitly migrate the same workspace to its new account origin after verified signed proof')
1535
+ .action(async (opts) => {
1536
+ try {
1537
+ const result = await enrollWorkspace(readWorkspacePayload(opts.file), opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin });
1538
+ if (!existsSync(new WebAccessStore().path))
1539
+ configureWebAccess({ pairing: true });
1540
+ const manager = new WebServiceManager();
1541
+ await manager.install(binPath, 49271, opts.configuration, { bind: '127.0.0.1', publicOrigin: result.origin });
1542
+ await manager.restart();
1543
+ const controlDeadline = Date.now() + 20000;
1544
+ while (!existsSync(webControlPath())) {
1545
+ if (Date.now() >= controlDeadline)
1546
+ throw Error('Workspace console is still starting; run ours-fleet link-device when ready');
1547
+ await new Promise(resolve => setTimeout(resolve, 200));
1548
+ }
1549
+ const link = await requestWebControl('link-device');
1550
+ const code = Buffer.from(JSON.stringify(link)).toString('base64url');
1551
+ process.stdout.write('Root proof submitted; account setup is ready only after tunnel health and binding verification.\nPrivate single-use device code:\n');
1552
+ process.stdout.write(await QRCode.toString(code, { type: 'terminal', small: true }));
1553
+ process.stdout.write('\n' + code + '\n');
1554
+ }
1555
+ catch (error) {
1556
+ die(error);
1557
+ }
1558
+ });
1559
+ program.command('link-device').description('create a single-use workspace device connection code (expires in five minutes)')
1560
+ .action(async () => {
1561
+ try {
1562
+ const link = await requestWebControl('link-device');
1563
+ process.stdout.write('Private connection code; share only with the intended device.\n');
1564
+ const code = Buffer.from(JSON.stringify(link)).toString('base64url');
1565
+ process.stdout.write(await QRCode.toString(code, { type: 'terminal', small: true }));
1566
+ process.stdout.write('\n' + code + '\n');
1567
+ }
1568
+ catch (e) {
1569
+ die(e);
1570
+ }
1571
+ });
1530
1572
  webCommand.command('revoke-all').description('revoke all trusted browsers and active web sessions')
1531
1573
  .action(async () => {
1532
1574
  try {
@@ -0,0 +1,18 @@
1
+ import { type ProducerConfig } from './outbox.js';
2
+ /** Read durable supervisor ledgers without restarting agents or attaching a
3
+ * conversation controller. Each tick reads at most 256 KiB per role, from its
4
+ * checkpoint; it never loads a complete transcript or requests model history. */
5
+ export declare class FleetNotificationProducer {
6
+ private readonly roots;
7
+ private readonly stateDir;
8
+ private readonly config;
9
+ private readonly warn;
10
+ private readonly queues;
11
+ private readonly timer;
12
+ private readonly existing;
13
+ constructor(roots: string[], stateDir: string, config: ProducerConfig, warn?: (line: string) => void);
14
+ private names;
15
+ poll(): void;
16
+ drain(): Promise<void>;
17
+ close(): Promise<void>;
18
+ }
@@ -0,0 +1,129 @@
1
+ import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { NotificationOutbox } from './outbox.js';
5
+ /** Read durable supervisor ledgers without restarting agents or attaching a
6
+ * conversation controller. Each tick reads at most 256 KiB per role, from its
7
+ * checkpoint; it never loads a complete transcript or requests model history. */
8
+ export class FleetNotificationProducer {
9
+ roots;
10
+ stateDir;
11
+ config;
12
+ warn;
13
+ queues = new Map();
14
+ timer;
15
+ existing = new Set();
16
+ constructor(roots, stateDir, config, warn = () => { }) {
17
+ this.roots = roots;
18
+ this.stateDir = stateDir;
19
+ this.config = config;
20
+ this.warn = warn;
21
+ for (const root of roots)
22
+ for (const name of this.names(root))
23
+ this.existing.add(join(root, name));
24
+ this.poll();
25
+ this.timer = setInterval(() => this.poll(), 1000);
26
+ this.timer.unref();
27
+ }
28
+ names(root) {
29
+ try {
30
+ return readdirSync(root, { withFileTypes: true }).filter(d => d.isDirectory() && /^[a-zA-Z0-9_-]+$/.test(d.name)).map(d => d.name);
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ }
36
+ poll() {
37
+ for (const root of this.roots)
38
+ for (const roleId of this.names(root)) {
39
+ const roleDir = join(root, roleId), dir = join(roleDir, '.conversation');
40
+ try {
41
+ if (!existsSync(dir) || lstatSync(dir).isSymbolicLink())
42
+ continue;
43
+ const segments = readdirSync(dir).filter(n => /^events-\d{6}\.jsonl$/.test(n) && !lstatSync(join(dir, n)).isSymbolicLink()).sort();
44
+ if (!segments.length)
45
+ continue;
46
+ let outbox = this.queues.get(roleDir);
47
+ if (!outbox) {
48
+ const key = createHash('sha256').update(roleDir).digest('hex');
49
+ outbox = new NotificationOutbox(join(this.stateDir, key + '.json'), this.config, async (value, eventId) => ({ eventId, title: `${roleId}: agent completed`, body: 'Your agent finished responding. Open the chat to view the result.',
50
+ url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1`, ...value }), this.warn);
51
+ if (!outbox.checkpoint) {
52
+ const segment = this.existing.has(roleDir) ? segments.at(-1) : segments[0];
53
+ outbox.saveCheckpoint({ segment, offset: this.existing.has(roleDir) ? statSync(join(dir, segment)).size : 0, prompts: [] });
54
+ }
55
+ this.queues.set(roleDir, outbox);
56
+ }
57
+ const saved = outbox.checkpoint;
58
+ const current = { ...saved, prompts: [...saved.prompts], messages: { ...saved.messages } };
59
+ let index = segments.indexOf(current.segment);
60
+ if (index < 0) {
61
+ this.warn('Notification ledger segment unavailable; checkpoint retained');
62
+ continue;
63
+ }
64
+ const file = join(dir, current.segment), size = statSync(file).size;
65
+ if (size < current.offset) {
66
+ this.warn('Notification ledger truncated; checkpoint retained');
67
+ continue;
68
+ }
69
+ if (size === current.offset && index < segments.length - 1) {
70
+ current.segment = segments[++index];
71
+ current.offset = 0;
72
+ }
73
+ const fd = openSync(join(dir, current.segment), 'r'), buffer = Buffer.alloc(256 * 1024);
74
+ let count;
75
+ try {
76
+ count = readSync(fd, buffer, 0, buffer.length, current.offset);
77
+ }
78
+ finally {
79
+ closeSync(fd);
80
+ }
81
+ let position = 0;
82
+ while (position < count) {
83
+ const end = buffer.indexOf(10, position);
84
+ if (end < 0 || end >= count) {
85
+ // Oversized model/media events are irrelevant to prompt/completion
86
+ // correlation. Skip in bounded chunks; retain partial small lines.
87
+ if (current.discarding || (position === 0 && count === buffer.length)) {
88
+ current.offset += count - position;
89
+ current.discarding = true;
90
+ this.warn('Notification observer skipping oversized ledger record');
91
+ }
92
+ break;
93
+ }
94
+ if (current.discarding) {
95
+ current.offset += end - position + 1;
96
+ position = end + 1;
97
+ current.discarding = false;
98
+ continue;
99
+ }
100
+ const event = JSON.parse(buffer.subarray(position, end).toString('utf8'));
101
+ if (event.schemaVersion !== 1 || event.roleId !== roleId)
102
+ throw new Error('invalid ledger event');
103
+ const prompt = `${event.sessionGeneration}:${event.promptId}`;
104
+ if (event.kind === 'prompt.admitted' && event.promptId && ['browser', 'owner_admin_console', 'owner_channel'].includes(event.source ?? '')) {
105
+ if (!current.prompts.includes(prompt))
106
+ current.prompts.push(prompt);
107
+ }
108
+ if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
109
+ current.messages[prompt] = event.messageId;
110
+ if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
111
+ if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
112
+ break;
113
+ current.prompts = current.prompts.filter(p => p !== prompt);
114
+ delete current.messages[prompt];
115
+ }
116
+ current.offset += end - position + 1;
117
+ position = end + 1;
118
+ }
119
+ if (current.segment !== saved.segment || current.offset !== saved.offset)
120
+ outbox.saveCheckpoint(current);
121
+ }
122
+ catch {
123
+ this.warn('Notification ledger admission pending; checkpoint will replay');
124
+ }
125
+ }
126
+ }
127
+ async drain() { await Promise.all([...this.queues.values()].map(q => q.drain())); }
128
+ async close() { clearInterval(this.timer); await Promise.all([...this.queues.values()].map(q => q.close())); }
129
+ }
@@ -0,0 +1,31 @@
1
+ export interface NotificationInput {
2
+ eventId: string;
3
+ title: string;
4
+ body: string;
5
+ url: string;
6
+ }
7
+ export interface ProducerConfig {
8
+ origin: string;
9
+ token: string;
10
+ }
11
+ export declare function producerConfig(env?: NodeJS.ProcessEnv): ProducerConfig | undefined;
12
+ /** Durable producer queue. Service dedupe makes crash-after-acceptance replay safe. */
13
+ export declare class NotificationOutbox {
14
+ private readonly file;
15
+ private readonly config;
16
+ private readonly project;
17
+ private readonly warn;
18
+ private entries;
19
+ cursor?: number;
20
+ checkpoint?: unknown;
21
+ private running?;
22
+ private timer;
23
+ constructor(file: string, config: ProducerConfig, project: (value: unknown, id: string) => Promise<NotificationInput>, warn?: (message: string) => void);
24
+ private persist;
25
+ advance(cursor: number): void;
26
+ saveCheckpoint(checkpoint: unknown): void;
27
+ enqueue(id: string, value: unknown, cursor?: number | undefined): boolean;
28
+ drain(): Promise<void>;
29
+ private drainOnce;
30
+ close(): Promise<void>;
31
+ }
@@ -0,0 +1,104 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ export function producerConfig(env = process.env) {
4
+ const origin = env.OURS_NOTIFICATIONS_ORIGIN, token = env.OURS_NOTIFICATIONS_PRODUCER_TOKEN;
5
+ if (!origin && !token)
6
+ return;
7
+ if (!origin || !token || token.length < 32)
8
+ throw new Error('notifications producer requires origin and scoped token');
9
+ const url = new URL(origin);
10
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== origin || url.username || url.password)
11
+ throw new Error('invalid notification service origin');
12
+ if (url.protocol === 'http:' && !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname))
13
+ throw new Error('notification credentials require HTTPS or loopback');
14
+ return { origin, token };
15
+ }
16
+ /** Durable producer queue. Service dedupe makes crash-after-acceptance replay safe. */
17
+ export class NotificationOutbox {
18
+ file;
19
+ config;
20
+ project;
21
+ warn;
22
+ entries;
23
+ cursor;
24
+ checkpoint;
25
+ running;
26
+ timer;
27
+ constructor(file, config, project, warn = () => { }) {
28
+ this.file = file;
29
+ this.config = config;
30
+ this.project = project;
31
+ this.warn = warn;
32
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
33
+ const saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { entries: [] };
34
+ this.entries = Array.isArray(saved) ? saved : saved.entries;
35
+ this.cursor = saved.cursor;
36
+ this.checkpoint = saved.checkpoint;
37
+ if (!Array.isArray(this.entries))
38
+ throw new Error('invalid notification outbox');
39
+ this.timer = setInterval(() => { void this.drain(); }, 3000);
40
+ this.timer.unref();
41
+ }
42
+ persist(entries, cursor = this.cursor, checkpoint = this.checkpoint) {
43
+ const tmp = this.file + '.tmp', fd = openSync(tmp, 'w', 0o600);
44
+ try {
45
+ writeFileSync(fd, JSON.stringify({ entries, cursor, checkpoint }));
46
+ fsyncSync(fd);
47
+ }
48
+ finally {
49
+ closeSync(fd);
50
+ }
51
+ renameSync(tmp, this.file);
52
+ const dir = openSync(dirname(this.file), 'r');
53
+ try {
54
+ fsyncSync(dir);
55
+ }
56
+ finally {
57
+ closeSync(dir);
58
+ }
59
+ this.entries = entries;
60
+ this.cursor = cursor;
61
+ this.checkpoint = checkpoint;
62
+ }
63
+ advance(cursor) { this.persist(this.entries, cursor); }
64
+ saveCheckpoint(checkpoint) { this.persist(this.entries, this.cursor, checkpoint); }
65
+ enqueue(id, value, cursor = this.cursor) {
66
+ if (this.entries.some(e => e.id === id)) {
67
+ if (cursor !== this.cursor)
68
+ this.advance(cursor);
69
+ return true;
70
+ }
71
+ if (this.entries.length >= 2000)
72
+ return false;
73
+ this.persist([...this.entries, { id, value }], cursor);
74
+ return true;
75
+ }
76
+ drain() {
77
+ return this.running ??= this.drainOnce().finally(() => { this.running = undefined; });
78
+ }
79
+ async drainOnce() {
80
+ for (const entry of [...this.entries].slice(0, 32)) {
81
+ try {
82
+ const value = entry.payload ?? await this.project(entry.value, entry.id);
83
+ // Freeze the canonical projection before any network attempt. A crash
84
+ // after service acceptance must replay the identical dedupe payload.
85
+ if (!entry.payload)
86
+ this.persist(this.entries.map(e => e.id === entry.id ? { ...e, payload: value } : e));
87
+ const response = await fetch(this.config.origin + '/api/v1/send', {
88
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
89
+ headers: { Authorization: `Bearer ${this.config.token}`, 'Content-Type': 'application/json' },
90
+ body: JSON.stringify(value),
91
+ });
92
+ await response.body?.cancel();
93
+ if (!response.ok)
94
+ throw new Error('notification service rejected event');
95
+ this.persist(this.entries.filter(e => e.id !== entry.id));
96
+ }
97
+ catch {
98
+ this.warn('Notification producer delivery pending; retrying from durable outbox');
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ async close() { clearInterval(this.timer); await this.running; }
104
+ }
@@ -27,7 +27,7 @@ export async function taskLiveReadiness(task, room, cowork, deps = {}) {
27
27
  if (!remote || remote.room_id !== room.room_id || remote.identity_cid !== room.room_identity_cid
28
28
  || remote.state !== 'active')
29
29
  return degraded('room_identity_or_state_mismatch');
30
- if (room.owner_seat_cid && remote.seats.filter(seat => seat.identity_cid === room.owner_seat_cid && seat.seat_state === 'active').length !== 1)
30
+ if (room.owner_seat_cid && remote.seats.filter(seat => seat.identity_cid.toLowerCase() === room.owner_seat_cid.toLowerCase() && seat.seat_state === 'active').length !== 1)
31
31
  return degraded('owner_seat_missing');
32
32
  for (const seat of room.member_seats) {
33
33
  const member = seat.role_name;
@@ -80,6 +80,6 @@ export async function taskLiveReadiness(task, room, cowork, deps = {}) {
80
80
  const memberCids = new Set(room.member_seats.map(seat => seat.identity_cid));
81
81
  const memberRoles = new Set(room.member_seats.map(seat => seat.cowork_role));
82
82
  if (remote.seats.some(seat => seat.seat_state === 'active' && memberRoles.has(seat.role)
83
- && !memberCids.has(seat.identity_cid) && seat.identity_cid !== room.owner_seat_cid))
83
+ && !memberCids.has(seat.identity_cid) && seat.identity_cid.toLowerCase() !== room.owner_seat_cid?.toLowerCase()))
84
84
  return degraded('untracked_member_seats');
85
85
  }
@@ -619,7 +619,7 @@ export async function reconcileExistingTaskMembers(input) {
619
619
  || remote.state !== 'active')
620
620
  throw new Error('Cowork room is not the exact active room');
621
621
  assertCoworkRoomPolicy(remote, storedRoomLaunchPolicy(current.room_policy).anonymous);
622
- if (current.owner_seat_cid && !remote.seats.some(seat => seat.identity_cid === current.owner_seat_cid && seat.seat_state === 'active'))
622
+ if (current.owner_seat_cid && !remote.seats.some(seat => seat.identity_cid.toLowerCase() === current.owner_seat_cid.toLowerCase() && seat.seat_state === 'active'))
623
623
  throw new Error('expected Owner seat is not active');
624
624
  const seats = [];
625
625
  for (const seat of current.member_seats) {
@@ -1,3 +1,4 @@
1
+ import { WorkspaceDeviceStore } from './workspace-devices.js';
1
2
  import type { FastifyRequest } from 'fastify';
2
3
  import type { WebSocket } from 'ws';
3
4
  import { TrustedDeviceStore, type TrustedDeviceIssue } from './device-store.js';
@@ -26,6 +27,8 @@ export declare class WebAuth {
26
27
  private readonly now;
27
28
  private readonly devices;
28
29
  private readonly access;
30
+ private readonly workspaceDevices?;
31
+ readonly appOrigin: string;
29
32
  private _bootstrapSecret;
30
33
  private bootstrapExpiresAt;
31
34
  private bootstrapUsed;
@@ -35,7 +38,7 @@ export declare class WebAuth {
35
38
  private readonly rates;
36
39
  private readonly transports;
37
40
  private readonly sockets;
38
- constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig);
41
+ constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig, workspaceDevices?: WorkspaceDeviceStore | undefined, appOrigin?: string);
39
42
  get bootstrapSecret(): string;
40
43
  get origin(): string;
41
44
  get host(): string;
@@ -66,6 +69,26 @@ export declare class WebAuth {
66
69
  bindTransport(sessionId: string, close: () => void): () => void;
67
70
  clearSessions(): void;
68
71
  revokeAllTrustedDevices(): number;
72
+ workspaceIdentity(): {
73
+ workspaceId: string;
74
+ };
75
+ workspaceEnrollment(issuer?: string | null): {
76
+ enrollment: string;
77
+ workspaceId: string;
78
+ expiresAt: number;
79
+ };
80
+ enrollWorkspace(request: FastifyRequest, input: {
81
+ enrollment: string;
82
+ workspaceId: string;
83
+ label: string;
84
+ }): {
85
+ token: string;
86
+ device: import("./workspace-devices.js").WorkspaceDevice;
87
+ workspaceId: string;
88
+ };
89
+ listWorkspaceDevices(): import("./workspace-devices.js").WorkspaceDevice[];
90
+ revokeWorkspaceDevice(id: string): void;
91
+ private requireWorkspaceDevices;
69
92
  shutdown(): void;
70
93
  private createSession;
71
94
  private removeSession;
package/dist/web/auth.js CHANGED
@@ -14,6 +14,8 @@ export class WebAuth {
14
14
  now;
15
15
  devices;
16
16
  access;
17
+ workspaceDevices;
18
+ appOrigin;
17
19
  _bootstrapSecret = token(32);
18
20
  bootstrapExpiresAt = Date.now() + 5 * 60_000;
19
21
  bootstrapUsed = false;
@@ -23,12 +25,14 @@ export class WebAuth {
23
25
  rates = new Map();
24
26
  transports = new Map();
25
27
  sockets = new Map();
26
- constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }) {
28
+ constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }, workspaceDevices, appOrigin = 'https://app.ours.network') {
27
29
  this._origin = _origin;
28
30
  this._host = _host;
29
31
  this.now = now;
30
32
  this.devices = devices;
31
33
  this.access = access;
34
+ this.workspaceDevices = workspaceDevices;
35
+ this.appOrigin = appOrigin;
32
36
  }
33
37
  get bootstrapSecret() { return this._bootstrapSecret; }
34
38
  get origin() { return this._origin; }
@@ -56,12 +60,17 @@ export class WebAuth {
56
60
  const origins = this.allowedOrigins.size ? this.allowedOrigins : new Set([this.origin]);
57
61
  if (!host || !hosts.has(host))
58
62
  throw new FleetError('forbidden', `This address is not configured for the fleet console. Open ${this.origin} or set --public-origin.`);
59
- if (requireOrigin && (!request.headers.origin || !origins.has(request.headers.origin)))
63
+ if (this.workspaceDevices && String(request.headers.authorization ?? '').startsWith('Bearer ') && request.headers.origin && request.headers.origin !== this.appOrigin && !origins.has(request.headers.origin))
64
+ throw new FleetError('forbidden', 'workspace request Origin does not match configured account origin');
65
+ const workspaceRequest = Boolean(this.workspaceDevices) && request.headers.origin === this.appOrigin
66
+ && (String(request.headers.authorization ?? '').startsWith('Bearer ') || (request.url ?? '').split('?')[0] === '/api/v1/devices/enroll' || request.method === 'OPTIONS');
67
+ if (requireOrigin && !workspaceRequest && (!request.headers.origin || !origins.has(request.headers.origin)))
60
68
  throw new FleetError('forbidden', 'request Origin does not match the configured control-panel origin');
61
69
  const fetchSite = request.headers['sec-fetch-site'];
62
70
  const pageNavigation = allowPageNavigation && !requireOrigin && request.method === 'GET'
63
- && request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document';
64
- if (!pageNavigation && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
71
+ && request.headers['sec-fetch-mode'] === 'navigate'
72
+ && (request.headers['sec-fetch-dest'] === 'document' || (Boolean(this.workspaceDevices) && request.headers['sec-fetch-dest'] === 'iframe' && new URL(request.url || '/', this.origin).searchParams.get('workspace-frame') === '1'));
73
+ if (!workspaceRequest && !pageNavigation && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
65
74
  throw new FleetError('forbidden', 'cross-site request rejected');
66
75
  }
67
76
  exchange(request) {
@@ -103,7 +112,23 @@ export class WebAuth {
103
112
  return { session: this.createSession(device.id), device };
104
113
  }
105
114
  authenticate(request, mutation = false) {
115
+ const authorization = String(request.headers.authorization ?? '');
116
+ if (authorization.startsWith('Bearer ')) {
117
+ this.validateBoundary(request, mutation);
118
+ if (!this.workspaceDevices)
119
+ throw new FleetError('unauthorized', 'workspace bearer credential is invalid');
120
+ const device = this.requireWorkspaceDevices().authenticate(authorization.slice(7));
121
+ const id = `workspace:${device.id}`;
122
+ const session = { id, csrf: '', createdAt: device.createdAt, lastSeenAt: this.now(), absoluteExpiresAt: device.expiresAt };
123
+ this.sessions.set(id, session);
124
+ this.sessionDevices.set(id, id);
125
+ if (mutation)
126
+ this.consumeRate(`mutation:${id}`, 120, 60000);
127
+ return session;
128
+ }
106
129
  this.validateBoundary(request, mutation);
130
+ if (request.headers.origin === this.appOrigin && !this.allowedOrigins.has(this.appOrigin))
131
+ throw new FleetError('unauthorized', 'workspace bearer credential required');
107
132
  const id = parseCookies(request.headers.cookie ?? '').ofs_session;
108
133
  const session = id ? this.sessions.get(id) : undefined;
109
134
  const now = this.now();
@@ -124,7 +149,9 @@ export class WebAuth {
124
149
  logout(request) {
125
150
  const session = this.authenticate(request, true);
126
151
  const deviceId = this.sessionDevices.get(session.id);
127
- if (deviceId && deviceId !== 'unprotected')
152
+ if (session.id.startsWith('workspace:'))
153
+ this.workspaceDevices?.revoke(session.id.slice(10));
154
+ else if (deviceId && deviceId !== 'unprotected')
128
155
  this.devices.revokeId(deviceId);
129
156
  this.removeSession(session.id);
130
157
  }
@@ -146,7 +173,7 @@ export class WebAuth {
146
173
  || ticket.roleId !== roleId)
147
174
  throw new FleetError('unauthorized', 'WebSocket ticket is invalid, expired, or already used');
148
175
  const session = this.sessions.get(ticket.sessionId);
149
- if (!session)
176
+ if (!session || (session.id.startsWith('workspace:') && !this.workspaceDevices?.valid(session.id.slice(10))))
150
177
  throw new FleetError('unauthorized', 'browser session expired');
151
178
  return session;
152
179
  }
@@ -157,7 +184,10 @@ export class WebAuth {
157
184
  this.sockets.set(sessionId, sockets);
158
185
  }
159
186
  sockets.add(socket);
160
- socket.once('close', () => sockets?.delete(socket));
187
+ const expiry = setInterval(() => { const session = this.sessions.get(sessionId); if (!session || session.absoluteExpiresAt <= this.now() || (sessionId.startsWith('workspace:') && !this.workspaceDevices?.valid(sessionId.slice(10))))
188
+ this.removeSession(sessionId); }, 1000);
189
+ expiry.unref();
190
+ socket.once('close', () => { clearInterval(expiry); sockets?.delete(socket); });
161
191
  }
162
192
  /** Raw service streams share the same logout/revocation boundary as native sockets. */
163
193
  bindTransport(sessionId, close) {
@@ -167,7 +197,10 @@ export class WebAuth {
167
197
  this.transports.set(sessionId, transports);
168
198
  }
169
199
  transports.add(close);
170
- return () => { transports.delete(close); if (!transports.size)
200
+ const expiry = setInterval(() => { const session = this.sessions.get(sessionId); if (!session || session.absoluteExpiresAt <= this.now() || (sessionId.startsWith('workspace:') && !this.workspaceDevices?.valid(sessionId.slice(10))))
201
+ this.removeSession(sessionId); }, 1000);
202
+ expiry.unref();
203
+ return () => { clearInterval(expiry); transports.delete(close); if (!transports.size)
171
204
  this.transports.delete(sessionId); };
172
205
  }
173
206
  clearSessions() {
@@ -185,10 +218,29 @@ export class WebAuth {
185
218
  }
186
219
  revokeAllTrustedDevices() {
187
220
  const count = this.devices.revokeAll();
221
+ for (const device of this.workspaceDevices?.list() ?? [])
222
+ this.workspaceDevices?.revoke(device.id);
188
223
  this.clearSessions();
189
224
  return count;
190
225
  }
191
- shutdown() { this.clearSessions(); }
226
+ workspaceIdentity() { return { workspaceId: this.requireWorkspaceDevices().workspaceId }; }
227
+ workspaceEnrollment(issuer = null) { return this.requireWorkspaceDevices().mint(issuer?.startsWith('workspace:') ? issuer.slice(10) : null); }
228
+ enrollWorkspace(request, input) {
229
+ this.validateBoundary(request, true);
230
+ this.consumeRate('workspace-enrollment', 30, 60000);
231
+ return this.requireWorkspaceDevices().enroll(input.enrollment, input.workspaceId, input.label);
232
+ }
233
+ listWorkspaceDevices() { return this.requireWorkspaceDevices().list(); }
234
+ revokeWorkspaceDevice(id) {
235
+ this.requireWorkspaceDevices().revoke(id);
236
+ this.removeSession(`workspace:${id}`);
237
+ }
238
+ requireWorkspaceDevices() {
239
+ if (!this.workspaceDevices)
240
+ throw new FleetError('capability_unavailable', 'workspace device access is not configured');
241
+ return this.workspaceDevices;
242
+ }
243
+ shutdown() { this.clearSessions(); this.workspaceDevices?.close(); }
192
244
  createSession(deviceId) {
193
245
  const now = this.now();
194
246
  const session = {
@@ -1,4 +1,4 @@
1
- export type WebControlCommand = 'open' | 'revoke-all';
1
+ export type WebControlCommand = 'open' | 'revoke-all' | 'link-device';
2
2
  export declare const webControlPath: (dir?: string) => string;
3
3
  export interface WebControlServer {
4
4
  path: string;
@@ -7,8 +7,9 @@ export interface WebControlServer {
7
7
  export declare function startWebControlServer(options: {
8
8
  dir?: string;
9
9
  onOpen(): void | Promise<void>;
10
+ onLinkDevice?(): Record<string, unknown> | Promise<Record<string, unknown>>;
10
11
  onRevokeAll(): void | Promise<void>;
11
12
  now?: () => number;
12
13
  rateLimit?: number;
13
14
  }): Promise<WebControlServer>;
14
- export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<void>;
15
+ export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<Record<string, unknown> | void>;