@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
@@ -1,19 +1,16 @@
1
1
  import type { ZodType } from 'zod';
2
+ import type { FeatureDescriptor } from '../domain/featureRegistry';
2
3
  import {
3
4
  EXTERNAL_FEATURE_ID_PATTERN,
4
5
  isExternalFeatureId,
6
+ type ExternalFeatureId,
5
7
  type FeatureId
6
8
  } from '../domain/workspaceRole';
7
9
 
8
10
  /**
9
11
  * The feature manifest: everything DevEye needs to know about a feature,
10
- * declared once.
11
- *
12
- * Historically this knowledge was scattered across ~14 registration points
13
- * (descriptor, catalog entry, settings wiring tables, invalidation keys,
14
- * teleport segments...). A module declares all of it here; the app's generated
15
- * glue reads the manifest and wires every screen from it. Native features are
16
- * 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).
17
14
  */
18
15
 
19
16
  /**
@@ -76,23 +73,48 @@ export type NativeCapability =
76
73
  | 'notify'
77
74
  | 'mail.accounts'
78
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'
79
82
  /** Read/authorize the workspace's devices. */
80
83
  | 'devices.read'
81
84
  /**
82
85
  * The full agent-fleet sync transport (outbound requests, browser fan-out,
83
- * per-socket subscriptions). Reserved for repatriated NATIVE features: the
84
- * 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.
85
88
  */
