@deveye/types 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +9 -6
  2. package/src/domain/device.ts +4 -29
  3. package/src/domain/featureRegistry.ts +40 -108
  4. package/src/domain/home.ts +40 -101
  5. package/src/domain/live.ts +36 -87
  6. package/src/domain/metrics.ts +10 -14
  7. package/src/domain/notifications.ts +39 -110
  8. package/src/domain/project.ts +3 -162
  9. package/src/domain/report.ts +46 -95
  10. package/src/domain/secrecy.ts +3 -5
  11. package/src/domain/sharing.ts +33 -70
  12. package/src/domain/syncProtocol.ts +5 -12
  13. package/src/domain/user.ts +8 -14
  14. package/src/domain/workspace.ts +0 -3
  15. package/src/domain/workspaceRole.ts +34 -82
  16. package/src/features/agent.ts +306 -0
  17. package/src/features/live.ts +17 -37
  18. package/src/features/notify.ts +19 -57
  19. package/src/features/registry.ts +7 -44
  20. package/src/features/secrecy.ts +4 -9
  21. package/src/features/sharing.ts +12 -26
  22. package/src/features/user.ts +6 -11
  23. package/src/features/workspace.ts +6 -11
  24. package/src/http/auth.ts +6 -13
  25. package/src/http/device.ts +13 -17
  26. package/src/http/status.ts +6 -10
  27. package/src/index.ts +29 -932
  28. package/src/protocol/agent.ts +40 -70
  29. package/src/protocol/envelope.ts +3 -10
  30. package/src/sdk/client-ambient.d.ts +244 -22
  31. package/src/sdk/client.ts +277 -17
  32. package/src/sdk/devb.ts +120 -0
  33. package/src/sdk/manifest.test.ts +96 -0
  34. package/src/sdk/manifest.ts +160 -25
  35. package/src/sdk/providers.ts +310 -5
  36. package/src/sdk/server.ts +483 -19
  37. package/src/sdk/testing.test.ts +62 -0
  38. package/src/sdk/testing.ts +416 -40
  39. package/src/utils/version.ts +5 -8
  40. package/src/domain/audience.ts +0 -549
  41. package/src/domain/backup.ts +0 -355
  42. package/src/domain/credential.ts +0 -55
  43. package/src/domain/database.ts +0 -467
  44. package/src/domain/deploy.ts +0 -231
  45. package/src/domain/finance.ts +0 -477
  46. package/src/domain/git.ts +0 -419
  47. package/src/domain/mail.ts +0 -394
  48. package/src/domain/note.ts +0 -202
  49. package/src/domain/password.ts +0 -36
  50. package/src/domain/projectBoard.ts +0 -130
  51. package/src/domain/projectChat.ts +0 -46
  52. package/src/domain/projectHistory.ts +0 -82
  53. package/src/domain/projectLink.ts +0 -87
  54. package/src/domain/projectPlan.ts +0 -68
  55. package/src/domain/sentinel.ts +0 -623
  56. package/src/domain/uptime.ts +0 -216
  57. package/src/features/audience.ts +0 -275
  58. package/src/features/backup.ts +0 -230
  59. package/src/features/database.ts +0 -461
  60. package/src/features/deploy.ts +0 -245
  61. package/src/features/device.ts +0 -292
  62. package/src/features/deviceFiles.ts +0 -83
  63. package/src/features/deviceLogs.ts +0 -36
  64. package/src/features/deviceTerminal.ts +0 -57
  65. package/src/features/finance.ts +0 -360
  66. package/src/features/git.ts +0 -368
  67. package/src/features/mail.ts +0 -374
  68. package/src/features/metrics.ts +0 -185
  69. package/src/features/note.ts +0 -189
  70. package/src/features/password.ts +0 -67
  71. package/src/features/project.ts +0 -709
  72. package/src/features/sentinel.ts +0 -233
  73. package/src/features/uptime.ts +0 -186
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
- /** The scope a settings panel is opened for. */
14
- export type SdkSettingsScope =
15
- { kind: 'feature' } | { kind: 'item'; itemId: number; itemLabel: string };
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 (`'notifications'`,
43
- * `'permissions'`) need no panel.
47
+ * `'sources'`, `'encryption'`, and any custom tab id. Generic tabs
48
+ * (`'notifications'`, `'permissions'`) need no panel.
44
49
  */
45
- settingsPanels?: Readonly<Record<string, ComponentType<SettingsPanelProps>>>;
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
- * Rendered with NO props, on purpose: that is the security contract. The
50
- * host hands the component nothing (no user, no stores, no other feature's
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
  }
@@ -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
+ });