@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/client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ComponentType } from 'react';
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Client-side SDK contracts: what your package's `./client` entry exports and
|
|
@@ -10,12 +10,17 @@ import type { ComponentType } from 'react';
|
|
|
10
10
|
* shapes both sides must agree on.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The scope a settings panel is opened for. An item id is a number for every
|
|
15
|
+
* row-keyed feature (the default), a string for a device (a UUID): a module
|
|
16
|
+
* whose items are strings types its panels `SettingsPanelProps<string>`. The
|
|
17
|
+
* shell's own sections (sharing, permissions, notifications) key on the number.
|
|
18
|
+
*/
|
|
19
|
+
export type SdkSettingsScope<Id extends number | string = number> =
|
|
20
|
+
{ kind: 'feature' } | { kind: 'item'; itemId: Id; itemLabel: string };
|
|
16
21
|
|
|
17
|
-
export interface SettingsPanelProps {
|
|
18
|
-
scope: SdkSettingsScope
|
|
22
|
+
export interface SettingsPanelProps<Id extends number | string = number> {
|
|
23
|
+
scope: SdkSettingsScope<Id>;
|
|
19
24
|
/** Caller has `write` on the feature. Render read-only when false. */
|
|
20
25
|
canWrite: boolean;
|
|
21
26
|
}
|
|
@@ -39,19 +44,18 @@ export interface FeatureClient {
|
|
|
39
44
|
Full: ComponentType<FeatureViewProps>;
|
|
40
45
|
/**
|
|
41
46
|
* Panels for the manifest's settings tabs that need one: `'general'`,
|
|
42
|
-
* `'sources'`, and any custom tab id. Generic tabs
|
|
43
|
-
* `'permissions'`) need no panel.
|
|
47
|
+
* `'sources'`, `'encryption'`, and any custom tab id. Generic tabs
|
|
48
|
+
* (`'notifications'`, `'permissions'`) need no panel.
|
|
44
49
|
*/
|
|
45
|
-
|
|
50
|
+
// `never` as the id: a panel typed for numbers and one typed for strings
|
|
51
|
+
// are both assignable here (props are contravariant), and the shell, which
|
|
52
|
+
// holds a `number | string`, casts once at that boundary.
|
|
53
|
+
settingsPanels?: Readonly<Record<string, ComponentType<SettingsPanelProps<never>>>>;
|
|
46
54
|
/**
|
|
47
|
-
* The compact topbar widget declared by `manifest.topbarWidget`.
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* host
|
|
51
|
-
* state); everything it shows must come through YOUR feature's commands
|
|
52
|
-
* (`featureApi`), which the server authorizes against the caller's grants
|
|
53
|
-
* like any other call. The host also only mounts it for members whose role
|
|
54
|
-
* grants your feature. Freedom inside the box, nothing outside it.
|
|
55
|
+
* The compact topbar widget declared by `manifest.topbarWidget`. Rendered
|
|
56
|
+
* with NO props: everything it shows must come through YOUR feature's
|
|
57
|
+
* commands, which the server authorizes against the caller's grants. The
|
|
58
|
+
* host mounts it only for members whose role grants your feature.
|
|
55
59
|
*/
|
|
56
60
|
TopbarWidget?: ComponentType;
|
|
57
61
|
/**
|
|
@@ -63,4 +67,260 @@ export interface FeatureClient {
|
|
|
63
67
|
preload?: boolean;
|
|
64
68
|
/** Hold the password-encryption unlock alive while the full view is open. */
|
|
65
69
|
holdSecrecy?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Named contracts offered to the host's screens (see `sdk/providers.ts`),
|
|
72
|
+
* the client twin of `FeatureService.providers`. The app looks a provider
|
|
73
|
+
* up at render time and degrades cleanly when the module is absent.
|
|
74
|
+
*/
|
|
75
|
+
providers?: Readonly<Record<string, unknown>>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A monitored service, as the Uptime client provider hands it to the host.
|
|
80
|
+
* Deliberately a subset of the module's own contract: what a linked-service
|
|
81
|
+
* block needs, and nothing the module may want to change later.
|
|
82
|
+
*/
|
|
83
|
+
export interface UptimeLinkedService {
|
|
84
|
+
id: number;
|
|
85
|
+
name: string;
|
|
86
|
+
url: string;
|
|
87
|
+
enabled: boolean;
|
|
88
|
+
status: string;
|
|
89
|
+
lastCheckedAt: number | null;
|
|
90
|
+
ratio24h: number | null;
|
|
91
|
+
ratio7d: number | null;
|
|
92
|
+
ratio30d: number | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One bucket of a service's availability history. */
|
|
96
|
+
export interface UptimeHistoryPoint {
|
|
97
|
+
at: number;
|
|
98
|
+
checks: number;
|
|
99
|
+
upChecks: number;
|
|
100
|
+
avgMs: number | null;
|
|
101
|
+
minMs: number | null;
|
|
102
|
+
maxMs: number | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type UptimeHistoryResolution = 'raw' | 'hour' | 'day';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What the Devices module offers the app's own screens (`DEVICES_CLIENT_PROVIDER`):
|
|
109
|
+
* the workspace's devices as a live store, the panel of one device (the home
|
|
110
|
+
* renders one view per placed device), and its compact tile. Without the
|
|
111
|
+
* module the home places no device and the topbar counts none.
|
|
112
|
+
*/
|
|
113
|
+
export interface DevicesClientProvider {
|
|
114
|
+
/** The active workspace's devices, refreshed by the `devices` live topic. */
|
|
115
|
+
useDevices(): { devices: readonly SdkDeviceSummary[]; loading: boolean; error: string | null };
|
|
116
|
+
refreshDevices(): void;
|
|
117
|
+
/** Forgets every loaded device (the app calls it when the session ends). */
|
|
118
|
+
resetDevices(): void;
|
|
119
|
+
DevicePanel: ComponentType<{ deviceId: string }>;
|
|
120
|
+
DeviceWidget: ComponentType<{ deviceId: string; hideStatus?: boolean }>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What the app's screens need of a device: identity and liveness, never the report. */
|
|
124
|
+
export interface SdkDeviceSummary {
|
|
125
|
+
id: string;
|
|
126
|
+
name: string;
|
|
127
|
+
online: boolean;
|
|
128
|
+
status: string;
|
|
129
|
+
platform: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What the Uptime module offers the host's screens under
|
|
134
|
+
* `UPTIME_CLIENT_PROVIDER`: the availability strip, the ratios, the history
|
|
135
|
+
* hook feeding the strip, the workspace's services, and the feature's own
|
|
136
|
+
* service dialog.
|
|
137
|
+
*/
|
|
138
|
+
export interface UptimeClientProvider {
|
|
139
|
+
/** The workspace's services, as `uptime.list` returns them. */
|
|
140
|
+
listServices(): Promise<readonly UptimeLinkedService[]>;
|
|
141
|
+
/**
|
|
142
|
+
* A service's history for the strip. `stamp` is what re-reads it (pass
|
|
143
|
+
* `service.lastCheckedAt`); no timer.
|
|
144
|
+
*/
|
|
145
|
+
useServiceHistory(
|
|
146
|
+
id: number,
|
|
147
|
+
stamp: number | null
|
|
148
|
+
): {
|
|
149
|
+
points: UptimeHistoryPoint[];
|
|
150
|
+
resolution: UptimeHistoryResolution;
|
|
151
|
+
axis: { from: number; to: number };
|
|
152
|
+
};
|
|
153
|
+
/** The availability strip over a window. */
|
|
154
|
+
StatusBars: ComponentType<{
|
|
155
|
+
points: UptimeHistoryPoint[];
|
|
156
|
+
from: number;
|
|
157
|
+
to: number;
|
|
158
|
+
resolution: UptimeHistoryResolution;
|
|
159
|
+
/** Rendered facing the legend (the ratios, typically). */
|
|
160
|
+
trailing?: ReactNode;
|
|
161
|
+
}>;
|
|
162
|
+
/** The three-window availability ratios of a service. */
|
|
163
|
+
Ratios: ComponentType<{ service: UptimeLinkedService; compact?: boolean }>;
|
|
164
|
+
/** The feature's service form: `service: null` declares a new one. */
|
|
165
|
+
ServiceDialog: ComponentType<{
|
|
166
|
+
open: boolean;
|
|
167
|
+
service: UptimeLinkedService | null;
|
|
168
|
+
onClose: () => void;
|
|
169
|
+
onSaved: (service: UptimeLinkedService) => void;
|
|
170
|
+
}>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A database of the workspace, as the Databases client provider lists it for
|
|
175
|
+
* a project's "add a database" picker. Deliberately a subset of the module's
|
|
176
|
+
* own contract: what a picker shows, and nothing the module may change later.
|
|
177
|
+
*/
|
|
178
|
+
export interface DatabaseLinkedCandidate {
|
|
179
|
+
id: number;
|
|
180
|
+
name: string;
|
|
181
|
+
/** The engine as the module labels it (« MySQL », « PostgreSQL »). */
|
|
182
|
+
engineLabel: string;
|
|
183
|
+
/** How many projects of the workspace already use it. */
|
|
184
|
+
projectCount: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What the Databases module offers the host's screens under
|
|
189
|
+
* `DATABASE_CLIENT_PROVIDER`: the workspace's databases, a linked database
|
|
190
|
+
* rendered in full inside a project's tab, and the feature's own dialog.
|
|
191
|
+
*/
|
|
192
|
+
export interface DatabaseClientProvider {
|
|
193
|
+
/** The workspace's databases, as `database.list` returns them. */
|
|
194
|
+
listDatabases(): Promise<readonly DatabaseLinkedCandidate[]>;
|
|
195
|
+
/**
|
|
196
|
+
* A database linked to a project, shown in full: header (name, address,
|
|
197
|
+
* actions, its own settings button), state, alerts, table explorer. Loads
|
|
198
|
+
* itself by `database.get`, follows the feature's invalidations, and
|
|
199
|
+
* renders the host's "unlink" as its trailing action. Handles the
|
|
200
|
+
* explorer's expanded mode on its own.
|
|
201
|
+
*/
|
|
202
|
+
LinkedDatabase: ComponentType<{ databaseId: number; canWrite: boolean; onUnlink: () => void }>;
|
|
203
|
+
/** The feature's database form, to declare a new database from a project. */
|
|
204
|
+
DatabaseDialog: ComponentType<{
|
|
205
|
+
open: boolean;
|
|
206
|
+
onClose: () => void;
|
|
207
|
+
onSaved: (databaseId: number) => void;
|
|
208
|
+
}>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A deploy target of the workspace, as the Deploy client provider lists it
|
|
213
|
+
* for a project's "add a target" picker.
|
|
214
|
+
*/
|
|
215
|
+
export interface DeployLinkedCandidate {
|
|
216
|
+
id: number;
|
|
217
|
+
name: string;
|
|
218
|
+
/** The instance host, as the module labels it (« dokploy.example.com »). */
|
|
219
|
+
host: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* What the Deploy module offers the host's screens under
|
|
224
|
+
* `DEPLOY_CLIENT_PROVIDER`: the workspace's targets, a linked target rendered
|
|
225
|
+
* in full inside a project's tab, and the feature's own dialog.
|
|
226
|
+
*/
|
|
227
|
+
export interface DeployClientProvider {
|
|
228
|
+
/** The workspace's targets, as `deploy.list` returns them. */
|
|
229
|
+
listTargets(): Promise<readonly DeployLinkedCandidate[]>;
|
|
230
|
+
/**
|
|
231
|
+
* A target linked to a project, shown in full: identity, last deployment,
|
|
232
|
+
* trigger, its own settings button. Loads itself by `deploy.get`, follows
|
|
233
|
+
* the feature's invalidations, and renders the host's "unlink" as its
|
|
234
|
+
* trailing action. `projectId` files a trigger in that project's timeline.
|
|
235
|
+
*/
|
|
236
|
+
LinkedTarget: ComponentType<{
|
|
237
|
+
targetId: number;
|
|
238
|
+
projectId: number;
|
|
239
|
+
canWrite: boolean;
|
|
240
|
+
onUnlink: () => void;
|
|
241
|
+
}>;
|
|
242
|
+
/** The feature's target form, to declare a new target from a project. */
|
|
243
|
+
TargetDialog: ComponentType<{
|
|
244
|
+
open: boolean;
|
|
245
|
+
onClose: () => void;
|
|
246
|
+
onSaved: (targetId: number) => void;
|
|
247
|
+
}>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A git repository of the workspace, as the Git client provider lists it for
|
|
252
|
+
* a project's "add a repository" picker.
|
|
253
|
+
*/
|
|
254
|
+
export interface GitLinkedCandidate {
|
|
255
|
+
id: number;
|
|
256
|
+
owner: string;
|
|
257
|
+
repo: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* What the Git module offers the host's screens under `GIT_CLIENT_PROVIDER`:
|
|
262
|
+
* the workspace's repositories, a linked repository rendered in full inside a
|
|
263
|
+
* project's tab, and the feature's own dialog.
|
|
264
|
+
*/
|
|
265
|
+
export interface GitClientProvider {
|
|
266
|
+
/** The workspace's repositories, as `git.repoList` returns them. */
|
|
267
|
+
listRepos(): Promise<readonly GitLinkedCandidate[]>;
|
|
268
|
+
/**
|
|
269
|
+
* A repository linked to a project, shown in full: header (name, sync
|
|
270
|
+
* state, actions, its own settings button), graph and panels. Loads
|
|
271
|
+
* itself by `git.repoGet`, follows the feature's invalidations and the
|
|
272
|
+
* sync progress, and renders the host's "unlink" as its trailing action.
|
|
273
|
+
*/
|
|
274
|
+
LinkedRepo: ComponentType<{ repoId: number; canWrite: boolean; onUnlink: () => void }>;
|
|
275
|
+
/** The feature's repository form, to declare a new repository from a project. */
|
|
276
|
+
RepoDialog: ComponentType<{
|
|
277
|
+
open: boolean;
|
|
278
|
+
onClose: () => void;
|
|
279
|
+
onSaved: (repoId: number) => void;
|
|
280
|
+
}>;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A tracked site of the workspace, as the Audience client provider lists it for a project's picker. */
|
|
284
|
+
export interface AudienceLinkedCandidate {
|
|
285
|
+
id: number;
|
|
286
|
+
name: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* What the Audience module offers the host's screens under
|
|
291
|
+
* `AUDIENCE_CLIENT_PROVIDER`: the workspace's sites, a linked site rendered
|
|
292
|
+
* in full inside a project's tab, and the feature's own dialog.
|
|
293
|
+
*/
|
|
294
|
+
export interface AudienceClientProvider {
|
|
295
|
+
/** The workspace's sites, as `audience.list` returns them. */
|
|
296
|
+
listSites(): Promise<readonly AudienceLinkedCandidate[]>;
|
|
297
|
+
/**
|
|
298
|
+
* A site linked to a project, shown in full: its sticky heading, actions
|
|
299
|
+
* (install, its own settings button), stats, funnels. Loads itself by
|
|
300
|
+
* `audience.get`, follows the feature's invalidations and the live beat,
|
|
301
|
+
* and renders the host's "unlink" as its trailing action.
|
|
302
|
+
*/
|
|
303
|
+
LinkedSite: ComponentType<{ siteId: number; canWrite: boolean; onUnlink: () => void }>;
|
|
304
|
+
/** The feature's site form, to declare a new site from a project. */
|
|
305
|
+
SiteDialog: ComponentType<{
|
|
306
|
+
open: boolean;
|
|
307
|
+
onClose: () => void;
|
|
308
|
+
onSaved: (siteId: number) => void;
|
|
309
|
+
}>;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* What the Mail module offers the host's screens under `MAIL_CLIENT_PROVIDER`:
|
|
314
|
+
* the ready senders (open tier, enabled) an email notification channel picks
|
|
315
|
+
* from, and the feature's own account dialog.
|
|
316
|
+
*/
|
|
317
|
+
export interface MailClientProvider {
|
|
318
|
+
/** The workspace's ready senders, as the module lists them. */
|
|
319
|
+
listSenders(): Promise<readonly { id: number; label: string; address: string }[]>;
|
|
320
|
+
/**
|
|
321
|
+
* The feature's account form (manual connection or OAuth consent), to
|
|
322
|
+
* declare a mailbox from the channel form. `onSaved` fires when a mailbox
|
|
323
|
+
* came out of it; the host re-lists to find which.
|
|
324
|
+
*/
|
|
325
|
+
AccountDialog: ComponentType<{ open: boolean; onClose: () => void; onSaved: () => void }>;
|
|
66
326
|
}
|
package/src/sdk/devb.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `DEVB` : le conteneur chiffré que CloudSync (blobs) et Backup (archives)
|
|
5
|
+
* partagent. Une seule définition parce que le format est figé : un octet de
|
|
6
|
+
* divergence et une sauvegarde ne se rouvre plus. Ne s'occupe que du FORMAT ;
|
|
7
|
+
* la clé est l'affaire de chaque module (`scripts/restore-backup.mjs` côté app
|
|
8
|
+
* relit ce format sans DevEye).
|
|
9
|
+
*
|
|
10
|
+
* v1, flux unique (lu, plus jamais écrit) :
|
|
11
|
+
* magic 'DEVB' (4) | 0x01 (1) | nonce (12) | ciphertext | tag GCM (16)
|
|
12
|
+
* Un seul AES-256-GCM ; irreprenable après redémarrage, l'état du cipher ne se
|
|
13
|
+
* sérialise pas.
|
|
14
|
+
*
|
|
15
|
+
* v2, scellé par blocs (format d'écriture) :
|
|
16
|
+
* magic 'DEVB' (4) | 0x02 (1) | nonce de base (12) | bloc* | bloc final
|
|
17
|
+
* bloc = ciphertext ({@link BLOB_CHUNK_BYTES} octets de clair) | tag (16)
|
|
18
|
+
* Nonce = nonce de base XOR compteur, AAD = compteur (8) + marqueur de fin (1) :
|
|
19
|
+
* le marqueur ferme la troncature, le compteur le réordonnancement. La reprise
|
|
20
|
+
* d'un partiel relit les blocs complets en local pour reconstituer le SHA-256
|
|
21
|
+
* courant, sans retransmettre un octet.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const BLOB_MAGIC = Buffer.from('DEVB');
|
|
25
|
+
const BLOB_V1 = 0x01;
|
|
26
|
+
const BLOB_V2 = 0x02;
|
|
27
|
+
|
|
28
|
+
/** Longueur de l'en-tête (magic + version + nonce) et d'un tag GCM. */
|
|
29
|
+
export const BLOB_HEADER_LEN = BLOB_MAGIC.length + 1 + 12;
|
|
30
|
+
export const BLOB_TAG_LEN = 16;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Clair par bloc en v2. 1 Mio : assez grand pour que le surcoût des tags soit
|
|
34
|
+
* négligeable (16 octets par Mio, soit 0,0015 %), assez petit pour qu'une
|
|
35
|
+
* reprise ne reperde jamais plus d'un Mio de travail.
|
|
36
|
+
*/
|
|
37
|
+
export const BLOB_CHUNK_BYTES = 1024 * 1024;
|
|
38
|
+
/** Taille d'un bloc v2 complet sur le disque. */
|
|
39
|
+
export const BLOB_CHUNK_SEALED = BLOB_CHUNK_BYTES + BLOB_TAG_LEN;
|
|
40
|
+
|
|
41
|
+
export const BLOB_VERSION_STREAM = BLOB_V1;
|
|
42
|
+
export const BLOB_VERSION_CHUNKED = BLOB_V2;
|
|
43
|
+
|
|
44
|
+
/** L'en-tête d'un nouveau blob v2 (magic + version + nonce de base aléatoire). */
|
|
45
|
+
export function createBlobHeader(): Buffer {
|
|
46
|
+
return Buffer.concat([BLOB_MAGIC, Buffer.from([BLOB_V2]), crypto.randomBytes(12)]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Valide un en-tête et rend sa version + son nonce de base. */
|
|
50
|
+
export function parseBlobHeader(header: Buffer): { version: number; nonce: Buffer } {
|
|
51
|
+
if (header.length !== BLOB_HEADER_LEN || !header.subarray(0, 4).equals(BLOB_MAGIC)) {
|
|
52
|
+
throw new Error('DEVB : blob corrompu (en-tête invalide)');
|
|
53
|
+
}
|
|
54
|
+
const version = header[4];
|
|
55
|
+
if (version !== BLOB_V1 && version !== BLOB_V2) {
|
|
56
|
+
throw new Error(`DEVB : version de blob inconnue (${version})`);
|
|
57
|
+
}
|
|
58
|
+
return { version, nonce: header.subarray(5, 17) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Le nonce du bloc `index` : nonce de base XOR le compteur en big-endian sur
|
|
63
|
+
* les 8 derniers octets. Deux blobs n'ont jamais le même nonce de base (12
|
|
64
|
+
* octets aléatoires), donc jamais la même paire (clé, nonce) — la règle d'or
|
|
65
|
+
* de GCM tient.
|
|
66
|
+
*/
|
|
67
|
+
function chunkNonce(base: Buffer, index: number): Buffer {
|
|
68
|
+
const nonce = Buffer.from(base);
|
|
69
|
+
const counter = Buffer.alloc(8);
|
|
70
|
+
counter.writeBigUInt64BE(BigInt(index));
|
|
71
|
+
for (let i = 0; i < 8; i += 1) nonce[4 + i] ^= counter[i];
|
|
72
|
+
return nonce;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** L'AAD d'un bloc : son rang, et s'il termine le blob. */
|
|
76
|
+
function chunkAad(index: number, final: boolean): Buffer {
|
|
77
|
+
const aad = Buffer.alloc(9);
|
|
78
|
+
aad.writeBigUInt64BE(BigInt(index));
|
|
79
|
+
aad[8] = final ? 1 : 0;
|
|
80
|
+
return aad;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Scelle un bloc de clair en `ciphertext | tag`. */
|
|
84
|
+
export function sealChunk(
|
|
85
|
+
key: Buffer,
|
|
86
|
+
base: Buffer,
|
|
87
|
+
index: number,
|
|
88
|
+
plain: Buffer,
|
|
89
|
+
final: boolean
|
|
90
|
+
): Buffer {
|
|
91
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, chunkNonce(base, index));
|
|
92
|
+
cipher.setAAD(chunkAad(index, final));
|
|
93
|
+
const body = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
94
|
+
return Buffer.concat([body, cipher.getAuthTag()]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Ouvre un bloc scellé. Lève si le tag ne colle pas — donc si le contenu, son
|
|
99
|
+
* rang ou son statut de dernier bloc ont bougé.
|
|
100
|
+
*/
|
|
101
|
+
export function openChunk(
|
|
102
|
+
key: Buffer,
|
|
103
|
+
base: Buffer,
|
|
104
|
+
index: number,
|
|
105
|
+
sealed: Buffer,
|
|
106
|
+
final: boolean
|
|
107
|
+
): Buffer {
|
|
108
|
+
if (sealed.length < BLOB_TAG_LEN) throw new Error('DEVB : bloc de blob tronqué');
|
|
109
|
+
const body = sealed.subarray(0, sealed.length - BLOB_TAG_LEN);
|
|
110
|
+
const tag = sealed.subarray(sealed.length - BLOB_TAG_LEN);
|
|
111
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, chunkNonce(base, index));
|
|
112
|
+
decipher.setAAD(chunkAad(index, final));
|
|
113
|
+
decipher.setAuthTag(tag);
|
|
114
|
+
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Ouvre le déchiffrement d'un blob v1 (flux unique) depuis son en-tête. */
|
|
118
|
+
export function openStreamDecipher(key: Buffer, nonce: Buffer): crypto.DecipherGCM {
|
|
119
|
+
return crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
120
|
+
}
|
package/src/sdk/manifest.test.ts
CHANGED
|
@@ -31,7 +31,6 @@ test('validateManifest rejects the classic mistakes', () => {
|
|
|
31
31
|
assert.throws(() => validateManifest({ ...base, ...patch }), new RegExp(fragment));
|
|
32
32
|
rejects({ label: ' ' }, 'empty label');
|
|
33
33
|
rejects({ hasItems: true }, 'itemNoun');
|
|
34
|
-
rejects({ itemSegment: (id) => `item:${id}` }, 'itemSegment');
|
|
35
34
|
rejects({ shareTier: 'open' }, "shareTier 'never'");
|
|
36
35
|
rejects({ commandPrefix: 'x-demo.' }, 'commandPrefix');
|
|
37
36
|
rejects(
|
package/src/sdk/manifest.ts
CHANGED
|
@@ -9,13 +9,8 @@ import {
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The feature manifest: everything DevEye needs to know about a feature,
|
|
12
|
-
* declared once.
|
|
13
|
-
*
|
|
14
|
-
* Historically this knowledge was scattered across ~14 registration points
|
|
15
|
-
* (descriptor, catalog entry, settings wiring tables, invalidation keys,
|
|
16
|
-
* teleport segments...). A module declares all of it here; the app's generated
|
|
17
|
-
* glue reads the manifest and wires every screen from it. Native features are
|
|
18
|
-
* being migrated onto the same contract (Weather first).
|
|
12
|
+
* declared once. The app's generated glue reads it and wires every screen
|
|
13
|
+
* (descriptor, catalog, settings tabs, invalidation keys, teleport segments).
|
|
19
14
|
*/
|
|
20
15
|
|
|
21
16
|
/**
|
|
@@ -78,23 +73,48 @@ export type NativeCapability =
|
|
|
78
73
|
| 'notify'
|
|
79
74
|
| 'mail.accounts'
|
|
80
75
|
| 'members.read'
|
|
76
|
+
/**
|
|
77
|
+
* List every workspace of this DevEye (id, name, kind, owner). A global
|
|
78
|
+
* administrator's surface only: the call refuses anyone else. What a
|
|
79
|
+
* fleet needs to attach a device to workspaces.
|
|
80
|
+
*/
|
|
81
|
+
| 'workspaces.read'
|
|
81
82
|
/** Read/authorize the workspace's devices. */
|
|
82
83
|
| 'devices.read'
|
|
83
84
|
/**
|
|
84
85
|
* The full agent-fleet sync transport (outbound requests, browser fan-out,
|
|
85
|
-
* per-socket subscriptions). Reserved for
|
|
86
|
-
*
|
|
86
|
+
* per-socket subscriptions). Reserved for native-id modules: the agent
|
|
87
|
+
* protocol is app infrastructure, not a third-party surface.
|
|
88
|
+
*/
|
|
89
|
+
| 'agents'
|
|
90
|
+
/**
|
|
91
|
+
* Read the devices' telemetry (process instants, metric rows, evidence
|
|
92
|
+
* pinning). Reserved for native-id modules like `'agents'`: the metric
|
|
93
|
+
* store is app infrastructure.
|
|
94
|
+
*/
|
|
95
|
+
| 'telemetry.read'
|
|
96
|
+
/**
|
|
97
|
+
* Public HTTP routes: endpoints reachable WITHOUT a session (an analytics
|
|
98
|
+
* beacon), mounted on every listener the host exposes, with open CORS.
|
|
99
|
+
* Declared, because opening a door is the one thing a module must not do
|
|
100
|
+
* quietly. See `FeatureService.publicRoutes`.
|
|
87
101
|
*/
|
|
88
|
-
| '
|
|
102
|
+
| 'routes.public';
|
|
89
103
|
|
|
90
104
|
/**
|
|
91
105
|
* Settings tabs the shell can render for you.
|
|
92
106
|
* - `'notifications'` and `'permissions'` are fully generic: DevEye renders
|
|
93
107
|
* them from the manifest alone, you write no component.
|
|
94
|
-
* - `'general'` and `'
|
|
95
|
-
* client entry (`settingsPanels`), keyed by
|
|
108
|
+
* - `'general'`, `'sources'`, `'sync'` and `'encryption'` need a panel
|
|
109
|
+
* component, provided by your client entry (`settingsPanels`), keyed by
|
|
110
|
+
* the tab id. `'sync'` (item scope only) is the cadence and maintenance of
|
|
111
|
+
* an item the module keeps fresh in the background (a mailbox).
|
|
112
|
+
* `'encryption'` (item scope only) is where an item chooses the form of
|
|
113
|
+
* its own data, when the feature leaves the choice: the shell names and
|
|
114
|
+
* places the tab, the module owns the choice.
|
|
96
115
|
*/
|
|
97
|
-
export type SettingsTab =
|
|
116
|
+
export type SettingsTab =
|
|
117
|
+
'general' | 'sources' | 'notifications' | 'permissions' | 'sync' | 'encryption';
|
|
98
118
|
|
|
99
119
|
/** A custom settings tab. Needs a matching panel in `settingsPanels`. */
|
|
100
120
|
export interface CustomTabRef {
|
|
@@ -127,6 +147,22 @@ export interface CrossTopicInvalidation {
|
|
|
127
147
|
/** Where a feature's card can live on the home grid. */
|
|
128
148
|
export type FeatureCategory = 'supervision' | 'security' | 'dev' | 'work' | 'daily' | 'analysis';
|
|
129
149
|
|
|
150
|
+
/**
|
|
151
|
+
* A data link from this feature to another one, as the "About" card of the
|
|
152
|
+
* home grid reads it (in both directions): a real coupling (an alert sender
|
|
153
|
+
* goes through a Mail account, a project points its repositories), not a
|
|
154
|
+
* thematic neighbourhood.
|
|
155
|
+
*/
|
|
156
|
+
export interface FeatureLink {
|
|
157
|
+
/** The feature linked to. Never your own id. */
|
|
158
|
+
to: FeatureId;
|
|
159
|
+
/** What the link allows, said from this feature's point of view. */
|
|
160
|
+
what: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Hard cap on `links`: the "About" card stays a card. */
|
|
164
|
+
export const MAX_FEATURE_LINKS = 6;
|
|
165
|
+
|
|
130
166
|
export interface FeatureManifest<Id extends FeatureId = FeatureId> {
|
|
131
167
|
/** External modules: `x-<slug>`. Native features keep their enum id. */
|
|
132
168
|
id: Id;
|
|
@@ -152,14 +188,20 @@ export interface FeatureManifest<Id extends FeatureId = FeatureId> {
|
|
|
152
188
|
sources?: { hint: string };
|
|
153
189
|
/**
|
|
154
190
|
* Whether an item can be projected into another workspace. Decided by
|
|
155
|
-
* encryption
|
|
156
|
-
*
|
|
191
|
+
* encryption: only the open tier is readable by the server alone. Anything
|
|
192
|
+
* but `'never'` commits the module to the sharing contract: a server entry
|
|
193
|
+
* with `items`, listings that read `ctx.sharing.scope()` and pick the
|
|
194
|
+
* cipher row by row, and `ctx.items.restrictions()` applied to what they
|
|
195
|
+
* return. External modules declare `'never'` for now.
|
|
157
196
|
*/
|
|
158
197
|
shareTier: 'open' | 'perItem' | 'never';
|
|
159
198
|
|
|
160
199
|
/** Card rendering on the home grid. `compact` halves the minimum height. */
|
|
161
200
|
tile?: { compact?: boolean };
|
|
162
201
|
|
|
202
|
+
/** See {@link FeatureLink}. At most {@link MAX_FEATURE_LINKS}. */
|
|
203
|
+
links?: readonly FeatureLink[];
|
|
204
|
+
|
|
163
205
|
/**
|
|
164
206
|
* The feature offers a compact TOPBAR widget (pinned top-right of the
|
|
165
207
|
* navbar). Declares only the DATA the picker shows; the component itself
|
|
@@ -186,6 +228,15 @@ export interface FeatureManifest<Id extends FeatureId = FeatureId> {
|
|
|
186
228
|
*/
|
|
187
229
|
invalidatedByTopic?: readonly string[];
|
|
188
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Secondary live topics of your own, beaten separately from your id so a
|
|
233
|
+
* frequent write does not refresh everything (a chat thread that must not
|
|
234
|
+
* re-fetch the board). Each names the keys it re-fetches (a subset of
|
|
235
|
+
* `resources`); its id starts with yours. A handler beats it with
|
|
236
|
+
* `mutates: ['<topic>']`, a service with `live.changed(ws, ['<topic>'])`.
|
|
237
|
+
*/
|
|
238
|
+
topics?: readonly { id: string; keys: readonly string[] }[];
|
|
239
|
+
|
|
189
240
|
/**
|
|
190
241
|
* Keys ALSO re-fetched when another feature's topic fires. The escape
|
|
191
242
|
* hatch for real data coupling (CloudSync's share rows carry device names:
|
|
@@ -202,13 +253,6 @@ export interface FeatureManifest<Id extends FeatureId = FeatureId> {
|
|
|
202
253
|
*/
|
|
203
254
|
commandPrefix?: string;
|
|
204
255
|
|
|
205
|
-
/**
|
|
206
|
-
* Builds the live/teleport segment for one item, e.g. `(id) => `job:${id}``.
|
|
207
|
-
* MUST return byte-for-byte what your view declares via `useLiveSegment('l1', ...)`:
|
|
208
|
-
* it is a rendezvous, not a convention. Requires `hasItems`.
|
|
209
|
-
*/
|
|
210
|
-
itemSegment?: (itemId: number | string) => string;
|
|
211
|
-
|
|
212
256
|
/** Settings tabs, per scope. Omit a scope to render no settings there. */
|
|
213
257
|
settings?: {
|
|
214
258
|
feature?: readonly (SettingsTab | CustomTabRef)[];
|
|
@@ -233,7 +277,6 @@ export function validateManifest(m: FeatureManifest): void {
|
|
|
233
277
|
if (!m.label.trim()) fail(m.id, 'empty label');
|
|
234
278
|
if (!m.description.trim()) fail(m.id, 'empty description');
|
|
235
279
|
if (m.hasItems && !m.itemNoun?.trim()) fail(m.id, 'hasItems requires itemNoun');
|
|
236
|
-
if (!m.hasItems && m.itemSegment) fail(m.id, 'itemSegment requires hasItems');
|
|
237
280
|
if (external && m.shareTier !== 'never') {
|
|
238
281
|
fail(m.id, "external modules must declare shareTier 'never' for now");
|
|
239
282
|
}
|
|
@@ -263,6 +306,27 @@ export function validateManifest(m: FeatureManifest): void {
|
|
|
263
306
|
fail(m.id, `invalidatedByTopic « ${key} » is not in resources`);
|
|
264
307
|
}
|
|
265
308
|
}
|
|
309
|
+
for (const topic of m.topics ?? []) {
|
|
310
|
+
if (
|
|
311
|
+
!/^[a-z][a-zA-Z0-9-]{1,31}$/.test(topic.id) ||
|
|
312
|
+
!topic.id.startsWith(m.id) ||
|
|
313
|
+
topic.id === m.id
|
|
314
|
+
) {
|
|
315
|
+
fail(m.id, `topic « ${topic.id} » must start with the feature id and differ from it`);
|
|
316
|
+
}
|
|
317
|
+
for (const key of topic.keys) {
|
|
318
|
+
if (!m.resources.includes(key)) {
|
|
319
|
+
fail(m.id, `topic « ${topic.id} » key « ${key} » is not in resources`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const links = m.links ?? [];
|
|
325
|
+
if (links.length > MAX_FEATURE_LINKS) fail(m.id, `more than ${MAX_FEATURE_LINKS} links`);
|
|
326
|
+
for (const link of links) {
|
|
327
|
+
if (link.to === m.id) fail(m.id, 'links must name ANOTHER feature');
|
|
328
|
+
if (!link.what.trim()) fail(m.id, `link to « ${link.to} »: empty what`);
|
|
329
|
+
}
|
|
266
330
|
|
|
267
331
|
const cross = m.alsoInvalidatedBy ?? [];
|
|
268
332
|
if (cross.length > 4) fail(m.id, 'more than 4 alsoInvalidatedBy entries');
|
|
@@ -275,8 +339,10 @@ export function validateManifest(m: FeatureManifest): void {
|
|
|
275
339
|
}
|
|
276
340
|
}
|
|
277
341
|
|
|
278
|
-
|
|
279
|
-
|
|
342
|
+
for (const reserved of ['agents', 'telemetry.read'] as const) {
|
|
343
|
+
if ((m.nativeCapabilities ?? []).includes(reserved) && external) {
|
|
344
|
+
fail(m.id, `capability '${reserved}' is reserved for native-id modules`);
|
|
345
|
+
}
|
|
280
346
|
}
|
|
281
347
|
|
|
282
348
|
const extras = m.extraPermissions ?? [];
|
|
@@ -315,6 +381,9 @@ export function validateManifest(m: FeatureManifest): void {
|
|
|
315
381
|
if (tab === 'sources' && scope === 'item') {
|
|
316
382
|
fail(m.id, 'sources is a feature-scope tab');
|
|
317
383
|
}
|
|
384
|
+
if (tab === 'encryption' && scope === 'feature') {
|
|
385
|
+
fail(m.id, 'encryption is an item-scope tab');
|
|
386
|
+
}
|
|
318
387
|
} else {
|
|
319
388
|
if (!CUSTOM_TAB_PATTERN.test(tab.id) || BUILTIN_TABS.includes(tab.id)) {
|
|
320
389
|
fail(m.id, `invalid custom tab id « ${tab.id} »`);
|