@deveye/types 0.15.0 → 0.15.2

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # deveye-types
1
+ # @deveye/types
2
2
 
3
3
  Les contrats partagés de [DevEye](https://github.com/Gerem66/DevEye) : types
4
4
  TypeScript et schémas zod, consommés par le serveur, le client et les modules
@@ -6,14 +6,14 @@ de features.
6
6
 
7
7
  Le package sert ses sources directement (`src/*.ts`, aucun build) :
8
8
 
9
- - **`deveye-types`** — le domaine transverse (espaces, rôles, live, partage…),
9
+ - **`@deveye/types`** — le domaine transverse (espaces, rôles, live, partage…),
10
10
  les protocoles (enveloppe WS, agent), le registre d'identité des features.
11
- - **`deveye-types/sdk`** — le contrat des modules de features : `FeatureManifest`,
11
+ - **`@deveye/types/sdk`** — le contrat des modules de features : `FeatureManifest`,
12
12
  `validateManifest`, ids `x-<slug>`.
13
- - **`deveye-types/sdk/server`** — le contexte serveur d'un module (handlers,
13
+ - **`@deveye/types/sdk/server`** — le contexte serveur d'un module (handlers,
14
14
  store, façade, service).
15
- - **`deveye-types/sdk/client`** — les contrats de l'entrée client d'un module.
16
- - **`deveye-types/sdk/testing`** — le harnais de test en mémoire des handlers.
15
+ - **`@deveye/types/sdk/client`** — les contrats de l'entrée client d'un module.
16
+ - **`@deveye/types/sdk/testing`** — le harnais de test en mémoire des handlers.
17
17
 
18
18
  Le portrait typé du barrel `deveye-sdk-client` (le runtime que l'app fournit
19
19
  aux modules) est publié ici aussi (`src/sdk/client-ambient.d.ts`) ; la CI de
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deveye/types",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Shared contracts (types + zod schemas) for DevEye apps",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -41,9 +41,10 @@
41
41
  "lint": "eslint .",
42
42
  "lint:fix": "eslint . --fix",
43
43
  "typecheck": "tsc --noEmit",
44
- "ci": "npm run lint && npm run typecheck",
44
+ "ci": "npm run lint && npm run typecheck && npm test",
45
45
  "prettier": "prettier --check .",
46
- "prettier:fix": "prettier --write ."
46
+ "prettier:fix": "prettier --write .",
47
+ "test": "tsx --test \"src/**/*.test.ts\""
47
48
  },
48
49
  "dependencies": {
49
50
  "zod": "^4.4.3"
@@ -54,11 +55,13 @@
54
55
  },
55
56
  "devDependencies": {
56
57
  "@eslint/js": "^8.0.0",
58
+ "@types/node": "^26.3.0",
57
59
  "@types/react": "^19.0.0",
58
60
  "@typescript-eslint/eslint-plugin": "^6.0.0",
59
61
  "@typescript-eslint/parser": "^6.0.0",
60
62
  "eslint": "^8.0.0",
61
63
  "eslint-plugin-prettier": "^5.5.4",
64
+ "tsx": "^4.23.12",
62
65
  "typescript": "^5.0.0",
63
66
  "typescript-eslint": "^8.39.1"
64
67
  },
