@deveye/types 0.15.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deveye/types",
3
- "version": "0.15.1",
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
  },
@@ -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;
@@ -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
 
@@ -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
+ }