@deveye/types 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -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 +244 -22
- package/src/sdk/client.ts +277 -17
- package/src/sdk/devb.ts +120 -0
- package/src/sdk/manifest.test.ts +96 -0
- package/src/sdk/manifest.ts +160 -25
- package/src/sdk/providers.ts +310 -5
- package/src/sdk/server.ts +483 -19
- package/src/sdk/testing.test.ts +62 -0
- package/src/sdk/testing.ts +416 -40
- 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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
externalDescriptorOf,
|
|
7
|
+
resolveExtras,
|
|
8
|
+
validateManifest,
|
|
9
|
+
type FeatureManifest
|
|
10
|
+
} from './manifest';
|
|
11
|
+
|
|
12
|
+
const base: FeatureManifest = {
|
|
13
|
+
id: 'x-demo',
|
|
14
|
+
label: 'Demo',
|
|
15
|
+
description: 'A demo module.',
|
|
16
|
+
icon: 'x-demo-icon',
|
|
17
|
+
category: 'daily',
|
|
18
|
+
notifies: false,
|
|
19
|
+
hasItems: false,
|
|
20
|
+
shareTier: 'never',
|
|
21
|
+
resources: ['x-demo.state'],
|
|
22
|
+
commands: [{ command: 'x-demo.get', input: z.object({}), output: z.object({}) }]
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
test('validateManifest accepts a minimal external manifest', () => {
|
|
26
|
+
assert.doesNotThrow(() => validateManifest(base));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('validateManifest rejects the classic mistakes', () => {
|
|
30
|
+
const rejects = (patch: Partial<FeatureManifest>, fragment: string) =>
|
|
31
|
+
assert.throws(() => validateManifest({ ...base, ...patch }), new RegExp(fragment));
|
|
32
|
+
rejects({ label: ' ' }, 'empty label');
|
|
33
|
+
rejects({ hasItems: true }, 'itemNoun');
|
|
34
|
+
rejects({ shareTier: 'open' }, "shareTier 'never'");
|
|
35
|
+
rejects({ commandPrefix: 'x-demo.' }, 'commandPrefix');
|
|
36
|
+
rejects(
|
|
37
|
+
{ commands: [{ command: 'other.get', input: z.object({}), output: z.object({}) }] },
|
|
38
|
+
'x-demo'
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('externalDescriptorOf projects the identity fields only, and refuses a native id', () => {
|
|
43
|
+
assert.deepEqual(
|
|
44
|
+
externalDescriptorOf({ ...base, itemNoun: 'thing', sources: { hint: 'keys' } }),
|
|
45
|
+
{
|
|
46
|
+
id: 'x-demo',
|
|
47
|
+
label: 'Demo',
|
|
48
|
+
description: 'A demo module.',
|
|
49
|
+
icon: 'x-demo-icon',
|
|
50
|
+
notifies: false,
|
|
51
|
+
hasItems: false,
|
|
52
|
+
itemNoun: 'thing',
|
|
53
|
+
sources: { hint: 'keys' },
|
|
54
|
+
shareTier: 'never'
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
assert.throws(() => externalDescriptorOf({ ...base, id: 'weather' }), /not an external id/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('resolveExtras: the owner holds everything, a member what the grant says, unknown keys nothing', () => {
|
|
61
|
+
const specs: FeatureManifest['extraPermissions'] = [
|
|
62
|
+
{ key: 'reset', type: 'toggle', label: 'Reset', description: '' },
|
|
63
|
+
{
|
|
64
|
+
key: 'limit',
|
|
65
|
+
type: 'choice',
|
|
66
|
+
label: 'Limit',
|
|
67
|
+
description: '',
|
|
68
|
+
options: [
|
|
69
|
+
{ value: 'low', label: 'Low' },
|
|
70
|
+
{ value: 'high', label: 'High' }
|
|
71
|
+
],
|
|
72
|
+
default: 'low',
|
|
73
|
+
ownerValue: 'high'
|
|
74
|
+
}
|
|
75
|
+
];
|
|
76
|
+
const owner = resolveExtras(specs, true, {});
|
|
77
|
+
assert.equal(owner.canExtra('reset'), true);
|
|
78
|
+
assert.equal(owner.extraValue('limit'), 'high');
|
|
79
|
+
|
|
80
|
+
const member = resolveExtras(specs, false, { reset: true, limit: 'high' });
|
|
81
|
+
assert.equal(member.canExtra('reset'), true);
|
|
82
|
+
assert.equal(member.extraValue('limit'), 'high');
|
|
83
|
+
|
|
84
|
+
const restricted = resolveExtras(specs, false, { reset: false, limit: 'bogus' });
|
|
85
|
+
assert.equal(restricted.canExtra('reset'), false);
|
|
86
|
+
assert.equal(
|
|
87
|
+
restricted.extraValue('limit'),
|
|
88
|
+
'low',
|
|
89
|
+
'a value outside the options falls back to the default'
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Wrong kind or undeclared: nothing, owner or not.
|
|
93
|
+
assert.equal(owner.canExtra('limit'), false);
|
|
94
|
+
assert.equal(owner.extraValue('reset'), '');
|
|
95
|
+
assert.equal(resolveExtras(undefined, true, { reset: true }).canExtra('reset'), false);
|
|
96
|
+
});
|