@ours.network/fleet 1.2.0-nightly.59 → 1.2.0-nightly.60

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
@@ -175,7 +175,9 @@ never enables it or requests root privileges.
175
175
  Use `ours-fleet web revoke-all` to revoke every linked device and active
176
176
  session. Only a domain-separated SHA-256 device-secret hash and bounded
177
177
  timestamps are stored in the owner-private fleet state directory (`0700`
178
- directory, `0600` atomic file). The link and revoke controls use an
178
+ directory, `0600` atomic file). App browser links use 5-minute access
179
+ credentials and rotating refresh credentials; see
180
+ [workspace onboarding](docs/workspace-onboarding.md#browser-link-credentials). The link and revoke controls use an
179
181
  owner-private Unix socket; local processes running as the same OS user are
180
182
  therefore inside the trust boundary.
181
183
 
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.59",
3
- "buildId": "ec27a9a1f17b",
4
- "commit": "92cc074f0a71a287f27f89787c4eefdbac9207e8",
2
+ "version": "1.2.0-nightly.60",
3
+ "buildId": "79a456ff560c",
4
+ "commit": "869d23c6d674d33b832c2b8ae97daaea2d5048b3",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-13T11:05:39.720Z",
6
+ "builtAt": "2026-10-13T12:13:09.187Z",
7
7
  "capabilities": [
8
8
  "managed-cli.codex-reasoning-effort-v1",
9
9
  "managed-cli.setup-v1",
@@ -9,6 +9,8 @@ export interface BrowserSession {
9
9
  createdAt: number;
10
10
  lastSeenAt: number;
11
11
  absoluteExpiresAt: number;
12
+ /** A workspace access credential, which cannot create further device links. */
13
+ rotating?: boolean;
12
14
  }
13
15
  export interface AuthResult {
14
16
  session: BrowserSession;
@@ -20,6 +22,8 @@ interface Ticket {
20
22
  purpose: 'events' | 'conversation';
21
23
  roleId?: string;
22
24
  expiresAt: number;
25
+ /** When the credential that minted this ticket stops authorizing; a socket opened with it ends then. */
26
+ authorizedUntil: number;
23
27
  }
24
28
  export declare class WebAuth {
25
29
  private _origin;
@@ -37,6 +41,7 @@ export declare class WebAuth {
37
41
  private readonly tickets;
38
42
  private readonly rates;
39
43
  private readonly transports;
44
+ private readonly renewals;
40
45
  private readonly sockets;
41
46
  constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig, workspaceDevices?: WorkspaceDeviceStore | undefined, appOrigin?: string);
42
47
  get bootstrapSecret(): string;
@@ -66,9 +71,22 @@ export declare class WebAuth {
66
71
  expiresAt: string;
67
72
  };
68
73
  consumeTicket(request: FastifyRequest, value: string, purpose: Ticket['purpose'], roleId?: string): BrowserSession;
69
- bindSocket(sessionId: string, socket: WebSocket): void;
74
+ /**
75
+ * `session` is what authenticate() or consumeTicket() returned for this connection. A workspace
76
+ * device shares one session id across credentials, so the socket ends at that credential's own
77
+ * deadline; revocation still ends everything the device has open.
78
+ */
79
+ bindSocket(session: Pick<BrowserSession, 'id' | 'absoluteExpiresAt'>, socket: WebSocket): void;
70
80
  /** Raw service streams share the same logout/revocation boundary as native sockets. */
71
- bindTransport(sessionId: string, close: () => void): () => void;
81
+ bindTransport(session: Pick<BrowserSession, 'id' | 'absoluteExpiresAt'>, close: () => void, renewal?: string): () => void;
82
+ /**
83
+ * Keep one open transport past the credential that opened it. Only its own reader knows the
84
+ * name it was given, and it must authenticate with a newer credential of the same device while
85
+ * the transport is still open: a copied credential cannot renew after it has expired.
86
+ */
87
+ renewTransport(request: FastifyRequest, renewal: string): {
88
+ authorizedUntil: number;
89
+ };
72
90
  clearSessions(): void;
73
91
  revokeAllTrustedDevices(): number;
74
92
  workspaceIdentity(): {
@@ -83,11 +101,26 @@ export declare class WebAuth {
83
101
  enrollment: string;
84
102
  workspaceId: string;
85
103
  label: string;
86
- }): {
104
+ credential?: unknown;
105
+ }): import("./workspace-devices.js").WorkspaceCredentials | {
87
106
  token: string;
88
107
  device: import("./workspace-devices.js").WorkspaceDevice;
89
108
  workspaceId: string;
90
109
  };
110
+ /**
111
+ * Refresh credentials are exchanged between the account server and this host only. A browser
112
+ * always states where a request comes from, so a request that does is refused: no page can be
113
+ * handed refresh material, whichever origin it runs on.
114
+ */
115
+ private machineOnly;
116
+ refreshWorkspace(request: FastifyRequest, refreshToken: string): import("./workspace-devices.js").WorkspaceCredentials;
117
+ exchangeWorkspace(request: FastifyRequest): import("./workspace-devices.js").WorkspaceCredentials;
118
+ revokeWorkspaceRefresh(request: FastifyRequest, refreshHash: string): boolean;
119
+ /** A link code for another browser, for the issuing link's own current refresh credential, which it rotates. */
120
+ linkWorkspaceDevice(request: FastifyRequest, refreshToken: string): import("./workspace-devices.js").WorkspaceCredentials & {
121
+ enrollment: string;
122
+ enrollmentExpiresAt: number;
123
+ };
91
124
  listWorkspaceDevices(): import("./workspace-devices.js").WorkspaceDevice[];
92
125
  revokeWorkspaceDevice(id: string): void;
93
126
  private requireWorkspaceDevices;
package/dist/web/auth.js CHANGED
@@ -1,4 +1,4 @@
1
- import { WorkspaceDeviceAuthError } from './workspace-devices.js';
1
+ import { WorkspaceDeviceAuthError, WorkspaceRefreshReuseError } from './workspace-devices.js';
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto';
3
3
  import { FleetError } from '../application/errors.js';
4
4
  import { TrustedDeviceStore } from './device-store.js';
@@ -25,6 +25,7 @@ export class WebAuth {
25
25
  tickets = new Map();
26
26
  rates = new Map();
27
27
  transports = new Map();
28
+ renewals = new Map();
28
29
  sockets = new Map();
29
30
  constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }, workspaceDevices, appOrigin = 'https://app.ours.network') {
30
31
  this._origin = _origin;
@@ -121,7 +122,8 @@ export class WebAuth {
121
122
  throw new WorkspaceDeviceAuthError('workspace bearer credential is invalid');
122
123
  const device = this.requireWorkspaceDevices().authenticate(authorization.slice(7));
123
124
  const id = `workspace:${device.id}`;
124
- const session = { id, csrf: '', createdAt: device.createdAt, lastSeenAt: this.now(), absoluteExpiresAt: device.expiresAt };
125
+ // A transport opened with this credential ends when this credential does, whatever replaces it.
126
+ const session = { id, csrf: '', createdAt: device.createdAt, lastSeenAt: this.now(), absoluteExpiresAt: device.authorizedUntil, rotating: device.rotating };
125
127
  this.sessions.set(id, session);
126
128
  this.sessionDevices.set(id, id);
127
129
  if (mutation)
@@ -162,7 +164,8 @@ export class WebAuth {
162
164
  const value = token();
163
165
  const ticket = {
164
166
  value, sessionId: session.id, purpose, roleId,
165
- expiresAt: this.now() + 30_000,
167
+ expiresAt: Math.min(this.now() + 30_000, session.absoluteExpiresAt),
168
+ authorizedUntil: session.absoluteExpiresAt,
166
169
  };
167
170
  this.tickets.set(value, ticket);
168
171
  return { ticket: value, expiresAt: new Date(ticket.expiresAt).toISOString() };
@@ -177,39 +180,80 @@ export class WebAuth {
177
180
  const session = this.sessions.get(ticket.sessionId);
178
181
  if (!session || (session.id.startsWith('workspace:') && !this.workspaceDevices?.valid(session.id.slice(10))))
179
182
  throw new FleetError('unauthorized', 'browser session expired');
180
- return session;
183
+ if (!session.id.startsWith('workspace:'))
184
+ return session;
185
+ // A workspace device's session entry is replaced by each newer credential; the ticket carries
186
+ // the deadline of the credential that minted it, and nothing newer extends it.
187
+ if (ticket.authorizedUntil <= this.now())
188
+ throw new FleetError('unauthorized', 'WebSocket ticket is invalid, expired, or already used');
189
+ return { ...session, absoluteExpiresAt: ticket.authorizedUntil };
181
190
  }
182
- bindSocket(sessionId, socket) {
191
+ /**
192
+ * `session` is what authenticate() or consumeTicket() returned for this connection. A workspace
193
+ * device shares one session id across credentials, so the socket ends at that credential's own
194
+ * deadline; revocation still ends everything the device has open.
195
+ */
196
+ bindSocket(session, socket) {
197
+ const sessionId = session.id, deadline = sessionId.startsWith('workspace:') ? session.absoluteExpiresAt : undefined;
183
198
  let sockets = this.sockets.get(sessionId);
184
199
  if (!sockets) {
185
200
  sockets = new Set();
186
201
  this.sockets.set(sessionId, sockets);
187
202
  }
188
203
  sockets.add(socket);
189
- const expiry = setInterval(() => { const session = this.sessions.get(sessionId); if (!session || session.absoluteExpiresAt <= this.now() || (sessionId.startsWith('workspace:') && !this.workspaceDevices?.valid(sessionId.slice(10))))
190
- this.removeSession(sessionId); }, 1000);
204
+ const expiry = setInterval(() => { const current = this.sessions.get(sessionId); if (!current || (deadline !== undefined ? !this.workspaceDevices?.valid(sessionId.slice(10)) : current.absoluteExpiresAt <= this.now()))
205
+ this.removeSession(sessionId);
206
+ else if (deadline !== undefined && deadline <= this.now()) {
207
+ clearInterval(expiry);
208
+ socket.close(4401, 'authentication expired');
209
+ } }, 1000);
191
210
  expiry.unref();
192
211
  socket.once('close', () => { clearInterval(expiry); sockets?.delete(socket); });
193
212
  }
194
213
  /** Raw service streams share the same logout/revocation boundary as native sockets. */
195
- bindTransport(sessionId, close) {
214
+ bindTransport(session, close, renewal) {
215
+ const sessionId = session.id, workspace = sessionId.startsWith('workspace:');
196
216
  let transports = this.transports.get(sessionId);
197
217
  if (!transports) {
198
218
  transports = new Set();
199
219
  this.transports.set(sessionId, transports);
200
220
  }
201
221
  transports.add(close);
202
- const expiry = setInterval(() => { const session = this.sessions.get(sessionId); if (!session || session.absoluteExpiresAt <= this.now() || (sessionId.startsWith('workspace:') && !this.workspaceDevices?.valid(sessionId.slice(10))))
203
- this.removeSession(sessionId); }, 1000);
222
+ const lease = { sessionId, deadline: session.absoluteExpiresAt };
223
+ if (workspace && renewal)
224
+ this.renewals.set(renewal, lease);
225
+ const expiry = setInterval(() => { const current = this.sessions.get(sessionId); if (!current || (workspace ? !this.workspaceDevices?.valid(sessionId.slice(10)) : current.absoluteExpiresAt <= this.now()))
226
+ this.removeSession(sessionId);
227
+ else if (workspace && lease.deadline <= this.now()) {
228
+ clearInterval(expiry);
229
+ transports.delete(close);
230
+ if (renewal)
231
+ this.renewals.delete(renewal);
232
+ close();
233
+ } }, 1000);
204
234
  expiry.unref();
205
- return () => { clearInterval(expiry); transports.delete(close); if (!transports.size)
235
+ return () => { clearInterval(expiry); transports.delete(close); if (renewal)
236
+ this.renewals.delete(renewal); if (!transports.size)
206
237
  this.transports.delete(sessionId); };
207
238
  }
239
+ /**
240
+ * Keep one open transport past the credential that opened it. Only its own reader knows the
241
+ * name it was given, and it must authenticate with a newer credential of the same device while
242
+ * the transport is still open: a copied credential cannot renew after it has expired.
243
+ */
244
+ renewTransport(request, renewal) {
245
+ const session = this.authenticate(request, true), lease = this.renewals.get(renewal);
246
+ if (!lease || lease.sessionId !== session.id || lease.deadline <= this.now())
247
+ throw new FleetError('resource_not_found', 'transport is not open');
248
+ lease.deadline = Math.max(lease.deadline, session.absoluteExpiresAt);
249
+ return { authorizedUntil: lease.deadline };
250
+ }
208
251
  clearSessions() {
209
252
  for (const transports of this.transports.values())
210
253
  for (const close of transports)
211
254
  close();
212
255
  this.transports.clear();
256
+ this.renewals.clear();
213
257
  for (const sockets of this.sockets.values())
214
258
  for (const socket of sockets)
215
259
  socket.close(4401, 'authentication revoked');
@@ -228,10 +272,69 @@ export class WebAuth {
228
272
  workspaceIdentity() { return { workspaceId: this.requireWorkspaceDevices().workspaceId }; }
229
273
  workspaceEnrollment(issuer = null) { return this.requireWorkspaceDevices().mint(issuer?.startsWith('workspace:') ? issuer.slice(10) : null); }
230
274
  enrollWorkspace(request, input) {
275
+ if (input.credential === 2) {
276
+ this.machineOnly(request);
277
+ this.consumeRate('workspace-enrollment', 30, 60000);
278
+ return this.requireWorkspaceDevices().enroll(input.enrollment, input.workspaceId, input.label, true);
279
+ }
231
280
  this.validateBoundary(request, true);
232
281
  this.consumeRate('workspace-enrollment', 30, 60000);
233
282
  return this.requireWorkspaceDevices().enroll(input.enrollment, input.workspaceId, input.label);
234
283
  }
284
+ /**
285
+ * Refresh credentials are exchanged between the account server and this host only. A browser
286
+ * always states where a request comes from, so a request that does is refused: no page can be
287
+ * handed refresh material, whichever origin it runs on.
288
+ */
289
+ machineOnly(request) {
290
+ this.validateBoundary(request, false);
291
+ if (request.headers.origin !== undefined || Object.keys(request.headers).some(name => name.startsWith('sec-fetch-')) || request.headers.cookie !== undefined)
292
+ throw new FleetError('forbidden', 'this endpoint does not accept browser requests');
293
+ }
294
+ refreshWorkspace(request, refreshToken) {
295
+ this.machineOnly(request);
296
+ this.consumeRate('workspace-refresh', 240, 60000);
297
+ try {
298
+ return this.requireWorkspaceDevices().refresh(refreshToken);
299
+ }
300
+ catch (error) {
301
+ if (error instanceof WorkspaceRefreshReuseError)
302
+ for (const id of error.revoked)
303
+ this.removeSession(`workspace:${id}`);
304
+ throw error;
305
+ }
306
+ }
307
+ exchangeWorkspace(request) {
308
+ this.machineOnly(request);
309
+ this.consumeRate('workspace-enrollment', 30, 60000);
310
+ const authorization = String(request.headers.authorization ?? '');
311
+ const issued = this.requireWorkspaceDevices().exchange(authorization.startsWith('Bearer ') ? authorization.slice(7) : '');
312
+ // Whatever the fixed bearer had open ends with it.
313
+ this.removeSession(`workspace:${issued.device.id}`);
314
+ return issued;
315
+ }
316
+ revokeWorkspaceRefresh(request, refreshHash) {
317
+ this.machineOnly(request);
318
+ this.consumeRate('workspace-revoke', 120, 60000);
319
+ const id = this.requireWorkspaceDevices().revokeByRefreshHash(refreshHash);
320
+ if (id)
321
+ this.removeSession(`workspace:${id}`);
322
+ return Boolean(id);
323
+ }
324
+ /** A link code for another browser, for the issuing link's own current refresh credential, which it rotates. */
325
+ linkWorkspaceDevice(request, refreshToken) {
326
+ this.machineOnly(request);
327
+ this.consumeRate('workspace-link', 10, 60000);
328
+ try {
329
+ return this.requireWorkspaceDevices().link(refreshToken);
330
+ }
331
+ catch (error) {
332
+ if (error instanceof WorkspaceRefreshReuseError)
333
+ for (const id of error.revoked)
334
+ this.removeSession(`workspace:${id}`);
335
+ throw error;
336
+ }
337
+ }
235
338
  listWorkspaceDevices() { return this.requireWorkspaceDevices().list(); }
236
339
  revokeWorkspaceDevice(id) {
237
340
  this.requireWorkspaceDevices().revoke(id);
@@ -257,6 +360,9 @@ export class WebAuth {
257
360
  for (const close of this.transports.get(id) ?? [])
258
361
  close();
259
362
  this.transports.delete(id);
363
+ for (const [renewal, lease] of this.renewals)
364
+ if (lease.sessionId === id)
365
+ this.renewals.delete(renewal);
260
366
  this.sessions.delete(id);
261
367
  this.sessionDevices.delete(id);
262
368
  for (const [ticket, value] of this.tickets)
@@ -106,7 +106,8 @@ export function createPrefixGateway(options) {
106
106
  const track = (authHeaders, session, close) => {
107
107
  if (!session)
108
108
  return () => { };
109
- const unbind = auth.bindTransport(session.id, close);
109
+ // The session authenticated for this request, with its own deadline, not whatever is current later.
110
+ const unbind = auth.bindTransport(session, close);
110
111
  const timer = setInterval(() => { try {
111
112
  auth.authenticate({ headers: authHeaders });
112
113
  }
@@ -134,10 +134,41 @@ export async function buildWebServer(services, boundary, options = {}) {
134
134
  throw new FleetError('invalid_request', 'device enrollment body required');
135
135
  return auth.enrollWorkspace(request, input);
136
136
  });
137
- app.post('/api/v1/devices/link', async (request) => {
137
+ // Refresh credentials travel between the account server and this host; these routes refuse browsers.
138
+ const refreshInput = (request, name) => {
139
+ const value = request.body?.[name];
140
+ if (typeof value !== 'string')
141
+ throw new FleetError('invalid_request', `${name} is required`);
142
+ return value;
143
+ };
144
+ app.post('/api/v1/devices/refresh', async (request, reply) => {
145
+ reply.header('Cache-Control', 'no-store');
146
+ return auth.refreshWorkspace(request, refreshInput(request, 'refreshToken'));
147
+ });
148
+ app.post('/api/v1/devices/exchange', async (request, reply) => {
149
+ reply.header('Cache-Control', 'no-store');
150
+ return auth.exchangeWorkspace(request);
151
+ });
152
+ app.post('/api/v1/devices/revoke', async (request) => ({ revoked: auth.revokeWorkspaceRefresh(request, refreshInput(request, 'refreshHash')) }));
153
+ app.post('/api/v1/devices/link', async (request, reply) => {
154
+ const proof = request.body?.refreshToken;
155
+ if (proof !== undefined) {
156
+ reply.header('Cache-Control', 'no-store');
157
+ const { enrollment, enrollmentExpiresAt, ...credentials } = auth.linkWorkspaceDevice(request, refreshInput(request, 'refreshToken'));
158
+ return { ...credentials, code: { version: 1, origin: auth.origin, enrollment, workspaceId: credentials.workspaceId, expiresAt: enrollmentExpiresAt } };
159
+ }
138
160
  const session = auth.authenticate(request, true);
161
+ // A short-lived access credential must not be able to create a lasting one.
162
+ if (session.rotating)
163
+ throw new FleetError('forbidden', 'linking another device requires this link\'s refresh credential');
139
164
  return { version: 1, origin: auth.origin, ...auth.workspaceEnrollment(session.id) };
140
165
  });
166
+ app.get('/api/v1/devices/current', async (request) => {
167
+ const session = auth.authenticate(request);
168
+ if (!session.id.startsWith('workspace:'))
169
+ throw new FleetError('resource_not_found', 'no workspace device');
170
+ return { deviceId: session.id.slice(10), authorizedUntil: session.absoluteExpiresAt };
171
+ });
141
172
  app.get('/api/v1/devices', async (request) => { auth.authenticate(request); return { devices: auth.listWorkspaceDevices() }; });
142
173
  app.delete('/api/v1/devices/:id', async (request) => { auth.authenticate(request, true); auth.revokeWorkspaceDevice(request.params.id); return { revoked: true }; });
143
174
  app.get('/api/v1/meta', async (request) => {
@@ -1003,7 +1034,9 @@ export async function buildWebServer(services, boundary, options = {}) {
1003
1034
  const write = (value) => { if (!ended && !reply.raw.write(JSON.stringify(value) + '\n'))
1004
1035
  close(); };
1005
1036
  reply.raw.once('close', close);
1006
- unbind = auth.bindTransport(browser.id, close);
1037
+ // A voice session outlives one access credential; its page renews this stream by name.
1038
+ const renewal = browser.rotating ? randomBytes(32).toString('base64url') : undefined;
1039
+ unbind = auth.bindTransport(browser, close, renewal);
1007
1040
  try {
1008
1041
  follow = await control.followConversation({
1009
1042
  after: page.nextCursor,
@@ -1012,7 +1045,7 @@ export async function buildWebServer(services, boundary, options = {}) {
1012
1045
  close();
1013
1046
  }
1014
1047
  else
1015
- write({ ready: true }); },
1048
+ write({ ready: true, ...(renewal ? { renewal } : {}) }); },
1016
1049
  onEvent: event => { if (event.sessionGeneration !== generation) {
1017
1050
  write({ error: 'Agent session changed. Start live voice again.' });
1018
1051
  close();
@@ -1021,9 +1054,11 @@ export async function buildWebServer(services, boundary, options = {}) {
1021
1054
  });
1022
1055
  if (ended)
1023
1056
  follow.close();
1057
+ // A renewable stream is ended by its own deadline and by revocation, not by the first credential's expiry.
1024
1058
  else
1025
1059
  heartbeat = setInterval(() => { try {
1026
- auth.authenticate(request);
1060
+ if (!renewal)
1061
+ auth.authenticate(request);
1027
1062
  write({ heartbeat: true });
1028
1063
  }
1029
1064
  catch {
@@ -1035,6 +1070,7 @@ export async function buildWebServer(services, boundary, options = {}) {
1035
1070
  close();
1036
1071
  }
1037
1072
  });
1073
+ app.post('/api/v1/transports/:id/renew', async (request) => auth.renewTransport(request, request.params.id));
1038
1074
  app.post('/api/v1/ws-tickets', async (request) => {
1039
1075
  const body = request.body;
1040
1076
  if (!body?.purpose || !['events', 'conversation'].includes(body.purpose))
@@ -1069,7 +1105,7 @@ export async function buildWebServer(services, boundary, options = {}) {
1069
1105
  requireSubprotocol(request, 'ours-fleet-events.v1');
1070
1106
  authorizeSocket(socket, request, async (hello) => {
1071
1107
  const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'events');
1072
- auth.bindSocket(session.id, socket);
1108
+ auth.bindSocket(session, socket);
1073
1109
  const detach = events.attach(socket, typeof hello.lastEventId === 'string' ? hello.lastEventId : undefined);
1074
1110
  socket.on('close', detach);
1075
1111
  socket.send(JSON.stringify({ kind: 'ready', at: new Date().toISOString() }));
@@ -1079,7 +1115,7 @@ export async function buildWebServer(services, boundary, options = {}) {
1079
1115
  requireSubprotocol(request, 'ours-fleet-conversation.v1');
1080
1116
  authorizeSocket(socket, request, async (hello) => {
1081
1117
  const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'conversation', request.params.id);
1082
- auth.bindSocket(session.id, socket);
1118
+ auth.bindSocket(session, socket);
1083
1119
  const control = await services.session(request.params.id);
1084
1120
  if (!control.followConversation)
1085
1121
  throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
@@ -3,6 +3,21 @@ import { FleetError } from '../application/errors.js';
3
3
  export declare class WorkspaceDeviceAuthError extends FleetError {
4
4
  constructor(message?: string);
5
5
  }
6
+ /** A spent refresh credential came back after its replacement was in use: the link is revoked, whoever presented it. */
7
+ export declare class WorkspaceRefreshReuseError extends WorkspaceDeviceAuthError {
8
+ readonly revoked: string[];
9
+ constructor(revoked: string[]);
10
+ }
11
+ /**
12
+ * Credential lifetimes of an App browser link. Rotation is not expiry: every refresh replaces the
13
+ * refresh credential, each one lapses when unused for REFRESH_IDLE_TTL, and no rotation moves a
14
+ * link past the FAMILY_TTL fixed when it was created.
15
+ */
16
+ export declare const ACCESS_TTL: number;
17
+ export declare const REFRESH_IDLE_TTL: number;
18
+ export declare const FAMILY_TTL: number;
19
+ /** How long after this store first runs rotating credentials the earlier fixed bearer is still accepted. */
20
+ export declare const LEGACY_BEARER_WINDOW: number;
6
21
  export interface WorkspaceDevice {
7
22
  id: string;
8
23
  label: string;
@@ -11,22 +26,78 @@ export interface WorkspaceDevice {
11
26
  expiresAt: number;
12
27
  revokedAt: number | null;
13
28
  }
29
+ export interface WorkspaceCredentials {
30
+ device: WorkspaceDevice;
31
+ workspaceId: string;
32
+ accessToken: string;
33
+ accessExpiresAt: number;
34
+ refreshToken: string;
35
+ refreshExpiresAt: number;
36
+ familyExpiresAt: number;
37
+ }
38
+ /** An authenticated device and the moment the presented credential stops authorizing anything. */
39
+ export type AuthenticatedWorkspaceDevice = WorkspaceDevice & {
40
+ authorizedUntil: number;
41
+ rotating: boolean;
42
+ };
14
43
  export declare class WorkspaceDeviceStore {
15
44
  private readonly now;
16
45
  private readonly db;
17
46
  readonly workspaceId: string;
47
+ /** After this moment a fixed bearer no longer authenticates and no new one is issued. */
48
+ readonly legacySunsetAt: number;
49
+ private readonly rotationKey;
18
50
  constructor(dir?: string, now?: () => number);
51
+ private transaction;
19
52
  mint(issuer?: string | null): {
20
53
  enrollment: string;
21
54
  workspaceId: string;
22
55
  expiresAt: number;
23
56
  };
24
- enroll(enrollment: string, workspaceId: string, label: string): {
57
+ /** Link a browser with rotating credentials. */
58
+ enroll(enrollment: string, workspaceId: string, label: string, rotating: true): WorkspaceCredentials;
59
+ /** The fixed bearer of earlier App versions; refused once the legacy window has closed. */
60
+ enroll(enrollment: string, workspaceId: string, label: string, rotating?: false): {
25
61
  token: string;
26
62
  device: WorkspaceDevice;
27
63
  workspaceId: string;
28
64
  };
29
- authenticate(token: string): WorkspaceDevice;
65
+ /** Inside a transaction: an access credential beside the given current refresh credential. */
66
+ private grant;
67
+ private live;
68
+ private device;
69
+ /**
70
+ * Replace a refresh credential. It has exactly one successor, derived on this host. Until that
71
+ * successor is seen in use (presented itself, or an access credential issued with it
72
+ * authenticates), the spent credential is answered again with the same successor: a lost
73
+ * answer, a closed tab or a restart converge on one value and extend nothing. Once the successor
74
+ * is in use, the spent credential is a replay and ends the link and the links it issued.
75
+ */
76
+ refresh(refreshToken: string): WorkspaceCredentials;
77
+ /**
78
+ * A link code for another browser, issued by this link. It is a lasting grant, so it takes the
79
+ * link's current, unspent refresh credential and rotates it in the same step, with the successor
80
+ * counted as in use at once: whoever else holds the spent credential is a replay on their next
81
+ * refresh, which also ends the link made from this code. An answer lost here is not retried.
82
+ */
83
+ link(refreshToken: string): WorkspaceCredentials & {
84
+ enrollment: string;
85
+ enrollmentExpiresAt: number;
86
+ };
87
+ /** Inside a transaction. Returns new credentials, or the devices ended by a replay. `strict` allows no repeat and confirms the successor. */
88
+ private rotate;
89
+ /**
90
+ * One-time move of a fixed bearer to rotating credentials. The device keeps its identity, the
91
+ * bearer stops working in the same transaction, and the link ends no later than it would have.
92
+ */
93
+ exchange(token: string): WorkspaceCredentials;
94
+ private legacy;
95
+ /** The device behind an access credential, or behind a fixed bearer while those are still accepted. */
96
+ authenticate(token: string): AuthenticatedWorkspaceDevice;
97
+ /** End the link a refresh credential belongs to, named by its SHA-256: a hash can end a link, never use it. */
98
+ revokeByRefreshHash(refreshHash: string): string | undefined;
99
+ /** Inside a transaction. With cascade, links issued from this one end too. Returns every device ended. */
100
+ private revokeRows;
30
101
  valid(id: string): boolean;
31
102
  list(): WorkspaceDevice[];
32
103
  revoke(id: string): void;
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from 'node:module';
2
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
3
3
  import { mkdirSync, chmodSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { stateRoot } from '../paths.js';
@@ -10,10 +10,33 @@ const hash = (value) => createHash('sha256').update(value).digest('hex');
10
10
  export class WorkspaceDeviceAuthError extends FleetError {
11
11
  constructor(message = 'workspace device is missing, expired, or revoked') { super('unauthorized', message); }
12
12
  }
13
+ /** A spent refresh credential came back after its replacement was in use: the link is revoked, whoever presented it. */
14
+ export class WorkspaceRefreshReuseError extends WorkspaceDeviceAuthError {
15
+ revoked;
16
+ constructor(revoked) {
17
+ super('workspace refresh credential was already replaced; this device link is revoked');
18
+ this.revoked = revoked;
19
+ }
20
+ }
21
+ /**
22
+ * Credential lifetimes of an App browser link. Rotation is not expiry: every refresh replaces the
23
+ * refresh credential, each one lapses when unused for REFRESH_IDLE_TTL, and no rotation moves a
24
+ * link past the FAMILY_TTL fixed when it was created.
25
+ */
26
+ export const ACCESS_TTL = 5 * 60_000;
27
+ export const REFRESH_IDLE_TTL = 7 * 86_400_000;
28
+ export const FAMILY_TTL = 30 * 86_400_000;
29
+ /** How long after this store first runs rotating credentials the earlier fixed bearer is still accepted. */
30
+ export const LEGACY_BEARER_WINDOW = 30 * 86_400_000;
31
+ const ACCESS_PREFIX = 'owa.', REFRESH_PREFIX = 'owr.';
32
+ const refreshShaped = (value) => typeof value === 'string' && value.startsWith(REFRESH_PREFIX) && value.length <= 128;
13
33
  export class WorkspaceDeviceStore {
14
34
  now;
15
35
  db;
16
36
  workspaceId;
37
+ /** After this moment a fixed bearer no longer authenticates and no new one is issued. */
38
+ legacySunsetAt;
39
+ rotationKey;
17
40
  constructor(dir = join(stateRoot(), 'web'), now = Date.now) {
18
41
  this.now = now;
19
42
  const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite');
@@ -26,76 +49,229 @@ export class WorkspaceDeviceStore {
26
49
  CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
27
50
  CREATE TABLE IF NOT EXISTS devices(id TEXT PRIMARY KEY,hash TEXT NOT NULL,label TEXT NOT NULL,createdAt INTEGER NOT NULL,lastUsedAt INTEGER NOT NULL,expiresAt INTEGER NOT NULL,revokedAt INTEGER);
28
51
  CREATE TABLE IF NOT EXISTS enrollments(hash TEXT PRIMARY KEY,expiresAt INTEGER NOT NULL,issuer TEXT);`);
29
- this.db.prepare('INSERT OR IGNORE INTO metadata VALUES(?,?)').run('workspaceId', opaque());
30
- this.workspaceId = String(this.db.prepare('SELECT value FROM metadata WHERE key=?').get('workspaceId').value);
52
+ // A device row is one browser link. A rotating link keeps no bearer hash: its credentials are the rows below.
53
+ const columns = this.db.prepare('PRAGMA table_info(devices)').all().map(column => column.name);
54
+ if (!columns.includes('familyExpiresAt'))
55
+ this.db.exec('ALTER TABLE devices ADD COLUMN familyExpiresAt INTEGER');
56
+ if (!columns.includes('issuer'))
57
+ this.db.exec('ALTER TABLE devices ADD COLUMN issuer TEXT');
58
+ this.db.exec(`CREATE TABLE IF NOT EXISTS refresh_tokens(hash TEXT PRIMARY KEY,deviceId TEXT NOT NULL,issuedAt INTEGER NOT NULL,expiresAt INTEGER NOT NULL,consumedAt INTEGER,confirmedAt INTEGER,parent TEXT);
59
+ CREATE INDEX IF NOT EXISTS refresh_tokens_device ON refresh_tokens(deviceId);
60
+ CREATE UNIQUE INDEX IF NOT EXISTS refresh_tokens_parent ON refresh_tokens(parent) WHERE parent IS NOT NULL;
61
+ CREATE TABLE IF NOT EXISTS access_tokens(hash TEXT PRIMARY KEY,deviceId TEXT NOT NULL,expiresAt INTEGER NOT NULL,refresh TEXT NOT NULL);
62
+ CREATE INDEX IF NOT EXISTS access_tokens_device ON access_tokens(deviceId);`);
63
+ const metadata = (key, initial) => { this.db.prepare('INSERT OR IGNORE INTO metadata VALUES(?,?)').run(key, initial); return String(this.db.prepare('SELECT value FROM metadata WHERE key=?').get(key).value); };
64
+ this.legacySunsetAt = Number(metadata('legacySunsetAt', String(this.now() + LEGACY_BEARER_WINDOW)));
65
+ // Derives each refresh credential's one successor from the credential itself; it never leaves
66
+ // this host and is useless without a current credential. It lives and dies with this
67
+ // database: a lost or replaced file ends every link, which then needs a new link code.
68
+ this.rotationKey = Buffer.from(metadata('rotationKey', opaque()), 'base64url');
69
+ this.workspaceId = metadata('workspaceId', opaque());
31
70
  }
32
- mint(issuer = null) {
33
- const now = this.now(), enrollment = opaque(), expiresAt = now + 5 * 60000;
71
+ transaction(run) {
34
72
  this.db.exec('BEGIN IMMEDIATE');
35
73
  try {
36
- this.db.prepare('DELETE FROM enrollments WHERE expiresAt<=?').run(now);
37
- const count = Number(this.db.prepare('SELECT count(*) AS n FROM enrollments').get().n);
38
- if (count >= 32)
39
- throw new FleetError('rate_limited', 'too many pending device links');
40
- this.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(hash(enrollment), expiresAt, issuer);
74
+ const result = run();
41
75
  this.db.exec('COMMIT');
42
- return { enrollment, workspaceId: this.workspaceId, expiresAt };
76
+ return result;
43
77
  }
44
78
  catch (error) {
45
79
  this.db.exec('ROLLBACK');
46
80
  throw error;
47
81
  }
48
82
  }
49
- enroll(enrollment, workspaceId, label) {
83
+ mint(issuer = null) {
84
+ const now = this.now(), enrollment = opaque(), expiresAt = now + 5 * 60000;
85
+ return this.transaction(() => {
86
+ this.db.prepare('DELETE FROM enrollments WHERE expiresAt<=?').run(now);
87
+ const count = Number(this.db.prepare('SELECT count(*) AS n FROM enrollments').get().n);
88
+ if (count >= 32)
89
+ throw new FleetError('rate_limited', 'too many pending device links');
90
+ this.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(hash(enrollment), expiresAt, issuer);
91
+ return { enrollment, workspaceId: this.workspaceId, expiresAt };
92
+ });
93
+ }
94
+ enroll(enrollment, workspaceId, label, rotating = false) {
50
95
  if (workspaceId !== this.workspaceId || typeof enrollment !== 'string' || !/^[\w-]{43}$/.test(enrollment))
51
96
  throw new FleetError('unauthorized', 'invalid device enrollment');
52
97
  if (typeof label !== 'string' || !label.trim() || label.length > 100 || /[\x00-\x1f\x7f]/.test(label))
53
98
  throw new FleetError('invalid_request', 'device name must contain 1–100 printable characters');
54
99
  const now = this.now(), id = opaque(), token = `${id}.${opaque()}`;
55
- this.db.exec('BEGIN IMMEDIATE');
56
- try {
100
+ if (!rotating && now >= this.legacySunsetAt)
101
+ throw new FleetError('invalid_request', 'this workspace no longer issues fixed device credentials; update the App');
102
+ return this.transaction(() => {
57
103
  const code = this.db.prepare('DELETE FROM enrollments WHERE hash=? AND expiresAt>? RETURNING issuer').get(hash(enrollment), now);
58
104
  if (!code)
59
105
  throw new FleetError('unauthorized', 'device enrollment is expired or already used');
60
- if (code.issuer && !this.db.prepare('SELECT id FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(code.issuer, now))
106
+ if (code.issuer && !this.live(String(code.issuer), now))
61
107
  throw new FleetError('unauthorized', 'issuing device has been revoked');
62
108
  const count = Number(this.db.prepare('SELECT count(*) AS n FROM devices WHERE revokedAt IS NULL AND expiresAt>?').get(now).n);
63
109
  if (count >= 128)
64
110
  throw new FleetError('rate_limited', 'authorized device limit reached');
65
- const device = { id, label: label.trim(), createdAt: now, lastUsedAt: now, expiresAt: now + 90 * 86400000, revokedAt: null };
66
- this.db.prepare('INSERT INTO devices VALUES(?,?,?,?,?,?,NULL)').run(id, hash(token), device.label, now, now, device.expiresAt);
67
- this.db.exec('COMMIT');
68
- return { token, device, workspaceId };
69
- }
70
- catch (error) {
71
- this.db.exec('ROLLBACK');
72
- throw error;
73
- }
111
+ const issuer = code.issuer === null ? null : String(code.issuer);
112
+ if (!rotating) {
113
+ const device = { id, label: label.trim(), createdAt: now, lastUsedAt: now, expiresAt: now + 90 * 86400000, revokedAt: null };
114
+ this.db.prepare('INSERT INTO devices(id,hash,label,createdAt,lastUsedAt,expiresAt,revokedAt,issuer) VALUES(?,?,?,?,?,?,NULL,?)').run(id, hash(token), device.label, now, now, device.expiresAt, issuer);
115
+ return { token, device, workspaceId };
116
+ }
117
+ const familyExpiresAt = now + FAMILY_TTL, refreshToken = REFRESH_PREFIX + opaque(), refreshExpiresAt = Math.min(now + REFRESH_IDLE_TTL, familyExpiresAt);
118
+ const device = { id, label: label.trim(), createdAt: now, lastUsedAt: now, expiresAt: refreshExpiresAt, revokedAt: null };
119
+ this.db.prepare('INSERT INTO devices(id,hash,label,createdAt,lastUsedAt,expiresAt,revokedAt,familyExpiresAt,issuer) VALUES(?,?,?,?,?,?,NULL,?,?)').run(id, '', device.label, now, now, refreshExpiresAt, familyExpiresAt, issuer);
120
+ this.db.prepare('INSERT INTO refresh_tokens VALUES(?,?,?,?,NULL,NULL,NULL)').run(hash(refreshToken), id, now, refreshExpiresAt);
121
+ return this.grant(device, familyExpiresAt, refreshToken, refreshExpiresAt, now);
122
+ });
74
123
  }
75
- authenticate(token) {
76
- const id = typeof token === 'string' ? token.split('.')[0] : '', now = this.now();
124
+ /** Inside a transaction: an access credential beside the given current refresh credential. */
125
+ grant(device, familyExpiresAt, refreshToken, refreshExpiresAt, now) {
126
+ const accessToken = ACCESS_PREFIX + opaque(), accessExpiresAt = Math.min(now + ACCESS_TTL, familyExpiresAt);
127
+ this.db.prepare('DELETE FROM access_tokens WHERE expiresAt<=?').run(now);
128
+ this.db.prepare('DELETE FROM refresh_tokens WHERE deviceId IN (SELECT id FROM devices WHERE revokedAt IS NOT NULL OR expiresAt<=?)').run(now);
129
+ this.db.prepare('INSERT INTO access_tokens VALUES(?,?,?,?)').run(hash(accessToken), device.id, accessExpiresAt, hash(refreshToken));
130
+ return { device, workspaceId: this.workspaceId, accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, familyExpiresAt };
131
+ }
132
+ live(id, now) {
77
133
  const row = this.db.prepare('SELECT * FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(id, now);
78
- const supplied = Buffer.from(hash(token || '')), expected = Buffer.from(String(row?.hash || '0'.repeat(64)));
79
- if (!row || !timingSafeEqual(supplied, expected))
134
+ if (!row || (row.familyExpiresAt === null && now >= this.legacySunsetAt))
135
+ return undefined;
136
+ return { id: String(row.id), hash: String(row.hash), label: String(row.label), createdAt: Number(row.createdAt), lastUsedAt: Number(row.lastUsedAt), expiresAt: Number(row.expiresAt), revokedAt: null, familyExpiresAt: row.familyExpiresAt === null ? null : Number(row.familyExpiresAt) };
137
+ }
138
+ device(row, changes = {}) {
139
+ return { id: row.id, label: row.label, createdAt: row.createdAt, lastUsedAt: row.lastUsedAt, expiresAt: row.expiresAt, revokedAt: null, ...changes };
140
+ }
141
+ /**
142
+ * Replace a refresh credential. It has exactly one successor, derived on this host. Until that
143
+ * successor is seen in use (presented itself, or an access credential issued with it
144
+ * authenticates), the spent credential is answered again with the same successor: a lost
145
+ * answer, a closed tab or a restart converge on one value and extend nothing. Once the successor
146
+ * is in use, the spent credential is a replay and ends the link and the links it issued.
147
+ */
148
+ refresh(refreshToken) {
149
+ const outcome = this.transaction(() => this.rotate(refreshToken, false));
150
+ if (Array.isArray(outcome))
151
+ throw new WorkspaceRefreshReuseError(outcome);
152
+ return outcome;
153
+ }
154
+ /**
155
+ * A link code for another browser, issued by this link. It is a lasting grant, so it takes the
156
+ * link's current, unspent refresh credential and rotates it in the same step, with the successor
157
+ * counted as in use at once: whoever else holds the spent credential is a replay on their next
158
+ * refresh, which also ends the link made from this code. An answer lost here is not retried.
159
+ */
160
+ link(refreshToken) {
161
+ const now = this.now(), enrollment = opaque(), enrollmentExpiresAt = now + 5 * 60000;
162
+ const outcome = this.transaction(() => {
163
+ const rotated = this.rotate(refreshToken, true);
164
+ if (Array.isArray(rotated))
165
+ return rotated;
166
+ this.db.prepare('DELETE FROM enrollments WHERE expiresAt<=?').run(now);
167
+ if (Number(this.db.prepare('SELECT count(*) AS n FROM enrollments').get().n) >= 32)
168
+ throw new FleetError('rate_limited', 'too many pending device links');
169
+ this.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(hash(enrollment), enrollmentExpiresAt, rotated.device.id);
170
+ return { ...rotated, enrollment, enrollmentExpiresAt };
171
+ });
172
+ if (Array.isArray(outcome))
173
+ throw new WorkspaceRefreshReuseError(outcome);
174
+ return outcome;
175
+ }
176
+ /** Inside a transaction. Returns new credentials, or the devices ended by a replay. `strict` allows no repeat and confirms the successor. */
177
+ rotate(refreshToken, strict) {
178
+ if (!refreshShaped(refreshToken))
80
179
  throw new WorkspaceDeviceAuthError();
81
- this.db.prepare('UPDATE devices SET lastUsedAt=? WHERE id=?').run(now, id);
82
- return { id, label: String(row.label), createdAt: Number(row.createdAt), lastUsedAt: now, expiresAt: Number(row.expiresAt), revokedAt: null };
180
+ const now = this.now(), key = hash(refreshToken);
181
+ const row = this.db.prepare('SELECT * FROM refresh_tokens WHERE hash=?').get(key);
182
+ const device = row && this.live(String(row.deviceId), now);
183
+ if (!row || !device || device.familyExpiresAt === null)
184
+ throw new WorkspaceDeviceAuthError();
185
+ const next = REFRESH_PREFIX + createHmac('sha256', this.rotationKey).update(`ours.fleet.workspace-refresh.v1\0${device.id}\0${refreshToken}`).digest('base64url');
186
+ if (row.consumedAt === null) {
187
+ if (Number(row.expiresAt) <= now)
188
+ throw new WorkspaceDeviceAuthError();
189
+ const expiresAt = Math.min(now + REFRESH_IDLE_TTL, device.familyExpiresAt);
190
+ this.db.prepare('UPDATE refresh_tokens SET consumedAt=?,confirmedAt=coalesce(confirmedAt,?) WHERE hash=?').run(now, now, key);
191
+ this.db.prepare('INSERT INTO refresh_tokens VALUES(?,?,?,?,NULL,?,?)').run(hash(next), device.id, now, expiresAt, strict ? now : null, key);
192
+ // The link itself lapses with its newest refresh credential.
193
+ this.db.prepare('UPDATE devices SET expiresAt=?,lastUsedAt=? WHERE id=?').run(expiresAt, now, device.id);
194
+ return this.grant(this.device(device, { expiresAt, lastUsedAt: now }), device.familyExpiresAt, next, expiresAt, now);
195
+ }
196
+ const successor = this.db.prepare('SELECT * FROM refresh_tokens WHERE parent=?').get(key);
197
+ if (strict || !successor || successor.confirmedAt !== null || successor.consumedAt !== null)
198
+ return this.revokeRows(device.id, now, true);
199
+ if (Number(successor.expiresAt) <= now)
200
+ throw new WorkspaceDeviceAuthError();
201
+ return this.grant(this.device(device), device.familyExpiresAt, next, Number(successor.expiresAt), now);
202
+ }
203
+ /**
204
+ * One-time move of a fixed bearer to rotating credentials. The device keeps its identity, the
205
+ * bearer stops working in the same transaction, and the link ends no later than it would have.
206
+ */
207
+ exchange(token) {
208
+ const now = this.now();
209
+ return this.transaction(() => {
210
+ const legacy = this.legacy(token, now);
211
+ const familyExpiresAt = Math.min(legacy.expiresAt, now + FAMILY_TTL), refreshToken = REFRESH_PREFIX + opaque(), refreshExpiresAt = Math.min(now + REFRESH_IDLE_TTL, familyExpiresAt);
212
+ this.db.prepare('UPDATE devices SET hash=?,familyExpiresAt=?,expiresAt=?,lastUsedAt=? WHERE id=?').run('', familyExpiresAt, refreshExpiresAt, now, legacy.id);
213
+ this.db.prepare('INSERT INTO refresh_tokens VALUES(?,?,?,?,NULL,NULL,NULL)').run(hash(refreshToken), legacy.id, now, refreshExpiresAt);
214
+ return this.grant({ ...legacy, expiresAt: refreshExpiresAt, lastUsedAt: now }, familyExpiresAt, refreshToken, refreshExpiresAt, now);
215
+ });
83
216
  }
84
- valid(id) { return Boolean(this.db.prepare('SELECT id FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(id, this.now())); }
217
+ legacy(token, now) {
218
+ const row = this.live(typeof token === 'string' ? token.split('.')[0] : '', now);
219
+ const supplied = Buffer.from(hash(typeof token === 'string' ? token : '')), expected = Buffer.from(row?.hash || '0'.repeat(64));
220
+ if (!row || row.familyExpiresAt !== null || !timingSafeEqual(supplied, expected))
221
+ throw new WorkspaceDeviceAuthError();
222
+ return this.device(row);
223
+ }
224
+ /** The device behind an access credential, or behind a fixed bearer while those are still accepted. */
225
+ authenticate(token) {
226
+ const now = this.now();
227
+ if (typeof token === 'string' && token.startsWith(ACCESS_PREFIX)) {
228
+ const access = token.length <= 128 ? this.db.prepare('SELECT * FROM access_tokens WHERE hash=? AND expiresAt>?').get(hash(token), now) : undefined;
229
+ const device = access && this.live(String(access.deviceId), now);
230
+ if (!access || !device || device.familyExpiresAt === null)
231
+ throw new WorkspaceDeviceAuthError();
232
+ this.db.prepare('UPDATE devices SET lastUsedAt=? WHERE id=?').run(now, device.id);
233
+ // Use of this credential shows its refresh credential reached the browser.
234
+ this.db.prepare('UPDATE refresh_tokens SET confirmedAt=? WHERE hash=? AND confirmedAt IS NULL').run(now, String(access.refresh));
235
+ return { ...this.device(device, { lastUsedAt: now }), authorizedUntil: Number(access.expiresAt), rotating: true };
236
+ }
237
+ const device = this.legacy(token, now);
238
+ this.db.prepare('UPDATE devices SET lastUsedAt=? WHERE id=?').run(now, device.id);
239
+ return { ...device, lastUsedAt: now, authorizedUntil: Math.min(device.expiresAt, this.legacySunsetAt), rotating: false };
240
+ }
241
+ /** End the link a refresh credential belongs to, named by its SHA-256: a hash can end a link, never use it. */
242
+ revokeByRefreshHash(refreshHash) {
243
+ if (typeof refreshHash !== 'string' || !/^[a-f0-9]{64}$/.test(refreshHash))
244
+ return undefined;
245
+ const row = this.db.prepare('SELECT deviceId FROM refresh_tokens WHERE hash=?').get(refreshHash);
246
+ if (!row)
247
+ return undefined;
248
+ this.revoke(String(row.deviceId));
249
+ return String(row.deviceId);
250
+ }
251
+ /** Inside a transaction. With cascade, links issued from this one end too. Returns every device ended. */
252
+ revokeRows(id, now, cascade = false) {
253
+ const ended = [], pending = [id];
254
+ while (pending.length) {
255
+ const current = pending.pop();
256
+ if (ended.includes(current))
257
+ continue;
258
+ ended.push(current);
259
+ this.db.prepare('UPDATE devices SET revokedAt=? WHERE id=? AND revokedAt IS NULL').run(now, current);
260
+ this.db.prepare('DELETE FROM enrollments WHERE issuer=?').run(current);
261
+ this.db.prepare('DELETE FROM access_tokens WHERE deviceId=?').run(current);
262
+ this.db.prepare('DELETE FROM refresh_tokens WHERE deviceId=?').run(current);
263
+ if (cascade)
264
+ for (const child of this.db.prepare('SELECT id FROM devices WHERE issuer=? AND revokedAt IS NULL').all(current))
265
+ pending.push(String(child.id));
266
+ }
267
+ return ended;
268
+ }
269
+ valid(id) { return Boolean(this.live(id, this.now())); }
85
270
  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) })); }
86
- 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); }
271
+ revoke(id) { this.transaction(() => this.revokeRows(id, this.now())); }
87
272
  /** Retire account links without changing the installation's stable host ID or other data. */
88
273
  revokeAll() {
89
- this.db.exec('BEGIN IMMEDIATE');
90
- try {
91
- this.db.prepare('UPDATE devices SET revokedAt=? WHERE revokedAt IS NULL').run(this.now());
92
- this.db.exec('DELETE FROM enrollments');
93
- this.db.exec('COMMIT');
94
- }
95
- catch (error) {
96
- this.db.exec('ROLLBACK');
97
- throw error;
98
- }
274
+ this.transaction(() => { this.db.prepare('UPDATE devices SET revokedAt=? WHERE revokedAt IS NULL').run(this.now()); this.db.exec('DELETE FROM enrollments; DELETE FROM access_tokens; DELETE FROM refresh_tokens'); });
99
275
  }
100
276
  close() { this.db.close(); }
101
277
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.59",
3
+ "version": "1.2.0-nightly.60",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",