@deveye/types 0.15.2 → 0.16.0

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.
Files changed (73) hide show
  1. package/package.json +6 -6
  2. package/src/domain/device.ts +4 -29
  3. package/src/domain/featureRegistry.ts +40 -108
  4. package/src/domain/home.ts +40 -101
  5. package/src/domain/live.ts +36 -87
  6. package/src/domain/metrics.ts +10 -14
  7. package/src/domain/notifications.ts +39 -110
  8. package/src/domain/project.ts +3 -162
  9. package/src/domain/report.ts +46 -95
  10. package/src/domain/secrecy.ts +3 -5
  11. package/src/domain/sharing.ts +33 -70
  12. package/src/domain/syncProtocol.ts +5 -12
  13. package/src/domain/user.ts +8 -14
  14. package/src/domain/workspace.ts +0 -3
  15. package/src/domain/workspaceRole.ts +34 -82
  16. package/src/features/agent.ts +306 -0
  17. package/src/features/live.ts +17 -37
  18. package/src/features/notify.ts +19 -57
  19. package/src/features/registry.ts +7 -44
  20. package/src/features/secrecy.ts +4 -9
  21. package/src/features/sharing.ts +12 -26
  22. package/src/features/user.ts +6 -11
  23. package/src/features/workspace.ts +6 -11
  24. package/src/http/auth.ts +6 -13
  25. package/src/http/device.ts +13 -17
  26. package/src/http/status.ts +6 -10
  27. package/src/index.ts +29 -932
  28. package/src/protocol/agent.ts +40 -70
  29. package/src/protocol/envelope.ts +3 -10
  30. package/src/sdk/client-ambient.d.ts +228 -21
  31. package/src/sdk/client.ts +277 -17
  32. package/src/sdk/devb.ts +120 -0
  33. package/src/sdk/manifest.test.ts +0 -1
  34. package/src/sdk/manifest.ts +94 -25
  35. package/src/sdk/providers.ts +310 -5
  36. package/src/sdk/server.ts +483 -19
  37. package/src/sdk/testing.test.ts +2 -2
  38. package/src/sdk/testing.ts +302 -26
  39. package/src/utils/version.ts +5 -8
  40. package/src/domain/audience.ts +0 -549
  41. package/src/domain/backup.ts +0 -355
  42. package/src/domain/credential.ts +0 -55
  43. package/src/domain/database.ts +0 -467
  44. package/src/domain/deploy.ts +0 -231
  45. package/src/domain/finance.ts +0 -477
  46. package/src/domain/git.ts +0 -419
  47. package/src/domain/mail.ts +0 -394
  48. package/src/domain/note.ts +0 -202
  49. package/src/domain/password.ts +0 -36
  50. package/src/domain/projectBoard.ts +0 -130
  51. package/src/domain/projectChat.ts +0 -46
  52. package/src/domain/projectHistory.ts +0 -82
  53. package/src/domain/projectLink.ts +0 -87
  54. package/src/domain/projectPlan.ts +0 -68
  55. package/src/domain/sentinel.ts +0 -623
  56. package/src/domain/uptime.ts +0 -216
  57. package/src/features/audience.ts +0 -275
  58. package/src/features/backup.ts +0 -230
  59. package/src/features/database.ts +0 -461
  60. package/src/features/deploy.ts +0 -245
  61. package/src/features/device.ts +0 -292
  62. package/src/features/deviceFiles.ts +0 -83
  63. package/src/features/deviceLogs.ts +0 -36
  64. package/src/features/deviceTerminal.ts +0 -57
  65. package/src/features/finance.ts +0 -360
  66. package/src/features/git.ts +0 -368
  67. package/src/features/mail.ts +0 -374
  68. package/src/features/metrics.ts +0 -185
  69. package/src/features/note.ts +0 -189
  70. package/src/features/password.ts +0 -67
  71. package/src/features/project.ts +0 -709
  72. package/src/features/sentinel.ts +0 -233
  73. package/src/features/uptime.ts +0 -186
@@ -1,14 +1,20 @@
1
1
  import type { ZodType } from 'zod';
