@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
package/src/sdk/server.ts CHANGED
@@ -2,6 +2,11 @@ import type { z, ZodType } from 'zod';
2
2
  import type { ErrorCode } from '../protocol/error';
3
3
  import type { FeatureAccess } from '../domain/workspaceRole';
4
4
  import type { LogLevelName } from '../domain/logs';
5
+ import type { ItemAccess } from '../domain/sharing';
6
+ import type { AuthWindow, DeviceReport, IntegrityReport, ReportProcess } from '../domain/report';
7
+ import type { AgentManifest } from '../http/device';
8
+ import type { MetricSnapshot } from '../domain/metrics';
9
+ import type { UserColor } from '../domain/user';
5
10
  import type {
6
11
  AgentSyncAckPayload,
7
12
  AgentSyncApplyChunkPayload,
@@ -17,6 +22,8 @@ import type {
17
22
  AgentSyncOpResultPayload,
18
23
  AgentSyncPushPayload,
19
24
  AgentSyncScanPayload,
25
+ AgentFilesMutatePayload,
26
+ AgentFilesUploadPayload,
20
27
  CloudSyncChunkPush,
21
28
  CloudSyncProgressPush,
22
29
  CloudSyncStatePush
@@ -133,6 +140,54 @@ export interface SdkQueryable {
133
140
  execute(sql: string, params?: unknown[]): Promise<{ affectedRows: number; insertId: number }>;
134
141
  }
135
142
 
143
+ /**
144
+ * An alert as the notify facade delivers it. `subject` and `body` are what every
145
+ * channel receives (mail, Slack, a custom webhook); `payload` adds structured
146
+ * fields for a custom endpoint; `embeds` is the optional Discord layout, used
147
+ * only on a Discord channel, where it replaces the plain text (see the app's
148
+ * `Services/notifications.ts`). Keep the text complete on its own: a channel
149
+ * that knows no embeds must lose nothing.
150
+ */
151
+ export interface SdkAlert {
152
+ subject: string;
153
+ body: string;
154
+ payload?: Record<string, unknown>;
155
+ /** Discord embed objects, as the Discord webhook API takes them. */
156
+ embeds?: readonly Record<string, unknown>[];
157
+ }
158
+
159
+ /** A routed channel that can carry a live message (see `notify.liveChannels`). */
160
+ export interface SdkLiveChannel {
161
+ id: number;
162
+ }
163
+
164
+ /** A rich message for a live channel: what the Discord webhook API takes. */
165
+ export interface SdkRichMessage {
166
+ content?: string;
167
+ embeds?: readonly Record<string, unknown>[];
168
+ }
169
+
170
+ /**
171
+ * One process-list instant next to its metric row: what a security engine
172
+ * needs to judge a device at a given timestamp. Capability `'telemetry.read'`.
173
+ */
174
+ export interface SdkTelemetrySnapshot {
175
+ ts: number;
176
+ processes: ReportProcess[];
177
+ activeConnections: number | null;
178
+ }
179
+
180
+ /**
181
+ * Read access to the devices' telemetry (capability `'telemetry.read'`,
182
+ * reserved to native-id modules: the metric store is app infrastructure).
183
+ */
184
+ export interface SdkTelemetry {
185
+ /** The instant nearest `ts` (process list plus the metric row), or null when nothing was recorded around it. */
186
+ snapshot(deviceId: string, ts: number): Promise<SdkTelemetrySnapshot | null>;
187
+ /** Pins the instant at `ts` so retention never prunes the evidence a finding rests on. */
188
+ pinInstant(deviceId: string, ts: number): Promise<void>;
189
+ }
190
+
136
191
  /**
137
192
  * Native features, reachable only if declared in `manifest.nativeCapabilities`.
138
193
  * An undeclared call throws `forbidden`.
@@ -142,11 +197,33 @@ export interface DevEyeFacade {
142
197
  notify: {
143
198
  /** Is at least one usable channel routed to this target? */
144
199
  hasRoute(itemId?: number): Promise<boolean>;
145
- /** Delivers to the configured channels. Resolves `true` if at least one accepted. */
200
+ /**
201
+ * Delivers to the configured channels. Resolves `true` if at least one
202
+ * accepted. `except`: channel ids to skip, those a live message
203
+ * (`postLive`) already concluded on, so a channel never hears the
204
+ * same news twice.
205
+ */
146
206
  send(
147
- alert: { subject: string; body: string; payload?: Record<string, unknown> },
148
- opts?: { itemId?: number }
207
+ alert: SdkAlert,
208
+ opts?: { itemId?: number; except?: readonly number[] }
149
209
  ): Promise<boolean>;
210
+ /**
211
+ * The routed channels able to carry a LIVE message: a rich message
212
+ * posted once and edited until it concludes (Discord webhooks today).
213
+ * Empty when none is routed to this target. Same routing as `send`.
214
+ */
215
+ liveChannels(opts?: { itemId?: number }): Promise<readonly SdkLiveChannel[]>;
216
+ /**
217
+ * Posts a rich message on one live channel of YOUR feature, or edits
218
+ * it when `messageId` is given. Resolves the message id to keep for
219
+ * the next edit, `null` when the channel refused (a message deleted
220
+ * by hand, a revoked webhook): stop there, never repost.
221
+ */
222
+ postLive(
223
+ channelId: number,
224
+ message: SdkRichMessage,
225
+ messageId?: string | null
226
+ ): Promise<string | null>;
150
227
  };
151
228
  /** Requires capability `'mail.accounts'`. Open-tier accounts, metadata only, never credentials. */
152
229
  mail: {
@@ -154,30 +231,98 @@ export interface DevEyeFacade {
154
231
  ReadonlyArray<{ id: number; label: string; address: string | null }>
155
232
  >;
156
233
  };
234
+ /**
235
+ * Requires capability `'workspaces.read'` AND a global administrator as
236
+ * caller (`forbidden` otherwise): every workspace of this DevEye.
237
+ */
238
+ workspaces: {
239
+ list(): Promise<readonly SdkWorkspaceSummary[]>;
240
+ };
157
241
  /** Requires capability `'members.read'`. */
158
242
  members: {
159
- list(): Promise<ReadonlyArray<{ userId: number; name: string; isOwner: boolean }>>;
243
+ /**
244
+ * The workspace's members, owner included. `color` is the account's
245
+ * colour, the one its live presence wears everywhere; null for an
246
+ * account never coloured (fall back to `defaultUserColor(userId)`,
247
+ * exactly like the app does).
248
+ */
249
+ list(): Promise<
250
+ ReadonlyArray<{
251
+ userId: number;
252
+ name: string;
253
+ isOwner: boolean;
254
+ color: UserColor | null;
255
+ }>
256
+ >;
160
257
  };
161
258
  /** Requires capability `'devices.read'`. */
162
259
  devices: {
163
- /** Throws `not_found` unless the device exists AND belongs to this workspace. */
260
+ /**
261
+ * Throws `not_found` unless the device exists AND belongs to this
262
+ * workspace (a global administrator passes the membership check).
263
+ */
164
264
  authorize(deviceId: string): Promise<SdkDevice>;
265
+ /**
266
+ * The devices this workspace sees: its own, or the whole fleet for a
267
+ * global administrator in their PERSONAL workspace (the app's own rule
268
+ * for its device list: that is where an admin watches their machines).
269
+ */
165
270
  list(): Promise<readonly SdkDevice[]>;
166
271
  isOnline(deviceId: string): boolean;
167
272
  };
273
+ /** Requires capability `'telemetry.read'` (native-id modules only). */
274
+ telemetry: SdkTelemetry;
168
275
  /** Requires capability `'agents'`. Same object as the service deps' `agents`. */
169
276
  agents: AgentsFacade;
170
277
  }
171
278
 
279
+ /** A workspace as the fleet sees it: enough to attach a device to it. */
280
+ export interface SdkWorkspaceSummary {
281
+ id: number;
282
+ name: string;
283
+ kind: 'personal' | 'shared';
284
+ ownerUserId: number;
285
+ }
286
+
172
287
  /**
173
288
  * The agent-fleet sync transport (capability `'agents'`, native-id modules
174
- * only). Method names and semantics mirror the app's MonitorHub exactly, so a
175
- * repatriated engine swaps its hub handle for this facade and changes nothing
176
- * else. Outbound calls return `false` when the agent is offline (frame
177
- * dropped, never queued).
289
+ * only). Method names mirror the app's MonitorHub. Outbound calls return
290
+ * `false` when the agent is offline (frame dropped, never queued).
178
291
  */
179
292
  export interface AgentsFacade {
180
293
  isOnline(deviceId: string): boolean;
294
+ /**
295
+ * The three orders a device's lifecycle gives the hub. `requestDestroy`
296
+ * asks the agent to uninstall itself (deletion path); `disconnectAgent`
297
+ * closes its socket now (a revoked or force-deleted device);
298
+ * `resetAgentSession` drops what the hub remembered of it (a device
299
+ * confirmed or reactivated starts clean). All three answer `false` when
300
+ * the agent is not connected, which is not an error.
301
+ */
302
+ requestDestroy(deviceId: string): boolean;
303
+ disconnectAgent(deviceId: string): boolean;
304
+ resetAgentSession(deviceId: string): boolean;
305
+ /**
306
+ * The manifest of the agent binaries the app serves (the build version and,
307
+ * per target, whether the binary is signed), or null while nothing is
308
+ * synced. What a fleet screen needs to flag an agent able to self-update
309
+ * (`agent.update`); the binaries and their distribution stay the app's.
310
+ */
311
+ servedManifest(): Promise<AgentManifest | null>;
312
+ /**
313
+ * Asks the agent for an immediate security scan (persistence manifest and
314
+ * authentication window). Distinct from the metric refresh on purpose: a
315
+ * scan fingerprints hundreds of files.
316
+ */
317
+ requestScan(deviceId: string): boolean;
318
+ /**
319
+ * Pushes the device's collection config to its agent, recomposed by the
320
+ * app from the device row and the installed modules' contributions (see
321
+ * `SENTINEL_AGENT_CONFIG_PROVIDER`). Call it after changing what your
322
+ * module contributes to that config. Resolves `false` when the agent is
323
+ * offline (it receives the config at its next connection anyway).
324
+ */
325
+ pushConfig(deviceId: string): Promise<boolean>;
181
326
  requestSyncConfig(deviceId: string, payload: AgentSyncConfigPayload): boolean;
182
327
  requestSyncScan(deviceId: string, payload: AgentSyncScanPayload): boolean;
183
328
  requestSyncPush(deviceId: string, payload: AgentSyncPushPayload): boolean;
@@ -190,6 +335,27 @@ export interface AgentsFacade {
190
335
  /** Fan-out to the browsers subscribed to the payload's share. */
191
336
  publishSyncProgress(payload: CloudSyncProgressPush): void;
192
337
  publishSyncState(payload: CloudSyncStatePush): void;
338
+ /**
339
+ * File orders on a device, the ones the file explorer already speaks
340
+ * (`files.mutate`: mkdir, rename, delete; `files.upload`: a chunk at an
341
+ * offset, `done` on the last one). What makes an enrolled machine a
342
+ * backup target without changing the agent. The agent answers ONE
343
+ * `files.op` frame per `opId`: arm `awaitFilesOp` before sending, and
344
+ * for an upload before the FIRST chunk (the answer comes with the last
345
+ * one, or with the first write failure).
346
+ */
347
+ requestFilesMutate(deviceId: string, payload: AgentFilesMutatePayload): boolean;
348
+ requestFilesUpload(deviceId: string, payload: AgentFilesUploadPayload): boolean;
349
+ awaitFilesOp(opId: string, timeoutMs: number): Promise<{ ok: boolean; error?: string }>;
350
+ /** Forgets a pending `awaitFilesOp` (the frame was never sent, or the caller gave up). */
351
+ cancelFilesOp(opId: string): void;
352
+ /**
353
+ * Bytes queued on the agent's socket, not yet on the wire. A sender that
354
+ * streams towards an agent must watch it: the socket accepts everything,
355
+ * and without backpressure the server's memory follows the size of what
356
+ * is sent.
357
+ */
358
+ buffered(deviceId: string): number;
193
359
  }
194
360
 
195
361
  /**
@@ -206,14 +372,23 @@ export interface SdkSocketTransport {
206
372
  }
207
373
 
208
374
  /**
209
- * Inbound agent events, dispatched by the app's agent socket layer to the
210
- * modules that declare `'agents'`. Every hook is optional; an absent hook is a
211
- * no-op. Hooks may fire before your service's `start()` has completed: drop
212
- * quietly in that case, the agent will resend or reconcile.
375
+ * Inbound agent events, dispatched to the modules that declare `'agents'`.
376
+ * Every hook is optional. Hooks may fire before your service's `start()` has
377
+ * completed: drop quietly, the agent will resend or reconcile. Telemetry hooks
378
+ * fire once the app has persisted it, and only for ACTIVE devices; whether the
379
+ * device is watched by YOUR feature is your decision.
213
380
  */
214
381
  export interface FeatureAgentHooks {
215
382
  onAgentConnect?(deviceId: string): void | Promise<void>;
216
383
  onAgentOffline?(deviceId: string): void;
384
+ /** The OS/security report (`agent.report`), just written to the device row. */
385
+ onReport?(deviceId: string, report: DeviceReport): void | Promise<void>;
386
+ /** A batch of metric instants, oldest first, just written to the metric store. */
387
+ onMetricsBatch?(deviceId: string, snapshots: readonly MetricSnapshot[]): void | Promise<void>;
388
+ /** The persistence manifest (`agent.integrity`). Never stored by the app: it only exists here. */
389
+ onIntegrity?(deviceId: string, integrity: IntegrityReport): void | Promise<void>;
390
+ /** An authentication window (`agent.authEvents`). Never stored by the app either. */
391
+ onAuthEvents?(deviceId: string, auth: AuthWindow): void | Promise<void>;
217
392
  /** `deviceId` est l'identite AUTHENTIFIEE du socket ; le payload en porte une copie non fiable. */
218
393
  onSyncChanged?(deviceId: string, payload: AgentSyncChangedPayload): void;
219
394
  onSyncIndex?(deviceId: string, payload: AgentSyncIndexPayload): void;
@@ -222,6 +397,17 @@ export interface FeatureAgentHooks {
222
397
  onSyncOpResult?(deviceId: string, payload: AgentSyncOpResultPayload): void;
223
398
  }
224
399
 
400
+ /**
401
+ * The named contracts the host holds (`sdk/providers.ts`): what the installed
402
+ * modules offer on their services. Looked up at call time, `undefined` when
403
+ * nobody offers the key; the caller degrades cleanly. A reader cannot tell who
404
+ * offers a key, on purpose: a contract can change hands without anything
405
+ * changing here.
406
+ */
407
+ export interface SdkProviders {
408
+ get<T>(key: string): T | undefined;
409
+ }
410
+
225
411
  /**
226
412
  * Raw bytes under the SERVER key (the `Encryption.encryptWithKey` wire format,
227
413
  * byte-compatible with what native code wrote). For wrapping module-owned key
@@ -231,6 +417,14 @@ export interface SdkServerKeys {
231
417
  sealBytes(plain: Uint8Array): string;
232
418
  /** null when the sealed blob cannot be opened (tampered, or server keys changed). */
233
419
  openBytes(sealed: string): Uint8Array | null;
420
+ /**
421
+ * A key DERIVED from the server key (HKDF-SHA256 over the same material
422
+ * as `sealBytes`), never stored anywhere. For material that must survive
423
+ * the database: a key kept in a table would sit inside the very backup it
424
+ * protects. The same (salt, info) always yields the same key, as long as
425
+ * `CRYPT_KEY_A`/`CRYPT_KEY_B` do not change.
426
+ */
427
+ derive(salt: string, info: string, length: number): Uint8Array;
234
428
  }
235
429
 
236
430
  /** A workspace device, as the devices facade reveals it. */
@@ -238,6 +432,113 @@ export interface SdkDevice {
238
432
  id: string;
239
433
  name: string;
240
434
  online: boolean;
435
+ /** `active` is the only status whose telemetry the app records. */
436
+ status: string;
437
+ /** The account that enrolled the device (the actor of its audit lines). */
438
+ ownerUserId: number;
439
+ /** The workspace the device was paired in, or null once that workspace is gone. */
440
+ workspaceId: number | null;
441
+ /** The agent's metric cadence in seconds, null when it follows the default. */
442
+ metricIntervalSeconds: number | null;
443
+ /** The last OS/security report, null before the first one (or unreadable). */
444
+ report: DeviceReport | null;
445
+ }
446
+
447
+ /** The session's password-encryption lock, seen from a handler. */
448
+ export interface SdkSecrecy {
449
+ /**
450
+ * True when the guarded tier is readable in this session: password-based
451
+ * encryption is off for the account, or the session was unlocked. A
452
+ * `'private'` read throws `locked` on its own; ask here when you need to
453
+ * decide BEFORE reading (list rows as masked, refuse an edit that would
454
+ * overwrite a body the session cannot see).
455
+ */
456
+ isUnlocked(): Promise<boolean>;
457
+ /**
458
+ * A short-lived ticket, signed by the host and bound to the caller (their
459
+ * session, this workspace, YOUR module), carrying `payload`. Hand it to
460
+ * the browser (a download URL, an OAuth `state`); a public route of your
461
+ * service redeems it (`deps.secrecy.redeem`) into the caller's ciphers,
462
+ * private tier included while the session is unlocked. The module never
463
+ * sees a session id nor a key. Default life: two minutes.
464
+ */
465
+ ticket(payload: unknown, opts?: { ttlSeconds?: number }): Promise<string>;
466
+ }
467
+
468
+ /**
469
+ * Your feature's items, as the workspace's roles see them. Items are the rows
470
+ * a module declares with `hasItems`; both members answer for THIS feature.
471
+ */
472
+ export interface SdkItems {
473
+ /**
474
+ * The items the caller's role sees differently from the others: `'none'`
475
+ * hidden, `'read'` read-only. Restrictive only: it can lower what the
476
+ * feature grants, never raise it. Empty for the owner and for a member
477
+ * without a role. Listings filter with it.
478
+ */
479
+ restrictions(): Promise<ReadonlyMap<number, ItemAccess>>;
480
+ /**
481
+ * Throws `forbidden` unless THIS item is open to the caller at `level`
482
+ * (default `'read'`), role restriction included. Commands that target one
483
+ * item call it first.
484
+ */
485
+ assert(itemId: number, level?: FeatureAccess): Promise<void>;
486
+ /**
487
+ * The item no longer exists: drops its projections, its role restrictions
488
+ * and its notification route. Call it from your delete handler; nothing
489
+ * links those rows to your table (the item lives in a different table per
490
+ * feature), so without this call the next item to inherit the id would
491
+ * inherit them too.
492
+ */
493
+ forget(itemId: number): Promise<void>;
494
+ }
495
+
496
+ /**
497
+ * What is projected INTO the active workspace, for one listing or read.
498
+ *
499
+ * A shared item keeps a single home: it stays encrypted under its home
500
+ * workspace's key, and is read elsewhere with that workspace's OPEN cipher.
501
+ * `cipherFor` is the only path to it, and it only answers for items whose
502
+ * projection really exists: an invented id yields the active workspace's own
503
+ * cipher, never a foreign one.
504
+ */
505
+ export interface SdkShareScope {
506
+ /** The ids of the items projected into the active workspace from elsewhere. */
507
+ readonly foreignIds: ReadonlySet<number>;
508
+ /** The home workspace of a projected item, or null when it is at home. */
509
+ homeOf(itemId: number): number | null;
510
+ /** The open cipher of the workspace the item lives in (the active one when it is at home). */
511
+ cipherFor(itemId: number): Promise<SdkCipher>;
512
+ }
513
+
514
+ /** Cross-workspace projection of your items (manifest `shareTier` other than `'never'`). */
515
+ export interface SdkSharing {
516
+ /**
517
+ * Loads what is projected into the active workspace. Once per listing or
518
+ * read command; the result does not outlive the command.
519
+ */
520
+ scope(): Promise<SdkShareScope>;
521
+ }
522
+
523
+ /** Live invalidation from a background service, which writes without a command. */
524
+ export interface SdkLive {
525
+ /**
526
+ * Something of YOUR feature changed in this workspace: every member's
527
+ * client re-fetches your declared resources (and, for a share-wired
528
+ * feature, so do the workspaces linked by projections). Call it on state
529
+ * transitions, never on every tick: each call re-fetches for everyone.
530
+ * `topics` beats those instead of your id: your own secondary topics
531
+ * (`manifest.topics`), or another feature's topic whose screens mirror
532
+ * this data.
533
+ */
534
+ changed(workspaceId: number, topics?: readonly string[]): void;
535
+ }
536
+
537
+ /** The whole fleet, sessionless (capability `'devices.read'`), for services. */
538
+ export interface SdkFleetDevices {
539
+ /** One device by id, whatever its workspace, or null. */
540
+ find(deviceId: string): Promise<SdkDevice | null>;
541
+ isOnline(deviceId: string): boolean;
241
542
  }
242
543
 
243
544
  /** What a handler receives. One request, one workspace, rights pre-resolved. */
@@ -246,6 +547,12 @@ export interface SdkFeatureContext<Repo = unknown> {
246
547
  workspaceId: number;
247
548
  workspace: { id: number; kind: 'personal' | 'shared'; name: string };
248
549
  isOwner: boolean;
550
+ /**
551
+ * The caller is a global administrator of this DevEye. What a fleet-wide
552
+ * view keys on (the admin in their personal workspace sees every device);
553
+ * a command that must REQUIRE it declares `access: { admin: true }`.
554
+ */
555
+ isAdmin: boolean;
249
556
  /** Caller's level on THIS feature. `read` is already guaranteed by the dispatcher. */
250
557
  canWrite: boolean;
251
558
  /** Extra permission of type `toggle`. Absent from the grant = false; owner = true. */
@@ -262,6 +569,14 @@ export interface SdkFeatureContext<Repo = unknown> {
262
569
  deveye: DevEyeFacade;
263
570
  /** The caller's socket (capability `'agents'`); every method throws `forbidden` otherwise. */
264
571
  transport: SdkSocketTransport;
572
+ /** The session's password-encryption lock. */
573
+ secrecy: SdkSecrecy;
574
+ /** Your items as the roles see them (restrictions), and their removal bookkeeping. */
575
+ items: SdkItems;
576
+ /** Projections into the active workspace. Throws `forbidden` when the manifest says `shareTier: 'never'`. */
577
+ sharing: SdkSharing;
578
+ /** The named contracts the host holds, see `SdkProviders`. */
579
+ providers: SdkProviders;
265
580
  /** Fire-and-forget audit line; actor, IP and workspace are pre-bound. */
266
581
  audit(entry: {
267
582
  action: string;
@@ -271,6 +586,16 @@ export interface SdkFeatureContext<Repo = unknown> {
271
586
  }): void;
272
587
  logger: SdkLogger;
273
588
  requestId: string;
589
+ /** The server key derivations, the same `keys` a service gets. */
590
+ keys: SdkServerKeys;
591
+ /**
592
+ * Where DevEye lives, as URLs without a trailing slash: `app` is the
593
+ * origin members use (`PUBLIC_ORIGIN`), `public` the one reachable
594
+ * without the VPN when the host has a public surface (else the same).
595
+ * For what a module hands to the outside world (an install snippet, a
596
+ * callback URL): never derive it from the browser's location.
597
+ */
598
+ origins: { app: string; public: string };
274
599
  }
275
600
 
276
601
  /**
@@ -289,12 +614,21 @@ export interface SdkFeatureDefinition<
289
614
  input: I;
290
615
  output: O;
291
616
  /**
292
- * `level` defaults to `'read'`; `extras` are ALL required. Your feature id
293
- * is implied: you cannot gate on another feature's rights.
617
+ * `level`: the caller's level on your feature (`read` by default).
618
+ * `extras`: the extra permissions the caller must hold, ALL required.
619
+ * `admin`: the caller must be a global administrator; the feature check
620
+ * still applies. Your feature id is implied: you cannot gate on another
621
+ * feature's rights.
622
+ */
623
+ access?: { level?: FeatureAccess; extras?: readonly string[]; admin?: boolean };
624
+ /**
625
+ * This command changes data other members can see. `true` beats your
626
+ * feature's own live topic (its id); a list names the topics to beat
627
+ * instead: your id, one of your `manifest.topics`, or another feature's
628
+ * topic (native or module) whose screens mirror this data (Projects when a
629
+ * linked item goes away). An unknown topic is refused at boot.
294
630
  */
295
- access?: { level?: FeatureAccess; extras?: readonly string[] };
296
- /** This command changes data other members can see. */
297
- mutates?: boolean;
631
+ mutates?: boolean | readonly string[];
298
632
  handler(ctx: SdkFeatureContext<Repo>, input: z.output<I>): Promise<z.input<O>>;
299
633
  }
300
634
 
@@ -305,6 +639,61 @@ export function defineSdkFeature<Repo, Cmd extends string, I extends ZodType, O
305
639
  return def;
306
640
  }
307
641
 
642
+ /** The request a public route sees: headers, decoded body, client address. Nothing of a session. */
643
+ export interface SdkPublicRequest {
644
+ headers: Readonly<Record<string, string | string[] | undefined>>;
645
+ /** The JSON body, already decoded (`undefined` when absent or unreadable). */
646
+ body: unknown;
647
+ /**
648
+ * The query string, decoded by the host into an object (`?a=1&b=2` reads
649
+ * `{ a: '1', b: '2' }`). `unknown` like `body`: read it through a schema.
650
+ * What a ticketed GET (a download URL, an OAuth callback) carries.
651
+ */
652
+ query?: unknown;
653
+ ip: string;
654
+ }
655
+
656
+ /** The reply of a public route, the minimal chainable surface the host maps onto its HTTP server. */
657
+ export interface SdkPublicReply {
658
+ header(name: string, value: string): SdkPublicReply;
659
+ code(status: number): SdkPublicReply;
660
+ send(payload?: unknown): unknown;
661
+ }
662
+
663
+ export interface SdkPublicRouteOptions {
664
+ /** A ceiling per client address, on top of the host's own: `{ max, timeWindow: '1 minute' }`. */
665
+ rateLimit?: { max: number; timeWindow: string };
666
+ /**
667
+ * Which listeners serve the route. `'everywhere'` (default): the app and
668
+ * the public surface, for what the outside world calls (a beacon).
669
+ * `'app'`: the app's own origin only, for what the logged-in browser
670
+ * fetches without a session header (a ticketed download, an OAuth
671
+ * callback that lands back in the app).
672
+ */
673
+ exposure?: 'everywhere' | 'app';
674
+ }
675
+
676
+ /** What a redeemed ticket gives a public route back (see `SdkSecrecy.ticket`). */
677
+ export interface SdkRedeemedTicket {
678
+ userId: number;
679
+ workspaceId: number;
680
+ payload: unknown;
681
+ /** The caller's ciphers: the open tier always, the private tier while their session is unlocked. */
682
+ cipher: { server: SdkCipher; private: SdkCipher | null };
683
+ }
684
+
685
+ export type SdkPublicHandler = (req: SdkPublicRequest, reply: SdkPublicReply) => Promise<unknown>;
686
+
687
+ /**
688
+ * Where a module declares its public routes (capability `'routes.public'`).
689
+ * Paths are absolute (`/t.js`, `/api/t/b`); a path the host already serves
690
+ * is refused at boot. Every route is registered on every public listener.
691
+ */
692
+ export interface SdkPublicApp {
693
+ get(path: string, opts: SdkPublicRouteOptions, handler: SdkPublicHandler): void;
694
+ post(path: string, opts: SdkPublicRouteOptions, handler: SdkPublicHandler): void;
695
+ }
696
+
308
697
  /**
309
698
  * A background worker. Started during boot (awaited, before the agent socket
310
699
  * layer registers), stopped on shutdown.
@@ -320,6 +709,12 @@ export interface FeatureService {
320
709
  * provider up at call time and degrades cleanly when the module is absent.
321
710
  */
322
711
  providers?: Readonly<Record<string, unknown>>;
712
+ /**
713
+ * Your public HTTP routes, declared once at boot (capability
714
+ * `'routes.public'`). Called by the host for each listener it exposes to
715
+ * the outside; register the same routes each time.
716
+ */
717
+ publicRoutes?(app: SdkPublicApp): void;
323
718
  }
324
719
 
325
720
  /**
@@ -337,6 +732,12 @@ export interface FeatureServiceDeps<Repo = unknown> {
337
732
  deveyeFor(workspaceId: number): Pick<DevEyeFacade, 'notify'>;
338
733
  /** Devices of one workspace, sessionless (capability `'devices.read'`). */
339
734
  devicesFor(workspaceId: number): Pick<DevEyeFacade['devices'], 'list' | 'isOnline'>;
735
+ /** The whole fleet by id, sessionless (capability `'devices.read'`). */
736
+ devices: SdkFleetDevices;
737
+ /** The devices' telemetry, sessionless (capability `'telemetry.read'`). */
738
+ telemetry: SdkTelemetry;
739
+ /** Live invalidation of your feature's resources, from a service. */
740
+ live: SdkLive;
340
741
  /**
341
742
  * Audit line recorded as the SYSTEM (no session). `userId` attributes the
342
743
  * line to a user when the work concerns their data.
@@ -350,8 +751,14 @@ export interface FeatureServiceDeps<Repo = unknown> {
350
751
  }): void;
351
752
  /** The agent-fleet transport (capability `'agents'`). */
352
753
  agents: AgentsFacade;
353
- /** Raw key wrapping under the server key. */
754
+ /** Raw key wrapping under the server key, and derived keys. */
354
755
  keys: SdkServerKeys;
756
+ /** Redeems a ticket minted by `ctx.secrecy.ticket` of THIS module; `null` when invalid, expired or another module's. */
757
+ secrecy: { redeem(ticket: string): Promise<SdkRedeemedTicket | null> };
758
+ /** Where DevEye lives (the same `origins` a request context gets): for a page or a link a route hands to the browser. */
759
+ origins: { app: string; public: string };
760
+ /** The named contracts the host holds, see `SdkProviders`. */
761
+ providers: SdkProviders;
355
762
  /**
356
763
  * The app's standard loop: setInterval + reentrancy guard + unref, the
357
764
  * exact pattern of every native service. Use it instead of rolling your own.
@@ -375,4 +782,61 @@ export interface FeatureServer<Repo = unknown> {
375
782
  */
376
783
  migrationsDir?: string;
377
784
  createService?(deps: FeatureServiceDeps<Repo>): FeatureService;
785
+ /**
786
+ * What the app needs to know about your items without opening your
787
+ * feature: required for a `shareTier` other than `'never'` (the sharing
788
+ * commands must find an item's home), useful to any `hasItems` feature that
789
+ * notifies (the channels screen names the item a route points to).
790
+ */
791
+ items?: FeatureItemsEntry<Repo>;
792
+ }
793
+
794
+ /**
795
+ * Your items, seen from the app's transversal commands (sharing, notification
796
+ * routes). Both calls are sessionless as far as you are concerned: the repo is
797
+ * yours, the cipher is the OPEN cipher of the calling workspace.
798
+ */
799
+ export interface FeatureItemsEntry<Repo = unknown> {
800
+ /**
801
+ * The workspace an item lives in, when it is visible from `workspaceId`
802
+ * (its own, or one it is projected into); null when it does not exist
803
+ * there. The sharing commands rely on it to tell a home from a window.
804
+ */
805
+ homeOf(repo: Repo, itemId: number, workspaceId: number): Promise<number | null>;
806
+ /**
807
+ * The item's display name, decrypted with `cipher` (the open cipher of
808
+ * `workspaceId`), or null when the item is gone or unreadable. Names the
809
+ * target a notification route points to; null reads as "a target that
810
+ * disappeared", which is exactly what the screen must show then.
811
+ */
812
+ labelOf(
813
+ repo: Repo,
814
+ cipher: SdkCipher,
815
+ itemId: number,
816
+ workspaceId: number
817
+ ): Promise<string | null>;
818
+ /**
819
+ * May this item be projected into another workspace? Omit when every item
820
+ * of yours can (an open-tier feature). A `'perItem'` feature answers
821
+ * `false` for an item encrypted under the caller's password (its guarded
822
+ * tier): no other workspace could read it, so the share is refused at the
823
+ * moment it is asked, with a message that says why. Called with the item's
824
+ * HOME workspace.
825
+ */
826
+ shareable?(repo: Repo, itemId: number, workspaceId: number): Promise<boolean>;
378
827
  }
