@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.
- package/package.json +6 -6
- package/src/domain/device.ts +4 -29
- package/src/domain/featureRegistry.ts +40 -108
- package/src/domain/home.ts +40 -101
- package/src/domain/live.ts +36 -87
- package/src/domain/metrics.ts +10 -14
- package/src/domain/notifications.ts +39 -110
- package/src/domain/project.ts +3 -162
- package/src/domain/report.ts +46 -95
- package/src/domain/secrecy.ts +3 -5
- package/src/domain/sharing.ts +33 -70
- package/src/domain/syncProtocol.ts +5 -12
- package/src/domain/user.ts +8 -14
- package/src/domain/workspace.ts +0 -3
- package/src/domain/workspaceRole.ts +34 -82
- package/src/features/agent.ts +306 -0
- package/src/features/live.ts +17 -37
- package/src/features/notify.ts +19 -57
- package/src/features/registry.ts +7 -44
- package/src/features/secrecy.ts +4 -9
- package/src/features/sharing.ts +12 -26
- package/src/features/user.ts +6 -11
- package/src/features/workspace.ts +6 -11
- package/src/http/auth.ts +6 -13
- package/src/http/device.ts +13 -17
- package/src/http/status.ts +6 -10
- package/src/index.ts +29 -932
- package/src/protocol/agent.ts +40 -70
- package/src/protocol/envelope.ts +3 -10
- package/src/sdk/client-ambient.d.ts +228 -21
- package/src/sdk/client.ts +277 -17
- package/src/sdk/devb.ts +120 -0
- package/src/sdk/manifest.test.ts +0 -1
- package/src/sdk/manifest.ts +94 -25
- package/src/sdk/providers.ts +310 -5
- package/src/sdk/server.ts +483 -19
- package/src/sdk/testing.test.ts +2 -2
- package/src/sdk/testing.ts +302 -26
- package/src/utils/version.ts +5 -8
- package/src/domain/audience.ts +0 -549
- package/src/domain/backup.ts +0 -355
- package/src/domain/credential.ts +0 -55
- package/src/domain/database.ts +0 -467
- package/src/domain/deploy.ts +0 -231
- package/src/domain/finance.ts +0 -477
- package/src/domain/git.ts +0 -419
- package/src/domain/mail.ts +0 -394
- package/src/domain/note.ts +0 -202
- package/src/domain/password.ts +0 -36
- package/src/domain/projectBoard.ts +0 -130
- package/src/domain/projectChat.ts +0 -46
- package/src/domain/projectHistory.ts +0 -82
- package/src/domain/projectLink.ts +0 -87
- package/src/domain/projectPlan.ts +0 -68
- package/src/domain/sentinel.ts +0 -623
- package/src/domain/uptime.ts +0 -216
- package/src/features/audience.ts +0 -275
- package/src/features/backup.ts +0 -230
- package/src/features/database.ts +0 -461
- package/src/features/deploy.ts +0 -245
- package/src/features/device.ts +0 -292
- package/src/features/deviceFiles.ts +0 -83
- package/src/features/deviceLogs.ts +0 -36
- package/src/features/deviceTerminal.ts +0 -57
- package/src/features/finance.ts +0 -360
- package/src/features/git.ts +0 -368
- package/src/features/mail.ts +0 -374
- package/src/features/metrics.ts +0 -185
- package/src/features/note.ts +0 -189
- package/src/features/password.ts +0 -67
- package/src/features/project.ts +0 -709
- package/src/features/sentinel.ts +0 -233
- 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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
175
|
-
*
|
|
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
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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
|
|
293
|
-
*
|
|
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
|
-
|
|
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';
|
package/src/sdk/testing.test.ts
CHANGED
|
@@ -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');
|