2
+ import type { ItemAccess } from '../domain/sharing';
2
3
  import { resolveExtras, type FeatureManifest } from './manifest';
3
- import type {
4
- DevEyeFacade,
5
- FeatureServiceDeps,
6
- FeatureStore,
7
- SdkCipher,
8
- SdkDevice,
9
- SdkFeatureContext,
10
- SdkLogger,
11
- StorageEncryption
4
+ import {
5
+ FeatureError,
6
+ type DevEyeFacade,
7
+ type FeatureServiceDeps,
8
+ type FeatureStore,
9
+ type SdkCipher,
10
+ type SdkDevice,
11
+ type SdkFeatureContext,
12
+ type SdkLogger,
13
+ type SdkProviders,
14
+ type SdkTelemetry,
15
+ type SdkTelemetrySnapshot,
16
+ type StorageEncryption,
17
+ SdkWorkspaceSummary
12
18
  } from './server';
13
19
 
14
20
  /**
@@ -34,6 +40,17 @@ const identityCipher: SdkCipher = {
34
40
  tryDecrypt: (blob) => Promise.resolve(blob)
35
41
  };
36
42
 
43
+ /**
44
+ * The guarded cipher of a SEALED session: `encrypt` and `decrypt` throw
45
+ * `locked`, `tryDecrypt` answers null. Handed out for `'private'` when the
46
+ * harness says `unlocked: false`.
47
+ */
48
+ const sealedCipher: SdkCipher = {
49
+ encrypt: () => Promise.reject(new FeatureError('locked', 'Password encryption is locked')),
50
+ decrypt: () => Promise.reject(new FeatureError('locked', 'Password encryption is locked')),
51
+ tryDecrypt: () => Promise.resolve(null)
52
+ };
53
+
37
54
  const silentLogger: SdkLogger = {
38
55
  debug: () => undefined,
39
56
  info: () => undefined,
@@ -78,37 +95,96 @@ function memoryStore(): TestFeatureStore {
78
95
  }
79
96
 
80
97
  export interface RecordedCalls {
81
- notifications: { subject: string; body: string; itemId?: number }[];
98
+ notifications: {
99
+ subject: string;
100
+ body: string;
101
+ itemId?: number;
102
+ embeds?: number;
103
+ except?: readonly number[];
104
+ }[];
105
+ /** Live messages posted (`messageId: null`) or edited through `notify.postLive`. */
106
+ liveMessages: { channelId: number; messageId: string | null; embeds?: number }[];
82
107
  audits: { action: string; description: string }[];
83
108
  /** Outbound agent frames, as `{ method, deviceId }` (payloads dropped for brevity). */
84
109
  agentRequests: { method: string; deviceId: string }[];
110
+ /** Instants pinned through `telemetry.pinInstant`. */
111
+ pinnedInstants: { deviceId: string; ts: number }[];
85
112
  }
86
113
 
87
- function recordingNotify(recorded: RecordedCalls, hasRoute: boolean): DevEyeFacade['notify'] {
114
+ function recordingNotify(
115
+ recorded: RecordedCalls,
116
+ hasRoute: boolean,
117
+ accepted: boolean,
118
+ liveChannels: readonly number[]
119
+ ): DevEyeFacade['notify'] {
120
+ let posted = 0;
88
121
  return {
89
122
  hasRoute: () => Promise.resolve(hasRoute),
90
123
  send(alert, opts) {
91
124
  recorded.notifications.push({
92
125
  subject: alert.subject,
93
126
  body: alert.body,
94
- itemId: opts?.itemId
127
+ itemId: opts?.itemId,
128
+ // Only when the alert carries a layout: a test that
129
+ // deep-equals the plain record must not see the key appear.
130
+ ...(alert.embeds ? { embeds: alert.embeds.length } : {}),
131
+ ...(opts?.except ? { except: opts.except } : {})
132
+ });
133
+ // Recorded either way (the module did try), but a refused delivery
134
+ // answers false, so a test sees what the module does with it.
135
+ return Promise.resolve(accepted);
136
+ },
137
+ liveChannels: () => Promise.resolve(liveChannels.map((id) => ({ id }))),
138
+ postLive(channelId, message, messageId) {
139
+ recorded.liveMessages.push({
140
+ channelId,
141
+ messageId: messageId ?? null,
142
+ ...(message.embeds ? { embeds: message.embeds.length } : {})
95
143
  });
96
- return Promise.resolve(true);
144
+ // A post mints an id (`live-1`, `live-2`...), an edit keeps the
145
+ // one it was given; a refusing host answers null either way.
146
+ if (!accepted) return Promise.resolve(null);
147
+ return Promise.resolve(messageId ?? `live-${++posted}`);
97
148
  }
98
149
  };
99
150
  }
100
151
 
152
+ /** A device the harness invents for an id nothing listed: active, online, unreported. */
153
+ export function testDevice(over: Partial<SdkDevice> & { id: string }): SdkDevice {
154
+ return {
155
+ name: 'Test device',
156
+ online: true,
157
+ status: 'active',
158
+ ownerUserId: 1,
159
+ workspaceId: 1,
160
+ metricIntervalSeconds: null,
161
+ report: null,
162
+ ...over
163
+ };
164
+ }
165
+
101
166
  function recordingDevices(devices: readonly SdkDevice[]): DevEyeFacade['devices'] {
102
167
  return {
103
- authorize: (id) =>
104
- Promise.resolve(
105
- devices.find((d) => d.id === id) ?? { id, name: 'Test device', online: true }
106
- ),
168
+ authorize: (id) => Promise.resolve(devices.find((d) => d.id === id) ?? testDevice({ id })),
107
169
  list: () => Promise.resolve([...devices]),
108
170
  isOnline: (id) => devices.find((d) => d.id === id)?.online ?? true
109
171
  };
110
172
  }
111
173
 
174
+ function recordingTelemetry(
175
+ recorded: RecordedCalls,
176
+ snapshots: readonly SdkTelemetrySnapshot[]
177
+ ): SdkTelemetry {
178
+ return {
179
+ snapshot: (_deviceId, ts) =>
180
+ Promise.resolve(snapshots.find((s) => Math.abs(s.ts - ts) <= 1000) ?? null),
181
+ pinInstant(deviceId, ts) {
182
+ recorded.pinnedInstants.push({ deviceId, ts });
183
+ return Promise.resolve();
184
+ }
185
+ };
186
+ }
187
+
112
188
  function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
113
189
  const req = (method: string) => (deviceId: string) => {
114
190
  recorded.agentRequests.push({ method, deviceId });
@@ -116,6 +192,8 @@ function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
116
192
  };
117
193
  return {
118
194
  isOnline: () => true,
195
+ requestScan: req('requestScan'),
196
+ pushConfig: (deviceId) => Promise.resolve(req('pushConfig')(deviceId)),
119
197
  requestSyncConfig: req('requestSyncConfig'),
120
198
  requestSyncScan: req('requestSyncScan'),
121
199
  requestSyncPush: req('requestSyncPush'),
@@ -126,13 +204,46 @@ function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
126
204
  requestSyncMove: req('requestSyncMove'),
127
205
  requestSyncDelete: req('requestSyncDelete'),
128
206
  publishSyncProgress: () => undefined,
129
- publishSyncState: () => undefined
207
+ publishSyncState: () => undefined,
208
+ requestDestroy: req('requestDestroy'),
209
+ disconnectAgent: req('disconnectAgent'),
210
+ resetAgentSession: req('resetAgentSession'),
211
+ // Nothing synced: a test of the self-update flag feeds a manifest to
212
+ // the module's own pure helper.
213
+ servedManifest: () => Promise.resolve(null),
214
+ requestFilesMutate: req('requestFilesMutate'),
215
+ requestFilesUpload: req('requestFilesUpload'),
216
+ // Every file order succeeds at once: a test of what a module does
217
+ // with a refusal injects its own facade through `deveye`.
218
+ awaitFilesOp: () => Promise.resolve({ ok: true }),
219
+ cancelFilesOp: () => undefined,
220
+ buffered: () => 0
130
221
  };
131
222
  }
132
223
 
224
+ /**
225
+ * A deterministic stand-in for `keys.derive`: the same (salt, info) yields
226
+ * the same bytes, distinct pairs distinct bytes, and nothing here is secret.
227
+ */
228
+ function fakeDerive(salt: string, info: string, length: number): Uint8Array {
229
+ const out = new Uint8Array(length);
230
+ const seed = `${salt}|${info}`;
231
+ for (let i = 0; i < length; i += 1) {
232
+ out[i] = (seed.charCodeAt(i % seed.length) * (i + 1)) & 0xff;
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /** The named contracts a test hands to the module (`providers` override). */
238
+ function fakeProviders(table: Readonly<Record<string, unknown>>): SdkProviders {
239
+ return { get: <T>(key: string) => table[key] as T | undefined };
240
+ }
241
+
133
242
  export interface TestContext<Repo> extends SdkFeatureContext<Repo> {
134
243
  recorded: RecordedCalls;
135
244
  store: TestFeatureStore;
245
+ /** Item ids passed to `items.forget`, in order. */
246
+ forgotten: number[];
136
247
  }
137
248
 
138
249
  export interface TestContextOverrides<Repo> {
@@ -141,6 +252,10 @@ export interface TestContextOverrides<Repo> {
141
252
  workspaceId?: number;
142
253
  kind?: 'personal' | 'shared';
143
254
  isOwner?: boolean;
255
+ /** Global administrator. Default false. */
256
+ isAdmin?: boolean;
257
+ /** What `deveye.workspaces.list()` answers. Default none. */
258
+ workspaces?: readonly SdkWorkspaceSummary[];
144
259
  canWrite?: boolean;
145
260
  /** Extra permissions the caller holds, as the grant would carry them. */
146
261
  extras?: Record<string, boolean | string>;
@@ -152,40 +267,135 @@ export interface TestContextOverrides<Repo> {
152
267
  manifest?: Pick<FeatureManifest, 'extraPermissions'>;
153
268
  /** What `deveye.notify.hasRoute` answers. Default true. */
154
269
  hasRoute?: boolean;
270
+ /** What `deveye.notify.send` resolves (no usable channel: false). Default true; recorded either way. */
271
+ notifyAccepted?: boolean;
272
+ /** What `ctx.origins` answers. Default `https://deveye.test` / `https://public.deveye.test`. */
273
+ origins?: { app: string; public: string };
274
+ /** The channel ids `deveye.notify.liveChannels` lists. Default none. */
275
+ liveChannels?: readonly number[];
155
276
  /** Devices `deveye.devices` reveals. Default none listed, any id authorized. */
156
277
  devices?: readonly SdkDevice[];
278
+ /** Instants `deveye.telemetry.snapshot` answers (matched within a second). Default none. */
279
+ snapshots?: readonly SdkTelemetrySnapshot[];
157
280
  /** Override facade members entirely when the defaults are not enough. */
158
281
  deveye?: Partial<DevEyeFacade>;
282
+ /**
283
+ * What `secrecy.isUnlocked` answers. Default true. When false, the
284
+ * `'private'` cipher is sealed too (`decrypt` throws `locked`,
285
+ * `tryDecrypt` answers null), exactly like the app's guarded tier in a
286
+ * locked session; the `'server'` cipher stays the identity.
287
+ */
288
+ unlocked?: boolean;
289
+ /** The caller's role restrictions on items, by item id. Default none. */
290
+ itemRestrictions?: Readonly<Record<number, ItemAccess>>;
291
+ /**
292
+ * Items projected INTO the workspace, as `itemId → home workspace id`.
293
+ * Default none: every item is at home. `sharing.scope().cipherFor` is the
294
+ * identity cipher either way.
295
+ */
296
+ shares?: Readonly<Record<number, number>>;
297
+ /** The named contracts the host holds (`ctx.providers.get(key)`). */
298
+ providers?: Readonly<Record<string, unknown>>;
159
299
  }
160
300
 
161
301
  export function createTestContext<Repo = undefined>(
162
302
  overrides: TestContextOverrides<Repo> = {}
163
303
  ): TestContext<Repo> {
164
- const recorded: RecordedCalls = { notifications: [], audits: [], agentRequests: [] };
304
+ const recorded: RecordedCalls = {
305
+ notifications: [],
306
+ liveMessages: [],
307
+ audits: [],
308
+ agentRequests: [],
309
+ pinnedInstants: []
310
+ };
165
311
  const isOwner = overrides.isOwner ?? true;
166
312
  const workspaceId = overrides.workspaceId ?? 1;
313
+ const canWrite = overrides.canWrite ?? true;
167
314
  const deveye: DevEyeFacade = {
168
- notify: recordingNotify(recorded, overrides.hasRoute ?? true),
315
+ notify: recordingNotify(
316
+ recorded,
317
+ overrides.hasRoute ?? true,
318
+ overrides.notifyAccepted ?? true,
319
+ overrides.liveChannels ?? []
320
+ ),
169
321
  mail: { listAccounts: () => Promise.resolve([]) },
170
322
  members: {
171
323
  list: () =>
172
- Promise.resolve([{ userId: overrides.userId ?? 1, name: 'Test', isOwner: true }])
324
+ Promise.resolve([
325
+ { userId: overrides.userId ?? 1, name: 'Test', isOwner: true, color: null }
326
+ ])
173
327
  },
328
+ workspaces: { list: () => Promise.resolve(overrides.workspaces ?? []) },
174
329
  devices: recordingDevices(overrides.devices ?? []),
330
+ telemetry: recordingTelemetry(recorded, overrides.snapshots ?? []),
175
331
  agents: recordingAgents(recorded),
176
332
  ...overrides.deveye
177
333
  };
334
+ const restrictions = new Map<number, ItemAccess>(
335
+ Object.entries(overrides.itemRestrictions ?? {}).map(([id, access]) => [Number(id), access])
336
+ );
337
+ const homes = new Map<number, number>(
338
+ Object.entries(overrides.shares ?? {}).map(([id, home]) => [Number(id), home])
339
+ );
340
+ const forgotten: number[] = [];
178
341
  return {
179
342
  recorded,
343
+ forgotten,
344
+ secrecy: {
345
+ isUnlocked: () => Promise.resolve(overrides.unlocked ?? true),
346
+ // Un ticket lisible tel quel : le harnais ne signe rien, il
347
+ // sérialise, et `createTestServiceDeps().secrecy.redeem` relit.
348
+ ticket: (payload) =>
349
+ Promise.resolve(
350
+ `ticket:${JSON.stringify({ userId: overrides.userId ?? 1, workspaceId, payload, unlocked: overrides.unlocked ?? true })}`
351
+ )
352
+ },
353
+ keys: {
354
+ sealBytes: (plain) => `sealed:${Buffer.from(plain).toString('base64')}`,
355
+ openBytes: () => null,
356
+ derive: fakeDerive
357
+ },
358
+ items: {
359
+ restrictions: () => Promise.resolve(restrictions),
360
+ // The exact rule of the app's dispatcher: the feature first (a
361
+ // restriction can only lower), then the row.
362
+ assert(itemId, level = 'read') {
363
+ if (level === 'write' && !canWrite) {
364
+ return Promise.reject(new FeatureError('forbidden', 'write required'));
365
+ }
366
+ const restriction = restrictions.get(itemId);
367
+ if (restriction === 'none') {
368
+ return Promise.reject(new FeatureError('forbidden', 'item hidden'));
369
+ }
370
+ if (restriction === 'read' && level === 'write') {
371
+ return Promise.reject(new FeatureError('forbidden', 'item read-only'));
372
+ }
373
+ return Promise.resolve();
374
+ },
375
+ forget(itemId) {
376
+ forgotten.push(itemId);
377
+ return Promise.resolve();
378
+ }
379
+ },
380
+ sharing: {
381
+ scope: () =>
382
+ Promise.resolve({
383
+ foreignIds: new Set(homes.keys()),
384
+ homeOf: (itemId) => homes.get(itemId) ?? null,
385
+ cipherFor: () => Promise.resolve(identityCipher)
386
+ })
387
+ },
180
388
  userId: overrides.userId ?? 1,
181
389
  workspaceId,
182
390
  workspace: { id: workspaceId, kind: overrides.kind ?? 'personal', name: 'Test' },
183
391
  isOwner,
392
+ isAdmin: overrides.isAdmin ?? false,
184
393
  canWrite: overrides.canWrite ?? true,
185
394
  ...resolveExtras(overrides.manifest?.extraPermissions, isOwner, overrides.extras ?? {}),
186
395
  repo: overrides.repo as Repo,
187
396
  store: memoryStore(),
188
- cipher: () => identityCipher,
397
+ cipher: (mode) =>
398
+ mode === 'private' && overrides.unlocked === false ? sealedCipher : identityCipher,
189
399
  deveye,
190
400
  transport: {
191
401
  subscribeSync: () => undefined,
@@ -193,17 +403,26 @@ export function createTestContext<Repo = undefined>(
193
403
  sendSyncChunk: () => 0,
194
404
  syncChunkBuffered: () => 0
195
405
  },
406
+ providers: fakeProviders(overrides.providers ?? {}),
196
407
  audit: (entry) => {
197
408
  recorded.audits.push({ action: entry.action, description: entry.description });
198
409
  },
199
410
  logger: silentLogger,
200
- requestId: 'test'
411
+ requestId: 'test',
412
+ origins: overrides.origins ?? {
413
+ app: 'https://deveye.test',
414
+ public: 'https://public.deveye.test'
415
+ }
201
416
  };
202
417
  }
203
418
 
204
419
  export interface RecordedServiceCalls extends RecordedCalls {
205
420
  /** Every `createTicker` call, so a test drives ticks by hand: `await tickers[0].tick()`. */
206
421
  tickers: { intervalMs: number; tick(): Promise<void> }[];
422
+ /** Workspaces passed to `live.changed`, in order. */
423
+ liveChanges: number[];
424
+ /** `live.changed` calls that named topics, as `{ workspaceId, topics }` (a bare beat is not listed here). */
425
+ liveTopicChanges: { workspaceId: number; topics: readonly string[] }[];
207
426
  }
208
427
 
209
428
  export interface TestServiceDeps<Repo> extends FeatureServiceDeps<Repo> {
@@ -220,6 +439,16 @@ export interface TestServiceOverrides<Repo> {
220
439
  devices?: readonly SdkDevice[];
221
440
  /** What `deveyeFor(...).notify.hasRoute` answers. Default true. */
222
441
  hasRoute?: boolean;
442
+ /** What `deveyeFor(...).notify.send` resolves. Default true; recorded either way. */
443
+ notifyAccepted?: boolean;
444
+ /** The channel ids `deveyeFor(...).notify.liveChannels` lists. Default none. */
445
+ liveChannels?: readonly number[];
446
+ /** What `deps.origins` answers. Default `https://deveye.test` / `https://public.deveye.test`. */
447
+ origins?: { app: string; public: string };
448
+ /** Instants `telemetry.snapshot` answers (matched within a second). Default none. */
449
+ snapshots?: readonly SdkTelemetrySnapshot[];
450
+ /** The named contracts the host holds (`deps.providers.get(key)`). */
451
+ providers?: Readonly<Record<string, unknown>>;
223
452
  }
224
453
 
225
454
  /**
@@ -232,13 +461,22 @@ export function createTestServiceDeps<Repo = undefined>(
232
461
  ): TestServiceDeps<Repo> {
233
462
  const recorded: RecordedServiceCalls = {
234
463
  notifications: [],
464
+ liveMessages: [],
235
465
  audits: [],
236
466
  agentRequests: [],
237
- tickers: []
467
+ pinnedInstants: [],
468
+ tickers: [],
469
+ liveChanges: [],
470
+ liveTopicChanges: []
238
471
  };
239
472
  const stores = new Map<number, TestFeatureStore>();
240
473
  const sealedBytes = new Map<string, Uint8Array>();
241
- const notify = recordingNotify(recorded, overrides.hasRoute ?? true);
474
+ const notify = recordingNotify(
475
+ recorded,
476
+ overrides.hasRoute ?? true,
477
+ overrides.notifyAccepted ?? true,
478
+ overrides.liveChannels ?? []
479
+ );
242
480
  const devices = recordingDevices(overrides.devices ?? []);
243
481
  return {
244
482
  recorded,
@@ -255,7 +493,43 @@ export function createTestServiceDeps<Repo = undefined>(
255
493
  },
256
494
  cipherFor: () => identityCipher,
257
495
  deveyeFor: () => ({ notify }),
496
+ origins: overrides.origins ?? {
497
+ app: 'https://deveye.test',
498
+ public: 'https://public.deveye.test'
499
+ },
500
+ secrecy: {
501
+ redeem: (ticket) => {
502
+ if (!ticket.startsWith('ticket:')) return Promise.resolve(null);
503
+ const parsed = JSON.parse(ticket.slice('ticket:'.length)) as {
504
+ userId: number;
505
+ workspaceId: number;
506
+ payload: unknown;
507
+ unlocked: boolean;
508
+ };
509
+ return Promise.resolve({
510
+ userId: parsed.userId,
511
+ workspaceId: parsed.workspaceId,
512
+ payload: parsed.payload,
513
+ cipher: {
514
+ server: identityCipher,
515
+ private: parsed.unlocked ? identityCipher : null
516
+ }
517
+ });
518
+ }
519
+ },
258
520
  devicesFor: () => ({ list: devices.list, isOnline: devices.isOnline }),
521
+ devices: {
522
+ find: (id) =>
523
+ Promise.resolve((overrides.devices ?? []).find((d) => d.id === id) ?? null),
524
+ isOnline: devices.isOnline
525
+ },
526
+ telemetry: recordingTelemetry(recorded, overrides.snapshots ?? []),
527
+ live: {
528
+ changed(workspaceId, topics) {
529
+ recorded.liveChanges.push(workspaceId);
530
+ if (topics) recorded.liveTopicChanges.push({ workspaceId, topics });
531
+ }
532
+ },
259
533
  audit: (entry) => {
260
534
  recorded.audits.push({ action: entry.action, description: entry.description });
261
535
  },
@@ -268,8 +542,10 @@ export function createTestServiceDeps<Repo = undefined>(
268
542
  sealedBytes.set(handle, Uint8Array.from(plain));
269
543
  return handle;
270
544
  },
271
- openBytes: (sealed) => sealedBytes.get(sealed) ?? null
545
+ openBytes: (sealed) => sealedBytes.get(sealed) ?? null,
546
+ derive: fakeDerive
272
547
  },
548
+ providers: fakeProviders(overrides.providers ?? {}),
273
549
  createTicker({ intervalMs, tick }) {
274
550
  recorded.tickers.push({ intervalMs, tick });
275
551
  return { start: () => undefined, stop: () => undefined };
@@ -1,13 +1,10 @@
1
1
  /**
2
- * Dotted-numeric version helpers shared by the server (which decides whether to
3
- * offer/push an agent self-update) and the web client (which decides whether to
4
- * show the affordance). Keeping a single implementation here means both ends agree
5
- * on what "newer" means — a self-update is only ever offered/pushed as an UPGRADE,
6
- * never a downgrade, even if the served manifest happens to lag a running agent.
2
+ * Dotted-numeric version helpers shared by the server (offers/pushes an agent
3
+ * self-update) and the web client (shows the affordance), so both agree on what
4
+ * "newer" means: a self-update is only ever an upgrade, never a downgrade.
7
5
  *
8
- * Non-numeric segments are treated as 0 and missing segments as 0, so `1.2` and
9
- * `1.2.0` compare equal. No pre-release/build-metadata handling: DevEye versions
10
- * come from a single `package.json`, so plain dotted integers are enough.
6
+ * Non-numeric and missing segments count as 0, so `1.2` equals `1.2.0`. No
7
+ * pre-release handling: DevEye versions are plain dotted integers.
11
8
  */
12
9
 
13
10
  /** Compare dotted numeric versions: <0 if a<b, >0 if a>b, 0 if equal. */