828
+
829
+ // Le conteneur chiffré que CloudSync et Backup partagent (voir `devb.ts`).
830
+ export {
831
+ BLOB_CHUNK_BYTES,
832
+ BLOB_CHUNK_SEALED,
833
+ BLOB_HEADER_LEN,
834
+ BLOB_TAG_LEN,
835
+ BLOB_VERSION_CHUNKED,
836
+ BLOB_VERSION_STREAM,
837
+ createBlobHeader,
838
+ openChunk,
839
+ openStreamDecipher,
840
+ parseBlobHeader,
841
+ sealChunk
842
+ } from './devb';
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import { test } from 'node:test';
3
3
  import { z } from 'zod';
4
4
 
5
- import { createTestContext, createTestServiceDeps } from './testing';
5
+ import { createTestContext, createTestServiceDeps, testDevice } from './testing';
6
6
 
7
7
  test('createTestContext follows the manifest for extras, and records what handlers do', async () => {
8
8
  const manifest = {
@@ -36,7 +36,7 @@ test('createTestContext follows the manifest for extras, and records what handle
36
36
  test('createTestServiceDeps: one store per workspace, hand-driven tickers, a wrapper that round-trips', async () => {
37
37
  const deps = createTestServiceDeps({
38
38
  workspaceIds: [1, 2],
39
- devices: [{ id: 'd1', name: 'One', online: false }]
39
+ devices: [testDevice({ id: 'd1', name: 'One', online: false })]
40
40
  });
41
41
  assert.deepEqual(await deps.listWorkspaceIds(), [1, 2]);
42
42
  await deps.storeFor(1).put('a', '1');