86
- | 'agents';
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`.
101
+ */
102
+ | 'routes.public';
87
103
 
88
104
  /**
89
105
  * Settings tabs the shell can render for you.
90
106
  * - `'notifications'` and `'permissions'` are fully generic: DevEye renders
91
107
  * them from the manifest alone, you write no component.
92
- * - `'general'` and `'sources'` need a panel component, provided by your
93
- * 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.
94
115
  */
95
- export type SettingsTab = 'general' | 'sources' | 'notifications' | 'permissions';
116
+ export type SettingsTab =
117
+ 'general' | 'sources' | 'notifications' | 'permissions' | 'sync' | 'encryption';
96
118
 
97
119
  /** A custom settings tab. Needs a matching panel in `settingsPanels`. */
98
120
  export interface CustomTabRef {
@@ -125,6 +147,22 @@ export interface CrossTopicInvalidation {
125
147
  /** Where a feature's card can live on the home grid. */
126
148
  export type FeatureCategory = 'supervision' | 'security' | 'dev' | 'work' | 'daily' | 'analysis';
127
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
+
128
166
  export interface FeatureManifest<Id extends FeatureId = FeatureId> {
129
167
  /** External modules: `x-<slug>`. Native features keep their enum id. */
130
168
  id: Id;
@@ -150,14 +188,20 @@ export interface FeatureManifest<Id extends FeatureId = FeatureId> {
150
188
  sources?: { hint: string };
151
189
  /**
152
190
  * Whether an item can be projected into another workspace. Decided by
153
- * encryption, not preference. External modules: `'never'` only for now
154
- * (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.
155
196
  */
156
197
  shareTier: 'open' | 'perItem' | 'never';
157
198
 
158
199
  /** Card rendering on the home grid. `compact` halves the minimum height. */
159
200
  tile?: { compact?: boolean };
160
201
 
202
+ /** See {@link FeatureLink}. At most {@link MAX_FEATURE_LINKS}. */
203
+ links?: readonly FeatureLink[];
204
+
161
205
  /**
162
206
  * The feature offers a compact TOPBAR widget (pinned top-right of the
163
207
  * navbar). Declares only the DATA the picker shows; the component itself
@@ -184,6 +228,15 @@ export interface FeatureManifest<Id extends FeatureId = FeatureId> {
184
228
  */
185
229
  invalidatedByTopic?: readonly string[];
186
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
+
187
240
  /**
188
241
  * Keys ALSO re-fetched when another feature's topic fires. The escape
189
242
  * hatch for real data coupling (CloudSync's share rows carry device names:
@@ -200,13 +253,6 @@ export interface FeatureManifest<Id extends FeatureId = FeatureId> {
200
253
  */
201
254
  commandPrefix?: string;
202
255
 
203
- /**
204
- * Builds the live/teleport segment for one item, e.g. `(id) => `job:${id}``.
205
- * MUST return byte-for-byte what your view declares via `useLiveSegment('l1', ...)`:
206
- * it is a rendezvous, not a convention. Requires `hasItems`.
207
- */
208
- itemSegment?: (itemId: number | string) => string;
209
-
210
256
  /** Settings tabs, per scope. Omit a scope to render no settings there. */
211
257
  settings?: {
212
258
  feature?: readonly (SettingsTab | CustomTabRef)[];
@@ -231,7 +277,6 @@ export function validateManifest(m: FeatureManifest): void {
231
277
  if (!m.label.trim()) fail(m.id, 'empty label');
232
278
  if (!m.description.trim()) fail(m.id, 'empty description');
233
279
  if (m.hasItems && !m.itemNoun?.trim()) fail(m.id, 'hasItems requires itemNoun');
234
- if (!m.hasItems && m.itemSegment) fail(m.id, 'itemSegment requires hasItems');
235
280
  if (external && m.shareTier !== 'never') {
236
281
  fail(m.id, "external modules must declare shareTier 'never' for now");
237
282
  }
@@ -261,6 +306,27 @@ export function validateManifest(m: FeatureManifest): void {
261
306
  fail(m.id, `invalidatedByTopic « ${key} » is not in resources`);
262
307
  }
263
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
+ }
264
330
 
265
331
  const cross = m.alsoInvalidatedBy ?? [];
266
332
  if (cross.length > 4) fail(m.id, 'more than 4 alsoInvalidatedBy entries');
@@ -273,8 +339,10 @@ export function validateManifest(m: FeatureManifest): void {
273
339
  }
274
340
  }
275
341
 
276
- if ((m.nativeCapabilities ?? []).includes('agents') && external) {
277
- 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
+ }
278
346
  }
279
347
 
280
348
  const extras = m.extraPermissions ?? [];
@@ -313,6 +381,9 @@ export function validateManifest(m: FeatureManifest): void {
313
381
  if (tab === 'sources' && scope === 'item') {
314
382
  fail(m.id, 'sources is a feature-scope tab');
315
383
  }
384
+ if (tab === 'encryption' && scope === 'feature') {
385
+ fail(m.id, 'encryption is an item-scope tab');
386
+ }
316
387
  } else {
317
388
  if (!CUSTOM_TAB_PATTERN.test(tab.id) || BUILTIN_TABS.includes(tab.id)) {
318
389
  fail(m.id, `invalid custom tab id « ${tab.id} »`);
@@ -324,3 +395,67 @@ export function validateManifest(m: FeatureManifest): void {
324
395
  }
325
396
  }
326
397
  }
398
+
399
+ /**
400
+ * The registry descriptor of an EXTERNAL module, read off its manifest: what
401
+ * the roles screen, the settings shell and the catalog need to know without
402
+ * opening the module. Natives keep their published descriptor; the app's
403
+ * server and client registries both project through here.
404
+ */
405
+ export function externalDescriptorOf(
406
+ m: FeatureManifest
407
+ ): FeatureDescriptor & { id: ExternalFeatureId } {
408
+ if (!isExternalFeatureId(m.id)) {
409
+ throw new Error(`externalDescriptorOf: « ${m.id} » is not an external id`);
410
+ }
411
+ return {
412
+ id: m.id,
413
+ label: m.label,
414
+ description: m.description,
415
+ icon: m.icon,
416
+ notifies: m.notifies,
417
+ hasItems: m.hasItems,
418
+ itemNoun: m.itemNoun,
419
+ sources: m.sources,
420
+ shareTier: m.shareTier
421
+ };
422
+ }
423
+
424
+ export interface ExtrasResolver {
425
+ canExtra(key: string): boolean;
426
+ extraValue(key: string): string;
427
+ }
428
+
429
+ /**
430
+ * The runtime rules of extra permissions, shared by the app's request context
431
+ * and the test harness so the two can never drift:
432
+ * - a key the manifest does not declare, or of the other kind, yields
433
+ * `false` / `''`;
434
+ * - the workspace owner holds every toggle and gets `ownerValue` of every
435
+ * choice;
436
+ * - a member holds a toggle when the grant says `true`, and gets a choice's
437
+ * granted value when it is one of the options, `default` otherwise.
438
+ */
439
+ export function resolveExtras(
440
+ specs: readonly ExtraPermissionSpec[] | undefined,
441
+ isOwner: boolean,
442
+ granted: Readonly<Record<string, boolean | string>>
443
+ ): ExtrasResolver {
444
+ const byKey = new Map((specs ?? []).map((spec) => [spec.key, spec]));
445
+ return {
446
+ canExtra(key) {
447
+ const spec = byKey.get(key);
448
+ if (!spec || spec.type !== 'toggle') return false;
449
+ return isOwner || granted[key] === true;
450
+ },
451
+ extraValue(key) {
452
+ const spec = byKey.get(key);
453
+ if (!spec || spec.type !== 'choice') return '';
454
+ if (isOwner) return spec.ownerValue;
455
+ const value = granted[key];
456
+ return typeof value === 'string' && spec.options.some((o) => o.value === value)
457
+ ? value
458
+ : spec.default;
459
+ }
460
+ };
461
+ }
@@ -1,12 +1,14 @@
1
1
  /**
2
- * Provider contracts: the inversion for PUBLIC app code that needs a MODULE's
3
- * data.
2
+ * Provider contracts: the inversion for code that needs a MODULE's data,
3
+ * whether it is the app or another module.
4
4
  *
5
5
  * A module exposes named contracts on its service (`FeatureService.providers`);
6
6
  * the app looks them up at call time (`moduleProvider(key)` in its SDK
7
- * assembly) and degrades cleanly when the module is absent. The contract types
8
- * live here, in the published package, because both sides must agree on them
9
- * while neither may import the other.
7
+ * assembly, `ctx.providers.get(key)` from a module) and degrades cleanly when
8
+ * the module is absent. The contract types live here, in the published
9
+ * package, because both sides must agree on them while neither may import the
10
+ * other. Every provider is offered by a module's service: the app offers none
11
+ * itself.
10
12
  */
11
13
 
12
14
  /** Key under `FeatureService.providers` for the CloudSync backup source. */
@@ -46,3 +48,306 @@ export interface CloudSyncBackupProvider {
46
48
  /** Decrypted plaintext stream of one blob. */
47
49
  openBlob(shareId: number, hash: string): Promise<AsyncIterable<Uint8Array>>;
48
50
  }
51
+
52
+ /**
53
+ * Key of the database access the Backup module consumes: a logical dump goes
54
+ * through the SAME access as the monitoring (SSH tunnel or SOCKS proxy
55
+ * included), and only the Databases feature knows how to decrypt a connection.
56
+ */
57
+ export const DATABASE_BACKUP_PROVIDER = 'database.backup' as const;
58
+
59
+ /** A database of the workspace, as the source picker lists it. */
60
+ export interface DatabaseBackupCandidate {
61
+ id: number;
62
+ name: string;
63
+ engine: 'mysql' | 'postgres';
64
+ host: string;
65
+ database: string;
66
+ }
67
+
68
+ /**
69
+ * An OPEN access to a database: what a dump tool needs, reachable from the
70
+ * server (the tunnel's local listener when there is one). `close()` releases
71
+ * the tunnel; call it whatever happens, a forgotten tunnel keeps an SSH
72
+ * session and a listener alive.
73
+ */
74
+ export interface DatabaseBackupAccess {
75
+ engine: 'mysql' | 'postgres';
76
+ host: string;
77
+ port: number;
78
+ database: string;
79
+ username: string;
80
+ password: string | null;
81
+ close(): Promise<void>;
82
+ }
83
+
84
+ export interface DatabaseBackupProvider {
85
+ /** The databases of the workspace, its own only (a projection is not a source). */
86
+ listDatabases(workspaceId: number): Promise<readonly DatabaseBackupCandidate[]>;
87
+ findDatabase(databaseId: number, workspaceId: number): Promise<DatabaseBackupCandidate | null>;
88
+ /** null when the database is unknown to this workspace. */
89
+ openAccess(databaseId: number, workspaceId: number): Promise<DatabaseBackupAccess | null>;
90
+ }
91
+
92
+ /**
93
+ * Key under `FeatureService.providers` for the Databases items the Projects
94
+ * feature links to: same shape and reason as `UPTIME_ITEMS_PROVIDER`
95
+ * (`project_database_links` is Projects' table).
96
+ */
97
+ export const DATABASE_ITEMS_PROVIDER = 'database.items' as const;
98
+
99
+ export interface DatabaseItemsProvider {
100
+ /** Does this database live in this workspace? Its home only, never a projection. */
101
+ exists(databaseId: number, workspaceId: number): Promise<boolean>;
102
+ /**
103
+ * The item's display name under the OPEN cipher of `workspaceId`, its home;
104
+ * `null` when it is gone or unreadable. What a window onto a projected
105
+ * project shows for a link it cannot open: a name, never an id.
106
+ */
107
+ labelOf(databaseId: number, workspaceId: number): Promise<string | null>;
108
+ }
109
+
110
+ /**
111
+ * Key under `FeatureService.providers` for the workspace's outgoing mail: the
112
+ * transport the app's notification channels of kind `email` send through.
113
+ * Offered by the Mail module; absent, an email channel cannot be readied and
114
+ * the settings screen says so. Senders are the module's OPEN-tier, enabled
115
+ * accounts: a guarded mailbox needs a session unlock no background job has.
116
+ */
117
+ export const MAIL_TRANSPORT_PROVIDER = 'mail.transport' as const;
118
+
119
+ /** A mailbox able to send without anyone unlocking anything. */
120
+ export interface MailSender {
121
+ id: number;
122
+ label: string;
123
+ address: string;
124
+ }
125
+
126
+ export interface MailTransportProvider {
127
+ /** The workspace's ready senders (open tier, enabled). */
128
+ listSenders(workspaceId: number): Promise<readonly MailSender[]>;
129
+ /** Is this account a ready sender of this workspace right now? */
130
+ isReady(accountId: number, workspaceId: number): Promise<boolean>;
131
+ /**
132
+ * Sends one plain-text message from this account. Resolves `true` when the
133
+ * provider accepted it; `false` (never a throw) when the account is not a
134
+ * ready sender or the send failed, the failure logged by the module.
135
+ */
136
+ send(
137
+ accountId: number,
138
+ workspaceId: number,
139
+ message: { to: string; subject: string; text: string }
140
+ ): Promise<boolean>;
141
+ }
142
+
143
+ /**
144
+ * Key under `FeatureClient.providers` for the Mail pieces the app's settings
145
+ * shell composes: the ready senders an email channel picks from, and the
146
+ * feature's own account dialog (the "+" of the channel form). The contract
147
+ * types live in `@deveye/types/sdk/client`.
148
+ */
149
+ export const MAIL_CLIENT_PROVIDER = 'mail.client' as const;
150
+
151
+ /**
152
+ * Key under `FeatureService.providers` for the Audience sites the app's
153
+ * Projects feature links to: same shape and same reason as
154
+ * `UPTIME_ITEMS_PROVIDER` (`project_audience_links` is Projects' table).
155
+ */
156
+ export const AUDIENCE_ITEMS_PROVIDER = 'audience.items' as const;
157
+
158
+ export interface AudienceItemsProvider {
159
+ /** Does this site live in this workspace? Its home only, never a projection. */
160
+ exists(siteId: number, workspaceId: number): Promise<boolean>;
161
+ /**
162
+ * The item's display name under the OPEN cipher of `workspaceId`, its home;
163
+ * `null` when it is gone or unreadable. What a window onto a projected
164
+ * project shows for a link it cannot open: a name, never an id.
165
+ */
166
+ labelOf(siteId: number, workspaceId: number): Promise<string | null>;
167
+ }
168
+
169
+ /**
170
+ * Key under `FeatureClient.providers` for the Audience pieces the app's
171
+ * Projects screens compose: the list of the workspace's sites, a linked site
172
+ * shown in full inside a project's tab, and the feature's own site dialog.
173
+ * The contract types live in `@deveye/types/sdk/client`.
174
+ */
175
+ export const AUDIENCE_CLIENT_PROVIDER = 'audience.client' as const;
176
+
177
+ /**
178
+ * Key under `FeatureService.providers` for the Git repositories the app's
179
+ * Projects feature links to: same shape and same reason as
180
+ * `UPTIME_ITEMS_PROVIDER` (`project_repo_links` is Projects' table).
181
+ */
182
+ export const GIT_ITEMS_PROVIDER = 'git.items' as const;
183
+
184
+ export interface GitItemsProvider {
185
+ /** Does this repository live in this workspace? Its home only, never a projection. */
186
+ exists(repoId: number, workspaceId: number): Promise<boolean>;
187
+ /**
188
+ * The item's display name under the OPEN cipher of `workspaceId`, its home;
189
+ * `null` when it is gone or unreadable. What a window onto a projected
190
+ * project shows for a link it cannot open: a name, never an id.
191
+ */
192
+ labelOf(repoId: number, workspaceId: number): Promise<string | null>;
193
+ }
194
+
195
+ /**
196
+ * Key under `FeatureClient.providers` for the Git pieces the app's Projects
197
+ * screens compose: the list of the workspace's repositories, a linked
198
+ * repository shown in full inside a project's tab, and the feature's own
199
+ * repository dialog. The contract types live in `@deveye/types/sdk/client`.
200
+ */
201
+ export const GIT_CLIENT_PROVIDER = 'git.client' as const;
202
+
203
+ /**
204
+ * Key under `FeatureService.providers` for the Deploy targets the app's
205
+ * Projects feature links to: same shape and same reason as
206
+ * `UPTIME_ITEMS_PROVIDER` (`project_deploy_links` is Projects' table).
207
+ */
208
+ export const DEPLOY_ITEMS_PROVIDER = 'deploy.items' as const;
209
+
210
+ export interface DeployItemsProvider {
211
+ /** Does this target live in this workspace? Its home only, never a projection. */
212
+ exists(targetId: number, workspaceId: number): Promise<boolean>;
213
+ /**
214
+ * The item's display name under the OPEN cipher of `workspaceId`, its home;
215
+ * `null` when it is gone or unreadable. What a window onto a projected
216
+ * project shows for a link it cannot open: a name, never an id.
217
+ */
218
+ labelOf(targetId: number, workspaceId: number): Promise<string | null>;
219
+ }
220
+
221
+ /**
222
+ * Key under `FeatureClient.providers` for the Deploy pieces the app's
223
+ * Projects screens compose: the list of the workspace's targets, a linked
224
+ * target shown in full inside a project's tab, and the feature's own target
225
+ * dialog. The contract types live in `@deveye/types/sdk/client`.
226
+ */
227
+ export const DEPLOY_CLIENT_PROVIDER = 'deploy.client' as const;
228
+
229
+ /**
230
+ * Key under `FeatureClient.providers` for the Databases pieces the app's
231
+ * Projects screens compose: the list of the workspace's databases, a linked
232
+ * database shown in full inside a project's tab, and the feature's own
233
+ * database dialog (declaring a database from a project goes through the real
234
+ * form, never a reduced copy). The contract types live in
235
+ * `@deveye/types/sdk/client` (they are React components).
236
+ */
237
+ export const DATABASE_CLIENT_PROVIDER = 'database.client' as const;
238
+
239
+ /**
240
+ * Key of what Projects knows about the items of OTHER features: the projects
241
+ * of the workspace that link them (a module's list shows how many projects use
242
+ * each item, its detail lists them by title), without reading Projects'
243
+ * tables. Keyed by the linked feature's id so every linkable feature reads the
244
+ * same contract.
245
+ */
246
+ export const PROJECTS_USAGE_PROVIDER = 'projects.usage' as const;
247
+
248
+ /**
249
+ * A project that links an item. Open tier only: a guarded project cannot link
250
+ * a workspace item (its link row is plain, the item lives at the open tier),
251
+ * so every title here is readable without a session.
252
+ */
253
+ export interface ProjectUsage {
254
+ projectId: number;
255
+ title: string;
256
+ status: 'draft' | 'active' | 'paused' | 'done';
257
+ }
258
+
259
+ export interface ProjectsUsageProvider {
260
+ /** The workspace's projects linking this item of this feature, in Projects' display order. */
261
+ usageOf(feature: string, itemId: number, workspaceId: number): Promise<readonly ProjectUsage[]>;
262
+ /** How many projects of the workspace link each item of this feature (absent = zero). */
263
+ countByItem(feature: string, workspaceId: number): Promise<ReadonlyMap<number, number>>;
264
+ /**
265
+ * Writes one line in a project's timeline (a deployment triggered from a
266
+ * project's tab). Open tier only: an event aimed at a guarded project is
267
+ * dropped silently. Rejects when the write fails; a lost timeline line
268
+ * never turns a deployment into an error.
269
+ */
270
+ recordEvent(
271
+ projectId: number,
272
+ workspaceId: number,
273
+ event: { kind: string; label: string; actorUserId: number | null }
274
+ ): Promise<void>;
275
+ /**
276
+ * Reports a version on the projects linking this item that asked to
277
+ * follow it (Projects' `versionSource`, `'github_release'` for a git
278
+ * repository): the field then belongs to the item, and the project's
279
+ * screen shows it read-only. All linked projects of the workspace, open
280
+ * tier only; the others are left untouched.
281
+ */
282
+ applyVersion(
283
+ feature: string,
284
+ itemId: number,
285
+ workspaceId: number,
286
+ version: string
287
+ ): Promise<void>;
288
+ }
289
+
290
+ /**
291
+ * Key under `FeatureService.providers` for the Uptime items the app's Projects
292
+ * feature links to. Projects stores only identifiers; before linking one it
293
+ * asks the module whether the service exists in the workspace, so a foreign
294
+ * id can neither be linked nor leak its existence.
295
+ */
296
+ export const UPTIME_ITEMS_PROVIDER = 'uptime.items' as const;
297
+
298
+ export interface UptimeItemsProvider {
299
+ /** Does this service live in this workspace? Its home only, never a projection. */
300
+ exists(serviceId: number, workspaceId: number): Promise<boolean>;
301
+ /**
302
+ * The item's display name under the OPEN cipher of `workspaceId`, its home;
303
+ * `null` when it is gone or unreadable. What a window onto a projected
304
+ * project shows for a link it cannot open: a name, never an id.
305
+ */
306
+ labelOf(serviceId: number, workspaceId: number): Promise<string | null>;
307
+ }
308
+
309
+ /**
310
+ * Key under `FeatureClient.providers` for the Uptime pieces the app's Projects
311
+ * screens compose: the availability strip and ratios of a linked service, and
312
+ * the feature's own service dialog (declaring a service from a project goes
313
+ * through the real form, never a reduced copy). The contract types live in
314
+ * `@deveye/types/sdk/client` (they are React components).
315
+ */
316
+ export const UPTIME_CLIENT_PROVIDER = 'uptime.client' as const;
317
+
318
+ /**
319
+ * The client twin of the Devices module (`DevicesClientProvider`,
320
+ * `sdk/client.ts`): the workspace's devices as a store, one device's panel
321
+ * and tile. The app's home and topbar compose them; without the module they
322
+ * render nothing device-related.
323
+ */
324
+ export const DEVICES_CLIENT_PROVIDER = 'devices.client' as const;
325
+
326
+ /**
327
+ * Key under `FeatureService.providers` for what Sentinel contributes to the
328
+ * collection config the app pushes to an agent (`agent.config`): whether the
329
+ * security probes run, and at which cadence. Absent module: the app pushes the
330
+ * probes off.
331
+ */
332
+ export const SENTINEL_AGENT_CONFIG_PROVIDER = 'sentinel.agentConfig' as const;
333
+
334
+ /**
335
+ * Cadence du manifeste de persistance quand rien n'est réglé, en minutes.
336
+ * Vit ici et non dans le domaine du module parce que l'app l'applique
337
+ * elle-même : sans module installé, ou sans ligne de config pour l'appareil,
338
+ * la config poussée à l'agent porte ce défaut (sondes éteintes).
339
+ */
340
+ export const DEFAULT_SENTINEL_INTEGRITY_MINUTES = 360;
341
+
342
+ export interface SentinelAgentConfig {
343
+ enabled: boolean;
344
+ /** Cadence of the persistence manifest, in minutes. */
345
+ integrityMinutes: number;
346
+ /** Whether the agent reads the authentication journal. */
347
+ authEvents: boolean;
348
+ }
349
+
350
+ export interface SentinelAgentConfigProvider {
351
+ /** The device's contribution, or null when Sentinel knows nothing about it (probes off). */
352
+ configFor(deviceId: string): Promise<SentinelAgentConfig | null>;
353
+ }