@deveye/types 0.15.1 → 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 +9 -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 +244 -22
  31. package/src/sdk/client.ts +277 -17
  32. package/src/sdk/devb.ts +120 -0
  33. package/src/sdk/manifest.test.ts +96 -0
  34. package/src/sdk/manifest.ts +160 -25
  35. package/src/sdk/providers.ts +310 -5
  36. package/src/sdk/server.ts +483 -19
  37. package/src/sdk/testing.test.ts +62 -0
  38. package/src/sdk/testing.ts +416 -40
  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
@@ -0,0 +1,62 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { z } from 'zod';
4
+
5
+ import { createTestContext, createTestServiceDeps, testDevice } from './testing';
6
+
7
+ test('createTestContext follows the manifest for extras, and records what handlers do', async () => {
8
+ const manifest = {
9
+ extraPermissions: [
10
+ { key: 'reset', type: 'toggle' as const, label: 'Reset', description: '' }
11
+ ]
12
+ };
13
+ const member = createTestContext({ manifest, isOwner: false, extras: { reset: true } });
14
+ assert.equal(member.canExtra('reset'), true);
15
+ assert.equal(createTestContext({ manifest, isOwner: false }).canExtra('reset'), false);
16
+ assert.equal(
17
+ createTestContext({ isOwner: true }).canExtra('reset'),
18
+ false,
19
+ 'no manifest, no extra'
20
+ );
21
+
22
+ await member.deveye.notify.send({ subject: 's', body: 'b' }, { itemId: 3 });
23
+ member.audit({ action: 'x.did', description: 'did' });
24
+ assert.deepEqual(member.recorded.notifications, [{ subject: 's', body: 'b', itemId: 3 }]);
25
+ assert.deepEqual(member.recorded.audits, [{ action: 'x.did', description: 'did' }]);
26
+
27
+ await member.store.putJson(
28
+ 'k',
29
+ z.object({ n: z.number() }),
30
+ { n: 1 },
31
+ { encryption: 'private' }
32
+ );
33
+ assert.equal(member.store.rows.get('k')?.mode, 'private');
34
+ });
35
+
36
+ test('createTestServiceDeps: one store per workspace, hand-driven tickers, a wrapper that round-trips', async () => {
37
+ const deps = createTestServiceDeps({
38
+ workspaceIds: [1, 2],
39
+ devices: [testDevice({ id: 'd1', name: 'One', online: false })]
40
+ });
41
+ assert.deepEqual(await deps.listWorkspaceIds(), [1, 2]);
42
+ await deps.storeFor(1).put('a', '1');
43
+ assert.equal(await deps.storeFor(2).get('a'), null);
44
+ assert.equal(deps.stores.size, 2);
45
+
46
+ let beats = 0;
47
+ const service = deps.createTicker({
48
+ intervalMs: 1000,
49
+ tick: () => Promise.resolve(void beats++)
50
+ });
51
+ await service.start();
52
+ assert.equal(beats, 0, 'a ticker never starts on its own');
53
+ await deps.recorded.tickers[0].tick();
54
+ assert.equal(beats, 1);
55
+
56
+ const sealed = deps.keys.sealBytes(new Uint8Array([1, 2, 3]));
57
+ assert.deepEqual(deps.keys.openBytes(sealed), new Uint8Array([1, 2, 3]));
58
+ assert.equal(deps.keys.openBytes('nope'), null);
59
+
60
+ assert.equal(deps.devicesFor(1).isOnline('d1'), false);
61
+ assert.equal((await deps.devicesFor(1).list()).length, 1);
62
+ });
@@ -1,22 +1,36 @@
1
1
  import type { ZodType } from 'zod';
2
- import type {
3
- DevEyeFacade,
4
- FeatureStore,
5
- SdkCipher,
6
- SdkFeatureContext,
7
- SdkLogger,
8
- StorageEncryption
2
+ import type { ItemAccess } from '../domain/sharing';
3
+ import { resolveExtras, type FeatureManifest } from './manifest';
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
9
18
  } from './server';
10
19
 
