@deveye/types 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +6 -6
  2. package/src/domain/device.ts +4 -29
  3. package/src/domain/featureRegistry.ts +40 -108
  4. package/src/domain/home.ts +40 -101
  5. package/src/domain/live.ts +36 -87
  6. package/src/domain/metrics.ts +10 -14
  7. package/src/domain/notifications.ts +39 -110
  8. package/src/domain/project.ts +3 -162
  9. package/src/domain/report.ts +46 -95
  10. package/src/domain/secrecy.ts +3 -5
  11. package/src/domain/sharing.ts +33 -70
  12. package/src/domain/syncProtocol.ts +5 -12
  13. package/src/domain/user.ts +8 -14
  14. package/src/domain/workspace.ts +0 -3
  15. package/src/domain/workspaceRole.ts +34 -82
  16. package/src/features/agent.ts +306 -0
  17. package/src/features/live.ts +17 -37
  18. package/src/features/notify.ts +19 -57
  19. package/src/features/registry.ts +7 -44
  20. package/src/features/secrecy.ts +4 -9
  21. package/src/features/sharing.ts +12 -26
  22. package/src/features/user.ts +6 -11
  23. package/src/features/workspace.ts +6 -11
  24. package/src/http/auth.ts +6 -13
  25. package/src/http/device.ts +13 -17
  26. package/src/http/status.ts +6 -10
  27. package/src/index.ts +29 -932
  28. package/src/protocol/agent.ts +40 -70
  29. package/src/protocol/envelope.ts +3 -10
  30. package/src/sdk/client-ambient.d.ts +228 -21
  31. package/src/sdk/client.ts +277 -17
  32. package/src/sdk/devb.ts +120 -0
  33. package/src/sdk/manifest.test.ts +0 -1
  34. package/src/sdk/manifest.ts +94 -25
  35. package/src/sdk/providers.ts +310 -5
  36. package/src/sdk/server.ts +483 -19
  37. package/src/sdk/testing.test.ts +2 -2
  38. package/src/sdk/testing.ts +302 -26
  39. package/src/utils/version.ts +5 -8
  40. package/src/domain/audience.ts +0 -549
  41. package/src/domain/backup.ts +0 -355
  42. package/src/domain/credential.ts +0 -55
  43. package/src/domain/database.ts +0 -467
  44. package/src/domain/deploy.ts +0 -231
  45. package/src/domain/finance.ts +0 -477
  46. package/src/domain/git.ts +0 -419
  47. package/src/domain/mail.ts +0 -394
  48. package/src/domain/note.ts +0 -202
  49. package/src/domain/password.ts +0 -36
  50. package/src/domain/projectBoard.ts +0 -130
  51. package/src/domain/projectChat.ts +0 -46
  52. package/src/domain/projectHistory.ts +0 -82
  53. package/src/domain/projectLink.ts +0 -87
  54. package/src/domain/projectPlan.ts +0 -68
  55. package/src/domain/sentinel.ts +0 -623
  56. package/src/domain/uptime.ts +0 -216
  57. package/src/features/audience.ts +0 -275
  58. package/src/features/backup.ts +0 -230
  59. package/src/features/database.ts +0 -461
  60. package/src/features/deploy.ts +0 -245
  61. package/src/features/device.ts +0 -292
  62. package/src/features/deviceFiles.ts +0 -83
  63. package/src/features/deviceLogs.ts +0 -36
  64. package/src/features/deviceTerminal.ts +0 -57
  65. package/src/features/finance.ts +0 -360
  66. package/src/features/git.ts +0 -368
  67. package/src/features/mail.ts +0 -374
  68. package/src/features/metrics.ts +0 -185
  69. package/src/features/note.ts +0 -189
  70. package/src/features/password.ts +0 -67
  71. package/src/features/project.ts +0 -709
  72. package/src/features/sentinel.ts +0 -233
  73. package/src/features/uptime.ts +0 -186
package/src/sdk/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
+ }
@@ -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(
@@ -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 repatriated NATIVE features: the
86
- * agent protocol is app infrastructure, not a third-party surface.
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
- | 'agents';
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 `'sources'` need a panel component, provided by your
95
- * client entry (`settingsPanels`), keyed by the tab id.
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 = 'general' | 'sources' | 'notifications' | 'permissions';
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, not preference. External modules: `'never'` only for now
156
- * (cross-workspace listing requires repo-level wiring not yet in the SDK).
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
- if ((m.nativeCapabilities ?? []).includes('agents') && external) {
279
- fail(m.id, "capability 'agents' is reserved for native-id modules");
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} »`);