@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.
- package/package.json +9 -6
- package/src/domain/device.ts +4 -29
- package/src/domain/featureRegistry.ts +40 -108
- package/src/domain/home.ts +40 -101
- package/src/domain/live.ts +36 -87
- package/src/domain/metrics.ts +10 -14
- package/src/domain/notifications.ts +39 -110
- package/src/domain/project.ts +3 -162
- package/src/domain/report.ts +46 -95
- package/src/domain/secrecy.ts +3 -5
- package/src/domain/sharing.ts +33 -70
- package/src/domain/syncProtocol.ts +5 -12
- package/src/domain/user.ts +8 -14
- package/src/domain/workspace.ts +0 -3
- package/src/domain/workspaceRole.ts +34 -82
- package/src/features/agent.ts +306 -0
- package/src/features/live.ts +17 -37
- package/src/features/notify.ts +19 -57
- package/src/features/registry.ts +7 -44
- package/src/features/secrecy.ts +4 -9
- package/src/features/sharing.ts +12 -26
- package/src/features/user.ts +6 -11
- package/src/features/workspace.ts +6 -11
- package/src/http/auth.ts +6 -13
- package/src/http/device.ts +13 -17
- package/src/http/status.ts +6 -10
- package/src/index.ts +29 -932
- package/src/protocol/agent.ts +40 -70
- package/src/protocol/envelope.ts +3 -10
- package/src/sdk/client-ambient.d.ts +244 -22
- package/src/sdk/client.ts +277 -17
- package/src/sdk/devb.ts +120 -0
- package/src/sdk/manifest.test.ts +96 -0
- package/src/sdk/manifest.ts +160 -25
- package/src/sdk/providers.ts +310 -5
- package/src/sdk/server.ts +483 -19
- package/src/sdk/testing.test.ts +62 -0
- package/src/sdk/testing.ts +416 -40
- package/src/utils/version.ts +5 -8
- package/src/domain/audience.ts +0 -549
- package/src/domain/backup.ts +0 -355
- package/src/domain/credential.ts +0 -55
- package/src/domain/database.ts +0 -467
- package/src/domain/deploy.ts +0 -231
- package/src/domain/finance.ts +0 -477
- package/src/domain/git.ts +0 -419
- package/src/domain/mail.ts +0 -394
- package/src/domain/note.ts +0 -202
- package/src/domain/password.ts +0 -36
- package/src/domain/projectBoard.ts +0 -130
- package/src/domain/projectChat.ts +0 -46
- package/src/domain/projectHistory.ts +0 -82
- package/src/domain/projectLink.ts +0 -87
- package/src/domain/projectPlan.ts +0 -68
- package/src/domain/sentinel.ts +0 -623
- package/src/domain/uptime.ts +0 -216
- package/src/features/audience.ts +0 -275
- package/src/features/backup.ts +0 -230
- package/src/features/database.ts +0 -461
- package/src/features/deploy.ts +0 -245
- package/src/features/device.ts +0 -292
- package/src/features/deviceFiles.ts +0 -83
- package/src/features/deviceLogs.ts +0 -36
- package/src/features/deviceTerminal.ts +0 -57
- package/src/features/finance.ts +0 -360
- package/src/features/git.ts +0 -368
- package/src/features/mail.ts +0 -374
- package/src/features/metrics.ts +0 -185
- package/src/features/note.ts +0 -189
- package/src/features/password.ts +0 -67
- package/src/features/project.ts +0 -709
- package/src/features/sentinel.ts +0 -233
- package/src/features/uptime.ts +0 -186
|
@@ -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, testDevice } 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: [testDevice({ 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
|
+
});
|
package/src/sdk/testing.ts
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
import type { ZodType } from 'zod';
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import type { ItemAccess } from '../domain/sharing';
|
|
3
|
+
import { resolveExtras, type FeatureManifest } from './manifest';
|
|
4
|
+
import {
|
|
5
|
+
FeatureError,
|
|
6
|
+
type DevEyeFacade,
|
|
7
|
+
type FeatureServiceDeps,
|
|
8
|
+
type FeatureStore,
|
|
9
|
+
type SdkCipher,
|
|
10
|
+
type SdkDevice,
|
|
11
|
+
type SdkFeatureContext,
|
|
12
|
+
type SdkLogger,
|
|
13
|
+
type SdkProviders,
|
|
14
|
+
type SdkTelemetry,
|
|
15
|
+
type SdkTelemetrySnapshot,
|
|
16
|
+
type StorageEncryption,
|
|
17
|
+
SdkWorkspaceSummary
|
|
9
18
|
} from './server';
|
|
10
19
|
|
|
11
20
|
/**
|
|
12
|
-
* Test
|
|
13
|
-
*
|
|
14
|
-
*
|
|
21
|
+
* Test harnesses: a fully in-memory {@link SdkFeatureContext} for handlers and
|
|
22
|
+
* a matching {@link FeatureServiceDeps} for background services, with
|
|
23
|
+
* identity ciphers, a recording facade, and a silent logger. Call your code
|
|
24
|
+
* directly from `node:test` files; no app, no database, no socket.
|
|
15
25
|
*
|
|
16
26
|
* ```ts
|
|
17
|
-
* const ctx = createTestContext({ repo: fakeRepo() });
|
|
27
|
+
* const ctx = createTestContext({ repo: fakeRepo(), manifest });
|
|
18
28
|
* const out = await myFeature.features[0].handler(ctx, { name: 'x' });
|
|
19
29
|
* assert.equal(ctx.recorded.notifications.length, 1);
|
|
30
|
+
*
|
|
31
|
+
* const deps = createTestServiceDeps({ repo: fakeRepo() });
|
|
32
|
+
* const service = myServer.createService(deps);
|
|
33
|
+
* await deps.recorded.tickers[0].tick();
|
|
20
34
|
* ```
|
|
21
35
|
*/
|
|
22
36
|
|
|
@@ -26,6 +40,17 @@ const identityCipher: SdkCipher = {
|
|
|
26
40
|
tryDecrypt: (blob) => Promise.resolve(blob)
|
|
27
41
|
};
|
|
28
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The guarded cipher of a SEALED session: `encrypt` and `decrypt` throw
|
|
45
|
+
* `locked`, `tryDecrypt` answers null. Handed out for `'private'` when the
|
|
46
|
+
* harness says `unlocked: false`.
|
|
47
|
+
*/
|
|
48
|
+
const sealedCipher: SdkCipher = {
|
|
49
|
+
encrypt: () => Promise.reject(new FeatureError('locked', 'Password encryption is locked')),
|
|
50
|
+
decrypt: () => Promise.reject(new FeatureError('locked', 'Password encryption is locked')),
|
|
51
|
+
tryDecrypt: () => Promise.resolve(null)
|
|
52
|
+
};
|
|
53
|
+
|
|
29
54
|
const silentLogger: SdkLogger = {
|
|
30
55
|
debug: () => undefined,
|
|
31
56
|
info: () => undefined,
|
|
@@ -70,10 +95,94 @@ function memoryStore(): TestFeatureStore {
|
|
|
70
95
|
}
|
|
71
96
|
|
|
72
97
|
export interface RecordedCalls {
|
|
73
|
-
notifications: {
|
|
98
|
+
notifications: {
|
|
99
|
+
subject: string;
|
|
100
|
+
body: string;
|
|
101
|
+
itemId?: number;
|
|
102
|
+
embeds?: number;
|
|
103
|
+
except?: readonly number[];
|
|
104
|
+
}[];
|
|
105
|
+
/** Live messages posted (`messageId: null`) or edited through `notify.postLive`. */
|
|
106
|
+
liveMessages: { channelId: number; messageId: string | null; embeds?: number }[];
|
|
74
107
|
audits: { action: string; description: string }[];
|
|
75
108
|
/** Outbound agent frames, as `{ method, deviceId }` (payloads dropped for brevity). */
|
|
76
109
|
agentRequests: { method: string; deviceId: string }[];
|
|
110
|
+
/** Instants pinned through `telemetry.pinInstant`. */
|
|
111
|
+
pinnedInstants: { deviceId: string; ts: number }[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function recordingNotify(
|
|
115
|
+
recorded: RecordedCalls,
|
|
116
|
+
hasRoute: boolean,
|
|
117
|
+
accepted: boolean,
|
|
118
|
+
liveChannels: readonly number[]
|
|
119
|
+
): DevEyeFacade['notify'] {
|
|
120
|
+
let posted = 0;
|
|
121
|
+
return {
|
|
122
|
+
hasRoute: () => Promise.resolve(hasRoute),
|
|
123
|
+
send(alert, opts) {
|
|
124
|
+
recorded.notifications.push({
|
|
125
|
+
subject: alert.subject,
|
|
126
|
+
body: alert.body,
|
|
127
|
+
itemId: opts?.itemId,
|
|
128
|
+
// Only when the alert carries a layout: a test that
|
|
129
|
+
// deep-equals the plain record must not see the key appear.
|
|
130
|
+
...(alert.embeds ? { embeds: alert.embeds.length } : {}),
|
|
131
|
+
...(opts?.except ? { except: opts.except } : {})
|
|
132
|
+
});
|
|
133
|
+
// Recorded either way (the module did try), but a refused delivery
|
|
134
|
+
// answers false, so a test sees what the module does with it.
|
|
135
|
+
return Promise.resolve(accepted);
|
|
136
|
+
},
|
|
137
|
+
liveChannels: () => Promise.resolve(liveChannels.map((id) => ({ id }))),
|
|
138
|
+
postLive(channelId, message, messageId) {
|
|
139
|
+
recorded.liveMessages.push({
|
|
140
|
+
channelId,
|
|
141
|
+
messageId: messageId ?? null,
|
|
142
|
+
...(message.embeds ? { embeds: message.embeds.length } : {})
|
|
143
|
+
});
|
|
144
|
+
// A post mints an id (`live-1`, `live-2`...), an edit keeps the
|
|
145
|
+
// one it was given; a refusing host answers null either way.
|
|
146
|
+
if (!accepted) return Promise.resolve(null);
|
|
147
|
+
return Promise.resolve(messageId ?? `live-${++posted}`);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A device the harness invents for an id nothing listed: active, online, unreported. */
|
|
153
|
+
export function testDevice(over: Partial<SdkDevice> & { id: string }): SdkDevice {
|
|
154
|
+
return {
|
|
155
|
+
name: 'Test device',
|
|
156
|
+
online: true,
|
|
157
|
+
status: 'active',
|
|
158
|
+
ownerUserId: 1,
|
|
159
|
+
workspaceId: 1,
|
|
160
|
+
metricIntervalSeconds: null,
|
|
161
|
+
report: null,
|
|
162
|
+
...over
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function recordingDevices(devices: readonly SdkDevice[]): DevEyeFacade['devices'] {
|
|
167
|
+
return {
|
|
168
|
+
authorize: (id) => Promise.resolve(devices.find((d) => d.id === id) ?? testDevice({ id })),
|
|
169
|
+
list: () => Promise.resolve([...devices]),
|
|
170
|
+
isOnline: (id) => devices.find((d) => d.id === id)?.online ?? true
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function recordingTelemetry(
|
|
175
|
+
recorded: RecordedCalls,
|
|
176
|
+
snapshots: readonly SdkTelemetrySnapshot[]
|
|
177
|
+
): SdkTelemetry {
|
|
178
|
+
return {
|
|
179
|
+
snapshot: (_deviceId, ts) =>
|
|
180
|
+
Promise.resolve(snapshots.find((s) => Math.abs(s.ts - ts) <= 1000) ?? null),
|
|
181
|
+
pinInstant(deviceId, ts) {
|
|
182
|
+
recorded.pinnedInstants.push({ deviceId, ts });
|
|
183
|
+
return Promise.resolve();
|
|
184
|
+
}
|
|
185
|
+
};
|
|
77
186
|
}
|
|
78
187
|
|
|
79
188
|
function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
|
|
@@ -83,6 +192,8 @@ function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
|
|
|
83
192
|
};
|
|
84
193
|
return {
|
|
85
194
|
isOnline: () => true,
|
|
195
|
+
requestScan: req('requestScan'),
|
|
196
|
+
pushConfig: (deviceId) => Promise.resolve(req('pushConfig')(deviceId)),
|
|
86
197
|
requestSyncConfig: req('requestSyncConfig'),
|
|
87
198
|
requestSyncScan: req('requestSyncScan'),
|
|
88
199
|
requestSyncPush: req('requestSyncPush'),
|
|
@@ -93,13 +204,46 @@ function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
|
|
|
93
204
|
requestSyncMove: req('requestSyncMove'),
|
|
94
205
|
requestSyncDelete: req('requestSyncDelete'),
|
|
95
206
|
publishSyncProgress: () => undefined,
|
|
96
|
-
publishSyncState: () => undefined
|
|
207
|
+
publishSyncState: () => undefined,
|
|
208
|
+
requestDestroy: req('requestDestroy'),
|
|
209
|
+
disconnectAgent: req('disconnectAgent'),
|
|
210
|
+
resetAgentSession: req('resetAgentSession'),
|
|
211
|
+
// Nothing synced: a test of the self-update flag feeds a manifest to
|
|
212
|
+
// the module's own pure helper.
|
|
213
|
+
servedManifest: () => Promise.resolve(null),
|
|
214
|
+
requestFilesMutate: req('requestFilesMutate'),
|
|
215
|
+
requestFilesUpload: req('requestFilesUpload'),
|
|
216
|
+
// Every file order succeeds at once: a test of what a module does
|
|
217
|
+
// with a refusal injects its own facade through `deveye`.
|
|
218
|
+
awaitFilesOp: () => Promise.resolve({ ok: true }),
|
|
219
|
+
cancelFilesOp: () => undefined,
|
|
220
|
+
buffered: () => 0
|
|
97
221
|
};
|
|
98
222
|
}
|
|
99
223
|
|
|
224
|
+
/**
|
|
225
|
+
* A deterministic stand-in for `keys.derive`: the same (salt, info) yields
|
|
226
|
+
* the same bytes, distinct pairs distinct bytes, and nothing here is secret.
|
|
227
|
+
*/
|
|
228
|
+
function fakeDerive(salt: string, info: string, length: number): Uint8Array {
|
|
229
|
+
const out = new Uint8Array(length);
|
|
230
|
+
const seed = `${salt}|${info}`;
|
|
231
|
+
for (let i = 0; i < length; i += 1) {
|
|
232
|
+
out[i] = (seed.charCodeAt(i % seed.length) * (i + 1)) & 0xff;
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The named contracts a test hands to the module (`providers` override). */
|
|
238
|
+
function fakeProviders(table: Readonly<Record<string, unknown>>): SdkProviders {
|
|
239
|
+
return { get: <T>(key: string) => table[key] as T | undefined };
|
|
240
|
+
}
|
|
241
|
+
|
|
100
242
|
export interface TestContext<Repo> extends SdkFeatureContext<Repo> {
|
|
101
243
|
recorded: RecordedCalls;
|
|
102
244
|
store: TestFeatureStore;
|
|
245
|
+
/** Item ids passed to `items.forget`, in order. */
|
|
246
|
+
forgotten: number[];
|
|
103
247
|
}
|
|
104
248
|
|
|
105
249
|
export interface TestContextOverrides<Repo> {
|
|
@@ -108,61 +252,150 @@ export interface TestContextOverrides<Repo> {
|
|
|
108
252
|
workspaceId?: number;
|
|
109
253
|
kind?: 'personal' | 'shared';
|
|
110
254
|
isOwner?: boolean;
|
|
255
|
+
/** Global administrator. Default false. */
|
|
256
|
+
isAdmin?: boolean;
|
|
257
|
+
/** What `deveye.workspaces.list()` answers. Default none. */
|
|
258
|
+
workspaces?: readonly SdkWorkspaceSummary[];
|
|
111
259
|
canWrite?: boolean;
|
|
112
260
|
/** Extra permissions the caller holds, as the grant would carry them. */
|
|
113
261
|
extras?: Record<string, boolean | string>;
|
|
262
|
+
/**
|
|
263
|
+
* Your manifest: `canExtra` / `extraValue` then follow the exact runtime
|
|
264
|
+
* rules ({@link resolveExtras}). Without it no extra is declared, so
|
|
265
|
+
* every key answers `false` / `''`, owner or not.
|
|
266
|
+
*/
|
|
267
|
+
manifest?: Pick<FeatureManifest, 'extraPermissions'>;
|
|
114
268
|
/** What `deveye.notify.hasRoute` answers. Default true. */
|
|
115
269
|
hasRoute?: boolean;
|
|
270
|
+
/** What `deveye.notify.send` resolves (no usable channel: false). Default true; recorded either way. */
|
|
271
|
+
notifyAccepted?: boolean;
|
|
272
|
+
/** What `ctx.origins` answers. Default `https://deveye.test` / `https://public.deveye.test`. */
|
|
273
|
+
origins?: { app: string; public: string };
|
|
274
|
+
/** The channel ids `deveye.notify.liveChannels` lists. Default none. */
|
|
275
|
+
liveChannels?: readonly number[];
|
|
276
|
+
/** Devices `deveye.devices` reveals. Default none listed, any id authorized. */
|
|
277
|
+
devices?: readonly SdkDevice[];
|
|
278
|
+
/** Instants `deveye.telemetry.snapshot` answers (matched within a second). Default none. */
|
|
279
|
+
snapshots?: readonly SdkTelemetrySnapshot[];
|
|
116
280
|
/** Override facade members entirely when the defaults are not enough. */
|
|
117
281
|
deveye?: Partial<DevEyeFacade>;
|
|
282
|
+
/**
|
|
283
|
+
* What `secrecy.isUnlocked` answers. Default true. When false, the
|
|
284
|
+
* `'private'` cipher is sealed too (`decrypt` throws `locked`,
|
|
285
|
+
* `tryDecrypt` answers null), exactly like the app's guarded tier in a
|
|
286
|
+
* locked session; the `'server'` cipher stays the identity.
|
|
287
|
+
*/
|
|
288
|
+
unlocked?: boolean;
|
|
289
|
+
/** The caller's role restrictions on items, by item id. Default none. */
|
|
290
|
+
itemRestrictions?: Readonly<Record<number, ItemAccess>>;
|
|
291
|
+
/**
|
|
292
|
+
* Items projected INTO the workspace, as `itemId → home workspace id`.
|
|
293
|
+
* Default none: every item is at home. `sharing.scope().cipherFor` is the
|
|
294
|
+
* identity cipher either way.
|
|
295
|
+
*/
|
|
296
|
+
shares?: Readonly<Record<number, number>>;
|
|
297
|
+
/** The named contracts the host holds (`ctx.providers.get(key)`). */
|
|
298
|
+
providers?: Readonly<Record<string, unknown>>;
|
|
118
299
|
}
|
|
119
300
|
|
|
120
301
|
export function createTestContext<Repo = undefined>(
|
|
121
302
|
overrides: TestContextOverrides<Repo> = {}
|
|
122
303
|
): TestContext<Repo> {
|
|
123
|
-
const recorded: RecordedCalls = {
|
|
124
|
-
|
|
304
|
+
const recorded: RecordedCalls = {
|
|
305
|
+
notifications: [],
|
|
306
|
+
liveMessages: [],
|
|
307
|
+
audits: [],
|
|
308
|
+
agentRequests: [],
|
|
309
|
+
pinnedInstants: []
|
|
310
|
+
};
|
|
311
|
+
const isOwner = overrides.isOwner ?? true;
|
|
125
312
|
const workspaceId = overrides.workspaceId ?? 1;
|
|
313
|
+
const canWrite = overrides.canWrite ?? true;
|
|
126
314
|
const deveye: DevEyeFacade = {
|
|
127
|
-
notify:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
itemId: opts?.itemId
|
|
134
|
-
});
|
|
135
|
-
return Promise.resolve(true);
|
|
136
|
-
}
|
|
137
|
-
},
|
|
315
|
+
notify: recordingNotify(
|
|
316
|
+
recorded,
|
|
317
|
+
overrides.hasRoute ?? true,
|
|
318
|
+
overrides.notifyAccepted ?? true,
|
|
319
|
+
overrides.liveChannels ?? []
|
|
320
|
+
),
|
|
138
321
|
mail: { listAccounts: () => Promise.resolve([]) },
|
|
139
322
|
members: {
|
|
140
323
|
list: () =>
|
|
141
|
-
Promise.resolve([
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
authorize: (id) => Promise.resolve({ id, name: 'Test device', online: true }),
|
|
145
|
-
list: () => Promise.resolve([]),
|
|
146
|
-
isOnline: () => true
|
|
324
|
+
Promise.resolve([
|
|
325
|
+
{ userId: overrides.userId ?? 1, name: 'Test', isOwner: true, color: null }
|
|
326
|
+
])
|
|
147
327
|
},
|
|
328
|
+
workspaces: { list: () => Promise.resolve(overrides.workspaces ?? []) },
|
|
329
|
+
devices: recordingDevices(overrides.devices ?? []),
|
|
330
|
+
telemetry: recordingTelemetry(recorded, overrides.snapshots ?? []),
|
|
148
331
|
agents: recordingAgents(recorded),
|
|
149
332
|
...overrides.deveye
|
|
150
333
|
};
|
|
334
|
+
const restrictions = new Map<number, ItemAccess>(
|
|
335
|
+
Object.entries(overrides.itemRestrictions ?? {}).map(([id, access]) => [Number(id), access])
|
|
336
|
+
);
|
|
337
|
+
const homes = new Map<number, number>(
|
|
338
|
+
Object.entries(overrides.shares ?? {}).map(([id, home]) => [Number(id), home])
|
|
339
|
+
);
|
|
340
|
+
const forgotten: number[] = [];
|
|
151
341
|
return {
|
|
152
342
|
recorded,
|
|
343
|
+
forgotten,
|
|
344
|
+
secrecy: {
|
|
345
|
+
isUnlocked: () => Promise.resolve(overrides.unlocked ?? true),
|
|
346
|
+
// Un ticket lisible tel quel : le harnais ne signe rien, il
|
|
347
|
+
// sérialise, et `createTestServiceDeps().secrecy.redeem` relit.
|
|
348
|
+
ticket: (payload) =>
|
|
349
|
+
Promise.resolve(
|
|
350
|
+
`ticket:${JSON.stringify({ userId: overrides.userId ?? 1, workspaceId, payload, unlocked: overrides.unlocked ?? true })}`
|
|
351
|
+
)
|
|
352
|
+
},
|
|
353
|
+
keys: {
|
|
354
|
+
sealBytes: (plain) => `sealed:${Buffer.from(plain).toString('base64')}`,
|
|
355
|
+
openBytes: () => null,
|
|
356
|
+
derive: fakeDerive
|
|
357
|
+
},
|
|
358
|
+
items: {
|
|
359
|
+
restrictions: () => Promise.resolve(restrictions),
|
|
360
|
+
// The exact rule of the app's dispatcher: the feature first (a
|
|
361
|
+
// restriction can only lower), then the row.
|
|
362
|
+
assert(itemId, level = 'read') {
|
|
363
|
+
if (level === 'write' && !canWrite) {
|
|
364
|
+
return Promise.reject(new FeatureError('forbidden', 'write required'));
|
|
365
|
+
}
|
|
366
|
+
const restriction = restrictions.get(itemId);
|
|
367
|
+
if (restriction === 'none') {
|
|
368
|
+
return Promise.reject(new FeatureError('forbidden', 'item hidden'));
|
|
369
|
+
}
|
|
370
|
+
if (restriction === 'read' && level === 'write') {
|
|
371
|
+
return Promise.reject(new FeatureError('forbidden', 'item read-only'));
|
|
372
|
+
}
|
|
373
|
+
return Promise.resolve();
|
|
374
|
+
},
|
|
375
|
+
forget(itemId) {
|
|
376
|
+
forgotten.push(itemId);
|
|
377
|
+
return Promise.resolve();
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
sharing: {
|
|
381
|
+
scope: () =>
|
|
382
|
+
Promise.resolve({
|
|
383
|
+
foreignIds: new Set(homes.keys()),
|
|
384
|
+
homeOf: (itemId) => homes.get(itemId) ?? null,
|
|
385
|
+
cipherFor: () => Promise.resolve(identityCipher)
|
|
386
|
+
})
|
|
387
|
+
},
|
|
153
388
|
userId: overrides.userId ?? 1,
|
|
154
389
|
workspaceId,
|
|
155
390
|
workspace: { id: workspaceId, kind: overrides.kind ?? 'personal', name: 'Test' },
|
|
156
|
-
isOwner
|
|
391
|
+
isOwner,
|
|
392
|
+
isAdmin: overrides.isAdmin ?? false,
|
|
157
393
|
canWrite: overrides.canWrite ?? true,
|
|
158
|
-
|
|
159
|
-
extraValue: (key) => {
|
|
160
|
-
const value = extras[key];
|
|
161
|
-
return typeof value === 'string' ? value : '';
|
|
162
|
-
},
|
|
394
|
+
...resolveExtras(overrides.manifest?.extraPermissions, isOwner, overrides.extras ?? {}),
|
|
163
395
|
repo: overrides.repo as Repo,
|
|
164
396
|
store: memoryStore(),
|
|
165
|
-
cipher: () =>
|
|
397
|
+
cipher: (mode) =>
|
|
398
|
+
mode === 'private' && overrides.unlocked === false ? sealedCipher : identityCipher,
|
|
166
399
|
deveye,
|
|
167
400
|
transport: {
|
|
168
401
|
subscribeSync: () => undefined,
|
|
@@ -170,10 +403,153 @@ export function createTestContext<Repo = undefined>(
|
|
|
170
403
|
sendSyncChunk: () => 0,
|
|
171
404
|
syncChunkBuffered: () => 0
|
|
172
405
|
},
|
|
406
|
+
providers: fakeProviders(overrides.providers ?? {}),
|
|
173
407
|
audit: (entry) => {
|
|
174
408
|
recorded.audits.push({ action: entry.action, description: entry.description });
|
|
175
409
|
},
|
|
176
410
|
logger: silentLogger,
|
|
177
|
-
requestId: 'test'
|
|
411
|
+
requestId: 'test',
|
|
412
|
+
origins: overrides.origins ?? {
|
|
413
|
+
app: 'https://deveye.test',
|
|
414
|
+
public: 'https://public.deveye.test'
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export interface RecordedServiceCalls extends RecordedCalls {
|
|
420
|
+
/** Every `createTicker` call, so a test drives ticks by hand: `await tickers[0].tick()`. */
|
|
421
|
+
tickers: { intervalMs: number; tick(): Promise<void> }[];
|
|
422
|
+
/** Workspaces passed to `live.changed`, in order. */
|
|
423
|
+
liveChanges: number[];
|
|
424
|
+
/** `live.changed` calls that named topics, as `{ workspaceId, topics }` (a bare beat is not listed here). */
|
|
425
|
+
liveTopicChanges: { workspaceId: number; topics: readonly string[] }[];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export interface TestServiceDeps<Repo> extends FeatureServiceDeps<Repo> {
|
|
429
|
+
recorded: RecordedServiceCalls;
|
|
430
|
+
/** One in-memory store per workspace touched, keyed by workspace id. */
|
|
431
|
+
stores: Map<number, TestFeatureStore>;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export interface TestServiceOverrides<Repo> {
|
|
435
|
+
repo?: Repo;
|
|
436
|
+
/** What `listWorkspaceIds` answers. Default `[1]`. */
|
|
437
|
+
workspaceIds?: readonly number[];
|
|
438
|
+
/** Devices every workspace reveals. Default none. */
|
|
439
|
+
devices?: readonly SdkDevice[];
|
|
440
|
+
/** What `deveyeFor(...).notify.hasRoute` answers. Default true. */
|
|
441
|
+
hasRoute?: boolean;
|
|
442
|
+
/** What `deveyeFor(...).notify.send` resolves. Default true; recorded either way. */
|
|
443
|
+
notifyAccepted?: boolean;
|
|
444
|
+
/** The channel ids `deveyeFor(...).notify.liveChannels` lists. Default none. */
|
|
445
|
+
liveChannels?: readonly number[];
|
|
446
|
+
/** What `deps.origins` answers. Default `https://deveye.test` / `https://public.deveye.test`. */
|
|
447
|
+
origins?: { app: string; public: string };
|
|
448
|
+
/** Instants `telemetry.snapshot` answers (matched within a second). Default none. */
|
|
449
|
+
snapshots?: readonly SdkTelemetrySnapshot[];
|
|
450
|
+
/** The named contracts the host holds (`deps.providers.get(key)`). */
|
|
451
|
+
providers?: Readonly<Record<string, unknown>>;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The service twin of {@link createTestContext}: sessionless, so no guarded
|
|
456
|
+
* cipher and no `'private'` rows, exactly like the app. Tickers never start on
|
|
457
|
+
* their own; the test calls `recorded.tickers[i].tick()` when it wants a beat.
|
|
458
|
+
*/
|
|
459
|
+
export function createTestServiceDeps<Repo = undefined>(
|
|
460
|
+
overrides: TestServiceOverrides<Repo> = {}
|
|
461
|
+
): TestServiceDeps<Repo> {
|
|
462
|
+
const recorded: RecordedServiceCalls = {
|
|
463
|
+
notifications: [],
|
|
464
|
+
liveMessages: [],
|
|
465
|
+
audits: [],
|
|
466
|
+
agentRequests: [],
|
|
467
|
+
pinnedInstants: [],
|
|
468
|
+
tickers: [],
|
|
469
|
+
liveChanges: [],
|
|
470
|
+
liveTopicChanges: []
|
|
471
|
+
};
|
|
472
|
+
const stores = new Map<number, TestFeatureStore>();
|
|
473
|
+
const sealedBytes = new Map<string, Uint8Array>();
|
|
474
|
+
const notify = recordingNotify(
|
|
475
|
+
recorded,
|
|
476
|
+
overrides.hasRoute ?? true,
|
|
477
|
+
overrides.notifyAccepted ?? true,
|
|
478
|
+
overrides.liveChannels ?? []
|
|
479
|
+
);
|
|
480
|
+
const devices = recordingDevices(overrides.devices ?? []);
|
|
481
|
+
return {
|
|
482
|
+
recorded,
|
|
483
|
+
stores,
|
|
484
|
+
repo: overrides.repo as Repo,
|
|
485
|
+
listWorkspaceIds: () => Promise.resolve([...(overrides.workspaceIds ?? [1])]),
|
|
486
|
+
storeFor(workspaceId) {
|
|
487
|
+
let store = stores.get(workspaceId);
|
|
488
|
+
if (!store) {
|
|
489
|
+
store = memoryStore();
|
|
490
|
+
stores.set(workspaceId, store);
|
|
491
|
+
}
|
|
492
|
+
return store;
|
|
493
|
+
},
|
|
494
|
+
cipherFor: () => identityCipher,
|
|
495
|
+
deveyeFor: () => ({ notify }),
|
|
496
|
+
origins: overrides.origins ?? {
|
|
497
|
+
app: 'https://deveye.test',
|
|
498
|
+
public: 'https://public.deveye.test'
|
|
499
|
+
},
|
|
500
|
+
secrecy: {
|
|
501
|
+
redeem: (ticket) => {
|
|
502
|
+
if (!ticket.startsWith('ticket:')) return Promise.resolve(null);
|
|
503
|
+
const parsed = JSON.parse(ticket.slice('ticket:'.length)) as {
|
|
504
|
+
userId: number;
|
|
505
|
+
workspaceId: number;
|
|
506
|
+
payload: unknown;
|
|
507
|
+
unlocked: boolean;
|
|
508
|
+
};
|
|
509
|
+
return Promise.resolve({
|
|
510
|
+
userId: parsed.userId,
|
|
511
|
+
workspaceId: parsed.workspaceId,
|
|
512
|
+
payload: parsed.payload,
|
|
513
|
+
cipher: {
|
|
514
|
+
server: identityCipher,
|
|
515
|
+
private: parsed.unlocked ? identityCipher : null
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
devicesFor: () => ({ list: devices.list, isOnline: devices.isOnline }),
|
|
521
|
+
devices: {
|
|
522
|
+
find: (id) =>
|
|
523
|
+
Promise.resolve((overrides.devices ?? []).find((d) => d.id === id) ?? null),
|
|
524
|
+
isOnline: devices.isOnline
|
|
525
|
+
},
|
|
526
|
+
telemetry: recordingTelemetry(recorded, overrides.snapshots ?? []),
|
|
527
|
+
live: {
|
|
528
|
+
changed(workspaceId, topics) {
|
|
529
|
+
recorded.liveChanges.push(workspaceId);
|
|
530
|
+
if (topics) recorded.liveTopicChanges.push({ workspaceId, topics });
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
audit: (entry) => {
|
|
534
|
+
recorded.audits.push({ action: entry.action, description: entry.description });
|
|
535
|
+
},
|
|
536
|
+
agents: recordingAgents(recorded),
|
|
537
|
+
// A fake wrapper: the sealed string is a handle to the bytes, and an
|
|
538
|
+
// unknown handle opens to `null` exactly like a tampered blob would.
|
|
539
|
+
keys: {
|
|
540
|
+
sealBytes(plain) {
|
|
541
|
+
const handle = `sealed:${sealedBytes.size}`;
|
|
542
|
+
sealedBytes.set(handle, Uint8Array.from(plain));
|
|
543
|
+
return handle;
|
|
544
|
+
},
|
|
545
|
+
openBytes: (sealed) => sealedBytes.get(sealed) ?? null,
|
|
546
|
+
derive: fakeDerive
|
|
547
|
+
},
|
|
548
|
+
providers: fakeProviders(overrides.providers ?? {}),
|
|
549
|
+
createTicker({ intervalMs, tick }) {
|
|
550
|
+
recorded.tickers.push({ intervalMs, tick });
|
|
551
|
+
return { start: () => undefined, stop: () => undefined };
|
|
552
|
+
},
|
|
553
|
+
logger: silentLogger
|
|
178
554
|
};
|
|
179
555
|
}
|
package/src/utils/version.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dotted-numeric version helpers shared by the server (
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* on what "newer" means — a self-update is only ever offered/pushed as an UPGRADE,
|
|
6
|
-
* never a downgrade, even if the served manifest happens to lag a running agent.
|
|
2
|
+
* Dotted-numeric version helpers shared by the server (offers/pushes an agent
|
|
3
|
+
* self-update) and the web client (shows the affordance), so both agree on what
|
|
4
|
+
* "newer" means: a self-update is only ever an upgrade, never a downgrade.
|
|
7
5
|
*
|
|
8
|
-
* Non-numeric
|
|
9
|
-
*
|
|
10
|
-
* come from a single `package.json`, so plain dotted integers are enough.
|
|
6
|
+
* Non-numeric and missing segments count as 0, so `1.2` equals `1.2.0`. No
|
|
7
|
+
* pre-release handling: DevEye versions are plain dotted integers.
|
|
11
8
|
*/
|
|
12
9
|
|
|
13
10
|
/** Compare dotted numeric versions: <0 if a<b, >0 if a>b, 0 if equal. */
|