11
20
  /**
12
- * Test harness for feature handlers: a fully in-memory {@link SdkFeatureContext}
13
- * with identity ciphers, a recording facade, and a silent logger. Call your
14
- * handlers directly from `node:test` files; no app, no database, no socket.
21
+ * Test harnesses: a fully in-memory {@link SdkFeatureContext} for handlers and
22
+ * a matching {@link FeatureServiceDeps} for background services, with
23
+ * identity ciphers, a recording facade, and a silent logger. Call your code
24
+ * directly from `node:test` files; no app, no database, no socket.
15
25
  *
16
26
  * ```ts
17
- * const ctx = createTestContext({ repo: fakeRepo() });
27
+ * const ctx = createTestContext({ repo: fakeRepo(), manifest });
18
28
  * const out = await myFeature.features[0].handler(ctx, { name: 'x' });
19
29
  * assert.equal(ctx.recorded.notifications.length, 1);
30
+ *
31
+ * const deps = createTestServiceDeps({ repo: fakeRepo() });
32
+ * const service = myServer.createService(deps);
33
+ * await deps.recorded.tickers[0].tick();
20
34
  * ```
21
35
  */
22
36
 
@@ -26,6 +40,17 @@ const identityCipher: SdkCipher = {
26
40
  tryDecrypt: (blob) => Promise.resolve(blob)
27
41
  };