@@ -283,7 +283,7 @@ export const audienceOverviewSchema = z.object({
283
283
  export type AudienceOverview = z.infer<typeof audienceOverviewSchema>;
284
284
 
285
285
  export const audienceBreakdownItemSchema = z.object({
286
- /** Le libellé déchiffré. Vide quand il l'est resté (clé d'espace convertie). */
286
+ /** Le libellé déchiffré, vide si le blob est illisible. */
287
287
  label: z.string(),
288
288
  views: z.number().int().nonnegative(),
289
289
  visitors: z.number().int().nonnegative()
@@ -10,8 +10,8 @@ import { featureAccessSchema, workspaceFeatureIdSchema } from './workspaceRole';
10
10
  * Un élément partagé **ne change jamais de clé**. Il reste chiffré sous celle de
11
11
  * son espace d'origine ; servi ailleurs, il est déchiffré avec le codec ouvert
12
12
  * de cet espace-là. C'est le prolongement direct du levier L3 de
13
- * `WORKSPACES.md` « un espace résout les clés de son propriétaire » et la
14
- * raison pour laquelle ce chantier ne re-chiffre rien.
13
+ * `WORKSPACES.md`, « chaque espace a sa clé, et un blob n'en change jamais »,
14
+ * et la raison pour laquelle ce chantier ne re-chiffre rien.
15
15
  *
16
16
  * `WORKSPACES.md` §10 range **déplacer** un élément hors périmètre, précisément
17
17
  * parce que ce serait la seule opération à exiger un déchiffrement clé A puis un
@@ -89,33 +89,6 @@ export const workspaceAddMember = {
89
89
  output: z.object({ workspace: workspaceSchema })
90
90
  };
91
91
 
92
- /**
93
- * État de la clé d'espace : déjà active, applicable, et ce qui l'empêcherait.
94
- */
95
- export const workspaceSharedKeyStatus = {
96
- command: 'workspace.sharedKeyStatus' as const,
97
- input: z.object({}),
98
- output: z.object({
99
- /** L'espace utilise déjà sa propre clé. */
100
- enabled: z.boolean(),
101
- /** Faux pour un espace personnel, qui garde la clé de son propriétaire. */
102
- applicable: z.boolean(),
103
- /** Obstacles à lever avant conversion, rédigés pour l'utilisateur. */
104
- blockers: z.array(z.string())
105
- })
106
- };
107
-
108
- /**
109
- * Convertit l'espace vers sa propre clé. Exige le propriétaire, session
110
- * déverrouillée : le contenu existant doit être relu avec sa clé avant d'être
111
- * réécrit sous celle de l'espace.
112
- */
113
- export const workspaceEnableSharedKey = {
114
- command: 'workspace.enableSharedKey' as const,
115
- input: z.object({}),
116
- output: z.object({ converted: z.number().int().nonnegative() })
117
- };
118
-
119
92
  /** Le contenu modifiable d'un rôle. */
120
93
  const roleDraftSchema = z.object({
121
94
  name: z.string().min(1).max(WORKSPACE_ROLE_NAME_MAX),
@@ -187,8 +160,6 @@ export const workspaceCommands = [
187
160
  workspaceRoleDelete,
188
161
  workspaceRoleSetDefault,
189
162
  workspaceAssignRole,
190
- workspaceSharedKeyStatus,
191
- workspaceEnableSharedKey,
192
163
  workspaceRename,
193
164
  workspaceLeave,
194
165
  workspaceRemoveMember,
package/src/index.ts CHANGED
@@ -1449,7 +1449,6 @@ export {
1449
1449
  workspaceAdd,
1450
1450
  workspaceCommands,
1451
1451
  workspaceDelete,
1452
- workspaceEnableSharedKey,
1453
1452
  workspaceLeave,
1454
1453
  workspaceRemoveMember,
1455
1454
  workspaceAddMember,
@@ -1460,8 +1459,7 @@ export {
1460
1459
  workspaceRoleList,
1461
1460
  workspaceRoleSetDefault,
1462
1461
  workspaceRoleUpdate,
1463
- workspaceSetFavorite,
1464
- workspaceSharedKeyStatus
1462
+ workspaceSetFavorite
1465
1463
  } from './features/workspace';
1466
1464
  export {
1467
1465
  AVATAR_MAX_LENGTH,
@@ -25,8 +25,8 @@ declare module 'deveye-sdk-client' {
25
25
  SelectHTMLAttributes
26
26
  } from 'react';
27
27
  import type { z, ZodType } from 'zod';
28
- import type { FeatureAccess, FeatureId, WorkspaceCapability } from 'deveye-types';
29
- import type { FeatureManifest } from 'deveye-types/sdk';
28
+ import type { FeatureAccess, FeatureId, WorkspaceCapability } from '@deveye/types';
29
+ import type { FeatureManifest } from '@deveye/types/sdk';
30
30
 
31
31
  // ── UI kit ─────────────────────────────────────────────────────────────
32
32
  export const Button: ComponentType<
@@ -78,6 +78,15 @@ declare module 'deveye-sdk-client' {
78
78
  fill?: boolean;
79
79
  holdSecrecy?: boolean;
80
80
  }>;
81
+ /** Request the enclosing Dialog's guarded close (the unsaved-changes prompt included). */
82
+ export function useDialogClose(): () => void;
83
+ /** Register `fn` as the enclosing Dialog's primary action (Enter triggers it); `null` clears it. */
84
+ export function useDialogSubmit(fn: (() => void) | null): void;
85
+ /**
86
+ * Register `onEscape` as the topmost dismissible layer while `open`, so
87
+ * Escape closes overlays innermost first. `null` absorbs Escape without closing.
88
+ */
89
+ export function useDismissLayer(open: boolean, onEscape: (() => void) | null): void;
81
90
  export const StatusBadge: ComponentType<{
82
91
  tone?: 'online' | 'offline' | 'success' | 'warning' | 'danger' | 'accent' | 'neutral';
83
92
  /** Show the leading status dot (default true). */
@@ -158,8 +167,14 @@ declare module 'deveye-sdk-client' {
158
167
  export interface SecrecyState {
159
168
  /** True when password-based encryption is enabled for the account. */
160
169
  enabled: boolean;
161
- /** True while the session holds the unlocked key. */
170
+ /** True while the session holds the unlocked key (no prompt needed). */
162
171
  unlocked: boolean;
172
+ /** True while the unlock dialog is open. */
173
+ prompting: boolean;
174
+ /** Epoch ms at which the grace window expires, or null when nothing counts down. */
175
+ unlockedUntil: number | null;
176
+ /** "Validate on every action": the key is never cached, unlocking ahead of time is pointless. */
177
+ alwaysPrompt: boolean;
163
178
  }
164
179
  /** Live lock state of the session (shared with the topbar widget and the global prompt). */
165
180
  export function useSecrecy(): SecrecyState;
package/src/sdk/index.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * The DevEye feature SDK, shared surface: ids and the manifest contract.
3
3
  *
4
4
  * Side-specific surfaces live in their own entries so neither bundle drags the
5
- * other's world in: `deveye-types/sdk/server` (handlers, storage, facade),
6
- * `deveye-types/sdk/client` (component contracts), `deveye-types/sdk/testing`
5
+ * other's world in: `@deveye/types/sdk/server` (handlers, storage, facade),
6
+ * `@deveye/types/sdk/client` (component contracts), `@deveye/types/sdk/testing`
7
7
  * (handler test harness).
8
8
  */
9
9
  export * from './ids';
@@ -0,0 +1,97 @@
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({ itemSegment: (id) => `item:${id}` }, 'itemSegment');
35
+ rejects({ shareTier: 'open' }, "shareTier 'never'");
36
+ rejects({ commandPrefix: 'x-demo.' }, 'commandPrefix');
37
+ rejects(
38
+ { commands: [{ command: 'other.get', input: z.object({}), output: z.object({}) }] },
39
+ 'x-demo'
40
+ );
41
+ });
42
+
43
+ test('externalDescriptorOf projects the identity fields only, and refuses a native id', () => {
44
+ assert.deepEqual(
45
+ externalDescriptorOf({ ...base, itemNoun: 'thing', sources: { hint: 'keys' } }),
46
+ {
47
+ id: 'x-demo',
48
+ label: 'Demo',
49
+ description: 'A demo module.',
50
+ icon: 'x-demo-icon',
51
+ notifies: false,
52
+ hasItems: false,
53
+ itemNoun: 'thing',
54
+ sources: { hint: 'keys' },
55
+ shareTier: 'never'
56
+ }
57
+ );
58
+ assert.throws(() => externalDescriptorOf({ ...base, id: 'weather' }), /not an external id/);
59
+ });
60
+
61
+ test('resolveExtras: the owner holds everything, a member what the grant says, unknown keys nothing', () => {
62
+ const specs: FeatureManifest['extraPermissions'] = [
63
+ { key: 'reset', type: 'toggle', label: 'Reset', description: '' },
64
+ {
65
+ key: 'limit',
66
+ type: 'choice',
67
+ label: 'Limit',
68
+ description: '',
69
+ options: [
70
+ { value: 'low', label: 'Low' },
71
+ { value: 'high', label: 'High' }
72
+ ],
73
+ default: 'low',
74
+ ownerValue: 'high'
75
+ }
76
+ ];
77
+ const owner = resolveExtras(specs, true, {});
78
+ assert.equal(owner.canExtra('reset'), true);
79
+ assert.equal(owner.extraValue('limit'), 'high');
80
+
81
+ const member = resolveExtras(specs, false, { reset: true, limit: 'high' });
82
+ assert.equal(member.canExtra('reset'), true);
83
+ assert.equal(member.extraValue('limit'), 'high');
84
+
85
+ const restricted = resolveExtras(specs, false, { reset: false, limit: 'bogus' });
86
+ assert.equal(restricted.canExtra('reset'), false);
87
+ assert.equal(
88
+ restricted.extraValue('limit'),
89
+ 'low',
90
+ 'a value outside the options falls back to the default'
91
+ );
92
+
93
+ // Wrong kind or undeclared: nothing, owner or not.
94
+ assert.equal(owner.canExtra('limit'), false);
95
+ assert.equal(owner.extraValue('reset'), '');
96
+ assert.equal(resolveExtras(undefined, true, { reset: true }).canExtra('reset'), false);
97
+ });
@@ -1,7 +1,9 @@
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
 
@@ -18,7 +20,7 @@ import {
18
20
 
19
21
  /**
20
22
  * One WebSocket command contract. Identical in shape to the descriptors native
21
- * features declare in `deveye-types/src/features/*`: the server validates the
23
+ * features declare in `@deveye/types/src/features/*`: the server validates the
22
24
  * input before your handler runs and the output after it returns, both ways,
23
25
  * so a payload that does not match a schema never crosses the wire.
24
26
  */
@@ -324,3 +326,67 @@ export function validateManifest(m: FeatureManifest): void {
324
326
  }
325
327
  }
326
328
  }
329
+
330
+ /**
331
+ * The registry descriptor of an EXTERNAL module, read off its manifest: what
332
+ * the roles screen, the settings shell and the catalog need to know without
333
+ * opening the module. Natives keep their published descriptor; the app's
334
+ * server and client registries both project through here.
335
+ */
336
+ export function externalDescriptorOf(
337
+ m: FeatureManifest
338
+ ): FeatureDescriptor & { id: ExternalFeatureId } {
339
+ if (!isExternalFeatureId(m.id)) {
340
+ throw new Error(`externalDescriptorOf: « ${m.id} » is not an external id`);
341
+ }
342
+ return {
343
+ id: m.id,
344
+ label: m.label,
345
+ description: m.description,
346
+ icon: m.icon,
347
+ notifies: m.notifies,
348
+ hasItems: m.hasItems,
349
+ itemNoun: m.itemNoun,
350
+ sources: m.sources,
351
+ shareTier: m.shareTier
352
+ };
353
+ }
354
+
355
+ export interface ExtrasResolver {
356
+ canExtra(key: string): boolean;
357
+ extraValue(key: string): string;
358
+ }
359
+
360
+ /**
361
+ * The runtime rules of extra permissions, shared by the app's request context
362
+ * and the test harness so the two can never drift:
363
+ * - a key the manifest does not declare, or of the other kind, yields
364
+ * `false` / `''`;
365
+ * - the workspace owner holds every toggle and gets `ownerValue` of every
366
+ * choice;
367
+ * - a member holds a toggle when the grant says `true`, and gets a choice's
368
+ * granted value when it is one of the options, `default` otherwise.
369
+ */
370
+ export function resolveExtras(
371
+ specs: readonly ExtraPermissionSpec[] | undefined,
372
+ isOwner: boolean,
373
+ granted: Readonly<Record<string, boolean | string>>
374
+ ): ExtrasResolver {
375
+ const byKey = new Map((specs ?? []).map((spec) => [spec.key, spec]));
376
+ return {
377
+ canExtra(key) {
378
+ const spec = byKey.get(key);
379
+ if (!spec || spec.type !== 'toggle') return false;
380
+ return isOwner || granted[key] === true;
381
+ },
382
+ extraValue(key) {
383
+ const spec = byKey.get(key);
384
+ if (!spec || spec.type !== 'choice') return '';
385
+ if (isOwner) return spec.ownerValue;
386
+ const value = granted[key];
387
+ return typeof value === 'string' && spec.options.some((o) => o.value === value)
388
+ ? value
389
+ : spec.default;
390
+ }
391
+ };
392
+ }
@@ -0,0 +1,62 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { z } from 'zod';
4
+
5
+ import { createTestContext, createTestServiceDeps } from './testing';
6
+
7
+ test('createTestContext follows the manifest for extras, and records what handlers do', async () => {
8
+ const manifest = {
9
+ extraPermissions: [
10
+ { key: 'reset', type: 'toggle' as const, label: 'Reset', description: '' }
11
+ ]
12
+ };
13
+ const member = createTestContext({ manifest, isOwner: false, extras: { reset: true } });
14
+ assert.equal(member.canExtra('reset'), true);
15
+ assert.equal(createTestContext({ manifest, isOwner: false }).canExtra('reset'), false);
16
+ assert.equal(
17
+ createTestContext({ isOwner: true }).canExtra('reset'),
18
+ false,
19
+ 'no manifest, no extra'
20
+ );
21
+
22
+ await member.deveye.notify.send({ subject: 's', body: 'b' }, { itemId: 3 });
23
+ member.audit({ action: 'x.did', description: 'did' });
24
+ assert.deepEqual(member.recorded.notifications, [{ subject: 's', body: 'b', itemId: 3 }]);
25
+ assert.deepEqual(member.recorded.audits, [{ action: 'x.did', description: 'did' }]);
26
+
27
+ await member.store.putJson(
28
+ 'k',
29
+ z.object({ n: z.number() }),
30
+ { n: 1 },
31
+ { encryption: 'private' }
32
+ );
33
+ assert.equal(member.store.rows.get('k')?.mode, 'private');
34
+ });
35
+
36
+ test('createTestServiceDeps: one store per workspace, hand-driven tickers, a wrapper that round-trips', async () => {
37
+ const deps = createTestServiceDeps({
38
+ workspaceIds: [1, 2],
39
+ devices: [{ id: 'd1', name: 'One', online: false }]
40
+ });
41
+ assert.deepEqual(await deps.listWorkspaceIds(), [1, 2]);
42
+ await deps.storeFor(1).put('a', '1');
43
+ assert.equal(await deps.storeFor(2).get('a'), null);
44
+ assert.equal(deps.stores.size, 2);
45
+
46
+ let beats = 0;
47
+ const service = deps.createTicker({
48
+ intervalMs: 1000,
49
+ tick: () => Promise.resolve(void beats++)
50
+ });
51
+ await service.start();
52
+ assert.equal(beats, 0, 'a ticker never starts on its own');
53
+ await deps.recorded.tickers[0].tick();
54
+ assert.equal(beats, 1);
55
+
56
+ const sealed = deps.keys.sealBytes(new Uint8Array([1, 2, 3]));
57
+ assert.deepEqual(deps.keys.openBytes(sealed), new Uint8Array([1, 2, 3]));
58
+ assert.equal(deps.keys.openBytes('nope'), null);
59
+
60
+ assert.equal(deps.devicesFor(1).isOnline('d1'), false);
61
+ assert.equal((await deps.devicesFor(1).list()).length, 1);
62
+ });
@@ -1,22 +1,30 @@
1
1
  import type { ZodType } from 'zod';
2
+ import { resolveExtras, type FeatureManifest } from './manifest';
2
3
  import type {
3
4
  DevEyeFacade,
5
+ FeatureServiceDeps,
4
6
  FeatureStore,
5
7
  SdkCipher,
8
+ SdkDevice,
6
9
  SdkFeatureContext,
7
10
  SdkLogger,
8
11
  StorageEncryption
9
12
  } from './server';
10
13
 
11
14
  /**
12
- * Test harness for feature handlers: a fully in-memory {@link SdkFeatureContext}
13
- * with identity ciphers, a recording facade, and a silent logger. Call your
14
- * handlers directly from `node:test` files; no app, no database, no socket.
15
+ * Test harnesses: a fully in-memory {@link SdkFeatureContext} for handlers and
16
+ * a matching {@link FeatureServiceDeps} for background services, with
17
+ * identity ciphers, a recording facade, and a silent logger. Call your code
18
+ * directly from `node:test` files; no app, no database, no socket.
15
19
  *
16
20
  * ```ts
17
- * const ctx = createTestContext({ repo: fakeRepo() });
21
+ * const ctx = createTestContext({ repo: fakeRepo(), manifest });
18
22
  * const out = await myFeature.features[0].handler(ctx, { name: 'x' });
19
23
  * assert.equal(ctx.recorded.notifications.length, 1);
24
+ *
25
+ * const deps = createTestServiceDeps({ repo: fakeRepo() });
26
+ * const service = myServer.createService(deps);
27
+ * await deps.recorded.tickers[0].tick();
20
28
  * ```
21
29
  */
22
30
 
@@ -76,6 +84,31 @@ export interface RecordedCalls {
76
84
  agentRequests: { method: string; deviceId: string }[];
77
85
  }
78
86
 
87
+ function recordingNotify(recorded: RecordedCalls, hasRoute: boolean): DevEyeFacade['notify'] {
88
+ return {
89
+ hasRoute: () => Promise.resolve(hasRoute),
90
+ send(alert, opts) {
91
+ recorded.notifications.push({
92
+ subject: alert.subject,
93
+ body: alert.body,
94
+ itemId: opts?.itemId
95
+ });
96
+ return Promise.resolve(true);
97
+ }
98
+ };
99
+ }
100
+
101
+ function recordingDevices(devices: readonly SdkDevice[]): DevEyeFacade['devices'] {
102
+ return {
103
+ authorize: (id) =>
104
+ Promise.resolve(
105
+ devices.find((d) => d.id === id) ?? { id, name: 'Test device', online: true }
106
+ ),
107
+ list: () => Promise.resolve([...devices]),
108
+ isOnline: (id) => devices.find((d) => d.id === id)?.online ?? true
109
+ };
110
+ }
111
+
79
112
  function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
80
113
  const req = (method: string) => (deviceId: string) => {
81
114
  recorded.agentRequests.push({ method, deviceId });
@@ -111,8 +144,16 @@ export interface TestContextOverrides<Repo> {
111
144
  canWrite?: boolean;
112
145
  /** Extra permissions the caller holds, as the grant would carry them. */
113
146
  extras?: Record<string, boolean | string>;
147
+ /**
148
+ * Your manifest: `canExtra` / `extraValue` then follow the exact runtime
149
+ * rules ({@link resolveExtras}). Without it no extra is declared, so
150
+ * every key answers `false` / `''`, owner or not.
151
+ */
152
+ manifest?: Pick<FeatureManifest, 'extraPermissions'>;
114
153
  /** What `deveye.notify.hasRoute` answers. Default true. */
115
154
  hasRoute?: boolean;
155
+ /** Devices `deveye.devices` reveals. Default none listed, any id authorized. */
156
+ devices?: readonly SdkDevice[];
116
157
  /** Override facade members entirely when the defaults are not enough. */
117
158
  deveye?: Partial<DevEyeFacade>;
118
159
  }
@@ -121,30 +162,16 @@ export function createTestContext<Repo = undefined>(
121
162
  overrides: TestContextOverrides<Repo> = {}
122
163
  ): TestContext<Repo> {
123
164
  const recorded: RecordedCalls = { notifications: [], audits: [], agentRequests: [] };
124
- const extras = overrides.extras ?? {};
165
+ const isOwner = overrides.isOwner ?? true;
125
166
  const workspaceId = overrides.workspaceId ?? 1;
126
167
  const deveye: DevEyeFacade = {
127
- notify: {
128
- hasRoute: () => Promise.resolve(overrides.hasRoute ?? true),
129
- send(alert, opts) {
130
- recorded.notifications.push({
131
- subject: alert.subject,
132
- body: alert.body,
133
- itemId: opts?.itemId
134
- });
135
- return Promise.resolve(true);
136
- }
137
- },
168
+ notify: recordingNotify(recorded, overrides.hasRoute ?? true),
138
169
  mail: { listAccounts: () => Promise.resolve([]) },
139
170
  members: {
140
171
  list: () =>
141
172
  Promise.resolve([{ userId: overrides.userId ?? 1, name: 'Test', isOwner: true }])
142
173
  },
143
- devices: {
144
- authorize: (id) => Promise.resolve({ id, name: 'Test device', online: true }),
145
- list: () => Promise.resolve([]),
146
- isOnline: () => true
147
- },
174
+ devices: recordingDevices(overrides.devices ?? []),
148
175
  agents: recordingAgents(recorded),
149
176
  ...overrides.deveye
150
177
  };
@@ -153,13 +180,9 @@ export function createTestContext<Repo = undefined>(
153
180
  userId: overrides.userId ?? 1,
154
181
  workspaceId,
155
182
  workspace: { id: workspaceId, kind: overrides.kind ?? 'personal', name: 'Test' },
156
- isOwner: overrides.isOwner ?? true,
183
+ isOwner,
157
184
  canWrite: overrides.canWrite ?? true,
158
- canExtra: (key) => (overrides.isOwner ?? true) || extras[key] === true,
159
- extraValue: (key) => {
160
- const value = extras[key];
161
- return typeof value === 'string' ? value : '';
162
- },
185
+ ...resolveExtras(overrides.manifest?.extraPermissions, isOwner, overrides.extras ?? {}),
163
186
  repo: overrides.repo as Repo,
164
187
  store: memoryStore(),
165
188
  cipher: () => identityCipher,
@@ -177,3 +200,80 @@ export function createTestContext<Repo = undefined>(
177
200
  requestId: 'test'
178
201
  };
179
202
  }
203
+
204
+ export interface RecordedServiceCalls extends RecordedCalls {
205
+ /** Every `createTicker` call, so a test drives ticks by hand: `await tickers[0].tick()`. */
206
+ tickers: { intervalMs: number; tick(): Promise<void> }[];
207
+ }
208
+
209
+ export interface TestServiceDeps<Repo> extends FeatureServiceDeps<Repo> {
210
+ recorded: RecordedServiceCalls;
211
+ /** One in-memory store per workspace touched, keyed by workspace id. */
212
+ stores: Map<number, TestFeatureStore>;
213
+ }
214
+
215
+ export interface TestServiceOverrides<Repo> {
216
+ repo?: Repo;
217
+ /** What `listWorkspaceIds` answers. Default `[1]`. */
218
+ workspaceIds?: readonly number[];
219
+ /** Devices every workspace reveals. Default none. */
220
+ devices?: readonly SdkDevice[];
221
+ /** What `deveyeFor(...).notify.hasRoute` answers. Default true. */
222
+ hasRoute?: boolean;
223
+ }
224
+
225
+ /**
226
+ * The service twin of {@link createTestContext}: sessionless, so no guarded
227
+ * cipher and no `'private'` rows, exactly like the app. Tickers never start on
228
+ * their own; the test calls `recorded.tickers[i].tick()` when it wants a beat.
229
+ */
230
+ export function createTestServiceDeps<Repo = undefined>(
231
+ overrides: TestServiceOverrides<Repo> = {}
232
+ ): TestServiceDeps<Repo> {
233
+ const recorded: RecordedServiceCalls = {
234
+ notifications: [],
235
+ audits: [],
236
+ agentRequests: [],
237
+ tickers: []
238
+ };
239
+ const stores = new Map<number, TestFeatureStore>();
240
+ const sealedBytes = new Map<string, Uint8Array>();
241
+ const notify = recordingNotify(recorded, overrides.hasRoute ?? true);
242
+ const devices = recordingDevices(overrides.devices ?? []);
243
+ return {
244
+ recorded,
245
+ stores,
246
+ repo: overrides.repo as Repo,
247
+ listWorkspaceIds: () => Promise.resolve([...(overrides.workspaceIds ?? [1])]),
248
+ storeFor(workspaceId) {
249
+ let store = stores.get(workspaceId);
250
+ if (!store) {
251
+ store = memoryStore();
252
+ stores.set(workspaceId, store);
253
+ }
254
+ return store;
255
+ },
256
+ cipherFor: () => identityCipher,
257
+ deveyeFor: () => ({ notify }),
258
+ devicesFor: () => ({ list: devices.list, isOnline: devices.isOnline }),
259
+ audit: (entry) => {
260
+ recorded.audits.push({ action: entry.action, description: entry.description });
261
+ },
262
+ agents: recordingAgents(recorded),
263
+ // A fake wrapper: the sealed string is a handle to the bytes, and an
264
+ // unknown handle opens to `null` exactly like a tampered blob would.
265
+ keys: {
266
+ sealBytes(plain) {
267
+ const handle = `sealed:${sealedBytes.size}`;
268
+ sealedBytes.set(handle, Uint8Array.from(plain));
269
+ return handle;
270
+ },
271
+ openBytes: (sealed) => sealedBytes.get(sealed) ?? null
272
+ },
273
+ createTicker({ intervalMs, tick }) {
274
+ recorded.tickers.push({ intervalMs, tick });
275
+ return { start: () => undefined, stop: () => undefined };
276
+ },
277
+ logger: silentLogger
278
+ };
279
+ }