28
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
+
29
54
  const silentLogger: SdkLogger = {
30
55
  debug: () => undefined,
31
56
  info: () => undefined,
@@ -70,10 +95,94 @@ function memoryStore(): TestFeatureStore {
70
95
  }
71
96
 
72
97
  export interface RecordedCalls {
73
- 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 }[];
74
107
  audits: { action: string; description: string }[];
75
108
  /** Outbound agent frames, as `{ method, deviceId }` (payloads dropped for brevity). */
76
109
  agentRequests: { method: string; deviceId: string }[];
110
+ /** Instants pinned through `telemetry.pinInstant`. */
111
+ pinnedInstants: { deviceId: string; ts: number }[];
112
+ }
113
+
114
+ function recordingNotify(
115
+ recorded: RecordedCalls,
116
+ hasRoute: boolean,
117
+ accepted: boolean,
118
+ liveChannels: readonly number[]
119
+ ): DevEyeFacade['notify'] {
120
+ let posted = 0;
121
+ return {
122
+ hasRoute: () => Promise.resolve(hasRoute),
123
+ send(alert, opts) {
124
+ recorded.notifications.push({
125
+ subject: alert.subject,
126
+ body: alert.body,
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 } : {})
143
+ });
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}`);
148
+ }
149
+ };
150
+ }
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
+
166
+ function recordingDevices(devices: readonly SdkDevice[]): DevEyeFacade['devices'] {
167
+ return {
168
+ authorize: (id) => Promise.resolve(devices.find((d) => d.id === id) ?? testDevice({ id })),
169
+ list: () => Promise.resolve([...devices]),
170
+ isOnline: (id) => devices.find((d) => d.id === id)?.online ?? true
171
+ };
172
+ }
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
+ };
77
186
  }
78
187
 
79
188
  function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
@@ -83,6 +192,8 @@ function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
83
192
  };
84
193
  return {
85
194
  isOnline: () => true,
195
+ requestScan: req('requestScan'),
196
+ pushConfig: (deviceId) => Promise.resolve(req('pushConfig')(deviceId)),
86
197
  requestSyncConfig: req('requestSyncConfig'),
87
198
  requestSyncScan: req('requestSyncScan'),
88
199
  requestSyncPush: req('requestSyncPush'),
@@ -93,13 +204,46 @@ function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
93
204
  requestSyncMove: req('requestSyncMove'),
94
205
  requestSyncDelete: req('requestSyncDelete'),
95
206
  publishSyncProgress: () => undefined,
96
- 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
97
221
  };
98
222
  }
99
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
+
100
242
  export interface TestContext<Repo> extends SdkFeatureContext<Repo> {
101
243
  recorded: RecordedCalls;
102
244
  store: TestFeatureStore;
245
+ /** Item ids passed to `items.forget`, in order. */
246
+ forgotten: number[];
103
247
  }
104
248
 
105
249
  export interface TestContextOverrides<Repo> {
@@ -108,61 +252,150 @@ export interface TestContextOverrides<Repo> {
108
252
  workspaceId?: number;
109
253
  kind?: 'personal' | 'shared';
110
254
  isOwner?: boolean;
255
+ /** Global administrator. Default false. */
256
+ isAdmin?: boolean;
257
+ /** What `deveye.workspaces.list()` answers. Default none. */
258
+ workspaces?: readonly SdkWorkspaceSummary[];
111
259
  canWrite?: boolean;
112
260
  /** Extra permissions the caller holds, as the grant would carry them. */
113
261
  extras?: Record<string, boolean | string>;
262
+ /**
263
+ * Your manifest: `canExtra` / `extraValue` then follow the exact runtime
264
+ * rules ({@link resolveExtras}). Without it no extra is declared, so
265
+ * every key answers `false` / `''`, owner or not.
266
+ */
267
+ manifest?: Pick<FeatureManifest, 'extraPermissions'>;
114
268
  /** What `deveye.notify.hasRoute` answers. Default true. */
115
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[];
276
+ /** Devices `deveye.devices` reveals. Default none listed, any id authorized. */
277
+ devices?: readonly SdkDevice[];
278
+ /** Instants `deveye.telemetry.snapshot` answers (matched within a second). Default none. */
279
+ snapshots?: readonly SdkTelemetrySnapshot[];
116
280
  /** Override facade members entirely when the defaults are not enough. */
117
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>>;
118
299
  }
119
300
 
120
301
  export function createTestContext<Repo = undefined>(
121
302
  overrides: TestContextOverrides<Repo> = {}
122
303
  ): TestContext<Repo> {
123
- const recorded: RecordedCalls = { notifications: [], audits: [], agentRequests: [] };
124
- const extras = overrides.extras ?? {};
304
+ const recorded: RecordedCalls = {
305
+ notifications: [],
306
+ liveMessages: [],
307
+ audits: [],
308
+ agentRequests: [],
309
+ pinnedInstants: []
310
+ };
311
+ const isOwner = overrides.isOwner ?? true;
125
312
  const workspaceId = overrides.workspaceId ?? 1;
313
+ const canWrite = overrides.canWrite ?? true;
126
314
  const deveye: DevEyeFacade = {
127
- notify: {
128
- hasRoute: () => Promise.resolve(overrides.hasRoute ?? true),
129
- send(alert, opts) {
130
- recorded.notifications.push({
131
- subject: alert.subject,
132
- body: alert.body,
133
- itemId: opts?.itemId
134
- });
135
- return Promise.resolve(true);
136
- }
137
- },
315
+ notify: recordingNotify(
316
+ recorded,
317
+ overrides.hasRoute ?? true,
318
+ overrides.notifyAccepted ?? true,
319
+ overrides.liveChannels ?? []
320
+ ),
138
321
  mail: { listAccounts: () => Promise.resolve([]) },
139
322
  members: {
140
323
  list: () =>
141
- Promise.resolve([{ userId: overrides.userId ?? 1, name: 'Test', isOwner: true }])
142
- },
143
- devices: {
144
- authorize: (id) => Promise.resolve({ id, name: 'Test device', online: true }),
145
- list: () => Promise.resolve([]),
146
- isOnline: () => true
324
+ Promise.resolve([
325
+ { userId: overrides.userId ?? 1, name: 'Test', isOwner: true, color: null }
326
+ ])
147
327
  },
328
+ workspaces: { list: () => Promise.resolve(overrides.workspaces ?? []) },
329
+ devices: recordingDevices(overrides.devices ?? []),
330
+ telemetry: recordingTelemetry(recorded, overrides.snapshots ?? []),
148
331
  agents: recordingAgents(recorded),
149
332
  ...overrides.deveye
150
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[] = [];
151
341
  return {
152
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
+ },
153
388
  userId: overrides.userId ?? 1,
154
389
  workspaceId,
155
390
  workspace: { id: workspaceId, kind: overrides.kind ?? 'personal', name: 'Test' },
156
- isOwner: overrides.isOwner ?? true,
391
+ isOwner,
392
+ isAdmin: overrides.isAdmin ?? false,
157
393
  canWrite: overrides.canWrite ?? true,
158
- canExtra: (key) => (overrides.isOwner ?? true) || extras[key] === true,
159
- extraValue: (key) => {
160
- const value = extras[key];
161
- return typeof value === 'string' ? value : '';
162
- },
394
+ ...resolveExtras(overrides.manifest?.extraPermissions, isOwner, overrides.extras ?? {}),
163
395
  repo: overrides.repo as Repo,
164
396
  store: memoryStore(),
165
- cipher: () => identityCipher,
397
+ cipher: (mode) =>
398
+ mode === 'private' && overrides.unlocked === false ? sealedCipher : identityCipher,
166
399
  deveye,
167
400
  transport: {
168
401
  subscribeSync: () => undefined,
@@ -170,10 +403,153 @@ export function createTestContext<Repo = undefined>(
170
403
  sendSyncChunk: () => 0,
171
404
  syncChunkBuffered: () => 0
172
405
  },
406
+ providers: fakeProviders(overrides.providers ?? {}),
173
407
  audit: (entry) => {
174
408
  recorded.audits.push({ action: entry.action, description: entry.description });
175
409
  },
176
410
  logger: silentLogger,
177
- requestId: 'test'
411
+ requestId: 'test',
412
+ origins: overrides.origins ?? {
413
+ app: 'https://deveye.test',
414
+ public: 'https://public.deveye.test'
415
+ }
416
+ };
417
+ }
418
+
419
+ export interface RecordedServiceCalls extends RecordedCalls {
420
+ /** Every `createTicker` call, so a test drives ticks by hand: `await tickers[0].tick()`. */
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[] }[];
426
+ }
427
+
428
+ export interface TestServiceDeps<Repo> extends FeatureServiceDeps<Repo> {
429
+ recorded: RecordedServiceCalls;
430
+ /** One in-memory store per workspace touched, keyed by workspace id. */
431
+ stores: Map<number, TestFeatureStore>;
432
+ }
433
+
434
+ export interface TestServiceOverrides<Repo> {
435
+ repo?: Repo;
436
+ /** What `listWorkspaceIds` answers. Default `[1]`. */
437
+ workspaceIds?: readonly number[];
438
+ /** Devices every workspace reveals. Default none. */
439
+ devices?: readonly SdkDevice[];
440
+ /** What `deveyeFor(...).notify.hasRoute` answers. Default true. */
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>>;
452
+ }
453
+
454
+ /**
455
+ * The service twin of {@link createTestContext}: sessionless, so no guarded
456
+ * cipher and no `'private'` rows, exactly like the app. Tickers never start on
457
+ * their own; the test calls `recorded.tickers[i].tick()` when it wants a beat.
458
+ */
459
+ export function createTestServiceDeps<Repo = undefined>(
460
+ overrides: TestServiceOverrides<Repo> = {}
461
+ ): TestServiceDeps<Repo> {
462
+ const recorded: RecordedServiceCalls = {
463
+ notifications: [],
464
+ liveMessages: [],
465
+ audits: [],
466
+ agentRequests: [],
467
+ pinnedInstants: [],
468
+ tickers: [],
469
+ liveChanges: [],
470
+ liveTopicChanges: []
471
+ };
472
+ const stores = new Map<number, TestFeatureStore>();
473
+ const sealedBytes = new Map<string, Uint8Array>();
474
+ const notify = recordingNotify(
475
+ recorded,
476
+ overrides.hasRoute ?? true,
477
+ overrides.notifyAccepted ?? true,
478
+ overrides.liveChannels ?? []
479
+ );
480
+ const devices = recordingDevices(overrides.devices ?? []);
481
+ return {
482
+ recorded,
483
+ stores,
484
+ repo: overrides.repo as Repo,
485
+ listWorkspaceIds: () => Promise.resolve([...(overrides.workspaceIds ?? [1])]),
486
+ storeFor(workspaceId) {
487
+ let store = stores.get(workspaceId);
488
+ if (!store) {
489
+ store = memoryStore();
490
+ stores.set(workspaceId, store);
491
+ }
492
+ return store;
493
+ },
494
+ cipherFor: () => identityCipher,
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
+ },
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
+ },
533
+ audit: (entry) => {
534
+ recorded.audits.push({ action: entry.action, description: entry.description });
535
+ },
536
+ agents: recordingAgents(recorded),
537
+ // A fake wrapper: the sealed string is a handle to the bytes, and an
538
+ // unknown handle opens to `null` exactly like a tampered blob would.
539
+ keys: {
540
+ sealBytes(plain) {
541
+ const handle = `sealed:${sealedBytes.size}`;
542
+ sealedBytes.set(handle, Uint8Array.from(plain));
543
+ return handle;
544
+ },
545
+ openBytes: (sealed) => sealedBytes.get(sealed) ?? null,
546
+ derive: fakeDerive
547
+ },
548
+ providers: fakeProviders(overrides.providers ?? {}),
549
+ createTicker({ intervalMs, tick }) {
550
+ recorded.tickers.push({ intervalMs, tick });
551
+ return { start: () => undefined, stop: () => undefined };
552
+ },
553
+ logger: silentLogger
178
554
  };
179
555
  }
@@ -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. */