@deveye/types 0.15.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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +68 -0
- package/src/domain/audience.ts +549 -0
- package/src/domain/backup.ts +355 -0
- package/src/domain/credential.ts +55 -0
- package/src/domain/database.ts +467 -0
- package/src/domain/deploy.ts +231 -0
- package/src/domain/device.ts +172 -0
- package/src/domain/deviceFiles.ts +84 -0
- package/src/domain/deviceLogs.ts +82 -0
- package/src/domain/featureRegistry.ts +392 -0
- package/src/domain/finance.ts +477 -0
- package/src/domain/git.ts +419 -0
- package/src/domain/home.ts +314 -0
- package/src/domain/live.ts +272 -0
- package/src/domain/logs.ts +117 -0
- package/src/domain/mail.ts +394 -0
- package/src/domain/metrics.ts +127 -0
- package/src/domain/note.ts +202 -0
- package/src/domain/notifications.ts +268 -0
- package/src/domain/packages.ts +35 -0
- package/src/domain/password.ts +36 -0
- package/src/domain/presence.ts +21 -0
- package/src/domain/project.ts +168 -0
- package/src/domain/projectBoard.ts +130 -0
- package/src/domain/projectChat.ts +46 -0
- package/src/domain/projectHistory.ts +82 -0
- package/src/domain/projectLink.ts +87 -0
- package/src/domain/projectPlan.ts +68 -0
- package/src/domain/report.ts +492 -0
- package/src/domain/role.ts +8 -0
- package/src/domain/secrecy.ts +66 -0
- package/src/domain/sentinel.ts +623 -0
- package/src/domain/sharing.ts +186 -0
- package/src/domain/syncProtocol.ts +116 -0
- package/src/domain/twoFactor.ts +40 -0
- package/src/domain/uptime.ts +216 -0
- package/src/domain/user.ts +141 -0
- package/src/domain/workspace.ts +56 -0
- package/src/domain/workspaceRole.ts +251 -0
- package/src/features/admin.ts +112 -0
- package/src/features/audience.ts +275 -0
- package/src/features/backup.ts +230 -0
- package/src/features/database.ts +461 -0
- package/src/features/deploy.ts +245 -0
- package/src/features/device.ts +292 -0
- package/src/features/deviceFiles.ts +83 -0
- package/src/features/deviceLogs.ts +36 -0
- package/src/features/deviceTerminal.ts +57 -0
- package/src/features/finance.ts +360 -0
- package/src/features/git.ts +368 -0
- package/src/features/home.ts +32 -0
- package/src/features/live.ts +113 -0
- package/src/features/logs.ts +86 -0
- package/src/features/mail.ts +374 -0
- package/src/features/metrics.ts +185 -0
- package/src/features/note.ts +189 -0
- package/src/features/notify.ts +164 -0
- package/src/features/password.ts +67 -0
- package/src/features/project.ts +709 -0
- package/src/features/registry.ts +103 -0
- package/src/features/secrecy.ts +120 -0
- package/src/features/sentinel.ts +233 -0
- package/src/features/sharing.ts +79 -0
- package/src/features/twoFactor.ts +47 -0
- package/src/features/uptime.ts +186 -0
- package/src/features/user.ts +91 -0
- package/src/features/workspace.ts +200 -0
- package/src/http/auth.ts +94 -0
- package/src/http/device.ts +222 -0
- package/src/http/status.ts +45 -0
- package/src/index.ts +1700 -0
- package/src/protocol/agent.ts +1171 -0
- package/src/protocol/envelope.ts +46 -0
- package/src/protocol/error.ts +27 -0
- package/src/protocol/result.ts +17 -0
- package/src/protocol/version.ts +6 -0
- package/src/sdk/client-ambient.d.ts +238 -0
- package/src/sdk/client.ts +66 -0
- package/src/sdk/ids.ts +25 -0
- package/src/sdk/index.ts +11 -0
- package/src/sdk/manifest.ts +326 -0
- package/src/sdk/providers.ts +48 -0
- package/src/sdk/server.ts +378 -0
- package/src/sdk/testing.ts +179 -0
- package/src/utils/version.ts +28 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import type { z, ZodType } from 'zod';
|
|
2
|
+
import type { ErrorCode } from '../protocol/error';
|
|
3
|
+
import type { FeatureAccess } from '../domain/workspaceRole';
|
|
4
|
+
import type { LogLevelName } from '../domain/logs';
|
|
5
|
+
import type {
|
|
6
|
+
AgentSyncAckPayload,
|
|
7
|
+
AgentSyncApplyChunkPayload,
|
|
8
|
+
AgentSyncApplyDirPayload,
|
|
9
|
+
AgentSyncApplyLocalPayload,
|
|
10
|
+
AgentSyncApplyStartPayload,
|
|
11
|
+
AgentSyncChangedPayload,
|
|
12
|
+
AgentSyncChunkPayload,
|
|
13
|
+
AgentSyncConfigPayload,
|
|
14
|
+
AgentSyncDeletePayload,
|
|
15
|
+
AgentSyncIndexPayload,
|
|
16
|
+
AgentSyncMovePayload,
|
|
17
|
+
AgentSyncOpResultPayload,
|
|
18
|
+
AgentSyncPushPayload,
|
|
19
|
+
AgentSyncScanPayload,
|
|
20
|
+
CloudSyncChunkPush,
|
|
21
|
+
CloudSyncProgressPush,
|
|
22
|
+
CloudSyncStatePush
|
|
23
|
+
} from '../protocol/agent';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Server-side SDK surface: what a feature module's handlers and background
|
|
27
|
+
* service are given, and nothing else.
|
|
28
|
+
*
|
|
29
|
+
* This is a deliberate subset of the app's internal context. A module never
|
|
30
|
+
* sees the full repo bundle, raw ciphers, or other features' data; it reaches
|
|
31
|
+
* native features only through the declared {@link DevEyeFacade}.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Structural subset of the app logger (pino-compatible). */
|
|
35
|
+
export interface SdkLogger {
|
|
36
|
+
debug(obj: unknown, msg?: string): void;
|
|
37
|
+
info(obj: unknown, msg?: string): void;
|
|
38
|
+
warn(obj: unknown, msg?: string): void;
|
|
39
|
+
error(obj: unknown, msg?: string): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The typed error a handler throws to reply with a clean protocol error.
|
|
44
|
+
* Anything else becomes an opaque `internal` error. Codes you will use:
|
|
45
|
+
* `validation`, `forbidden`, `not_found`, `conflict`, `locked`, `internal`.
|
|
46
|
+
*/
|
|
47
|
+
export class FeatureError extends Error {
|
|
48
|
+
constructor(
|
|
49
|
+
public readonly code: ErrorCode,
|
|
50
|
+
message: string,
|
|
51
|
+
public readonly details?: unknown
|
|
52
|
+
) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'FeatureError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A cipher over strings. `decrypt` throws a {@link FeatureError} (`locked`
|
|
60
|
+
* when the guarded tier is sealed, `forbidden` for a foreign caller);
|
|
61
|
+
* `tryDecrypt` returns `null` instead, for listings that degrade gracefully.
|
|
62
|
+
*/
|
|
63
|
+
export interface SdkCipher {
|
|
64
|
+
encrypt(plaintext: string): Promise<string>;
|
|
65
|
+
decrypt(blob: string): Promise<string>;
|
|
66
|
+
tryDecrypt(blob: string): Promise<string | null>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How a stored value is protected. THE rule to remember:
|
|
71
|
+
*
|
|
72
|
+
* - `'server'` (the default): encrypted at rest, the server can always read
|
|
73
|
+
* it. Right for API keys, tokens, cached data. Works in handlers AND in
|
|
74
|
+
* background services.
|
|
75
|
+
* - `'private'`: for user secrets the server operator must not be able to
|
|
76
|
+
* read when password encryption is on. Handlers only. Reads can throw
|
|
77
|
+
* `locked` or `forbidden`; your UI must tolerate both. NEVER available in
|
|
78
|
+
* background services: if a scheduler needs the value, store a `'server'`
|
|
79
|
+
* projection instead.
|
|
80
|
+
* - `'none'`: plaintext, for non-sensitive metadata you want to query in SQL.
|
|
81
|
+
*/
|
|
82
|
+
export type StorageEncryption = 'server' | 'private' | 'none';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Per-feature, per-workspace key-value storage. The simplest way to persist:
|
|
86
|
+
* no table, no migration, encryption in one argument. For relational data,
|
|
87
|
+
* declare your own tables and a repo instead (see {@link FeatureServer}).
|
|
88
|
+
*/
|
|
89
|
+
export interface FeatureStore {
|
|
90
|
+
put(key: string, value: string, opts?: { encryption?: StorageEncryption }): Promise<void>;
|
|
91
|
+
putJson<T>(
|
|
92
|
+
key: string,
|
|
93
|
+
schema: ZodType<T>,
|
|
94
|
+
value: T,
|
|
95
|
+
opts?: { encryption?: StorageEncryption }
|
|
96
|
+
): Promise<void>;
|
|
97
|
+
/** Decrypts according to how the row was written. */
|
|
98
|
+
get(key: string): Promise<string | null>;
|
|
99
|
+
getJson<T>(key: string, schema: ZodType<T>): Promise<T | null>;
|
|
100
|
+
remove(key: string): Promise<void>;
|
|
101
|
+
keys(prefix?: string): Promise<string[]>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The background-service variant: `'private'` is unrepresentable here, and
|
|
106
|
+
* `get()` on a `'private'` row throws `locked`. This is by design, not a
|
|
107
|
+
* missing feature: the private tier only exists inside an unlocked user
|
|
108
|
+
* session, which a scheduler never has.
|
|
109
|
+
*/
|
|
110
|
+
export interface SessionlessFeatureStore {
|
|
111
|
+
put(key: string, value: string, opts?: { encryption?: 'server' | 'none' }): Promise<void>;
|
|
112
|
+
putJson<T>(
|
|
113
|
+
key: string,
|
|
114
|
+
schema: ZodType<T>,
|
|
115
|
+
value: T,
|
|
116
|
+
opts?: { encryption?: 'server' | 'none' }
|
|
117
|
+
): Promise<void>;
|
|
118
|
+
get(key: string): Promise<string | null>;
|
|
119
|
+
getJson<T>(key: string, schema: ZodType<T>): Promise<T | null>;
|
|
120
|
+
remove(key: string): Promise<void>;
|
|
121
|
+
keys(prefix?: string): Promise<string[]>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Access to your feature's own tables. Deliberately thin and workspace-unaware:
|
|
126
|
+
* your repo filters by `workspace_id` itself, with `?` placeholders, exactly
|
|
127
|
+
* like every native repo. Table names must carry your `ft_<slug>_` prefix
|
|
128
|
+
* (checked at build time). Repos never encrypt: handlers pass values already
|
|
129
|
+
* sealed through `ctx.cipher(...)`.
|
|
130
|
+
*/
|
|
131
|
+
export interface SdkQueryable {
|
|
132
|
+
query<T extends object>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
133
|
+
execute(sql: string, params?: unknown[]): Promise<{ affectedRows: number; insertId: number }>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Native features, reachable only if declared in `manifest.nativeCapabilities`.
|
|
138
|
+
* An undeclared call throws `forbidden`.
|
|
139
|
+
*/
|
|
140
|
+
export interface DevEyeFacade {
|
|
141
|
+
/** Requires capability `'notify'`. Uses the channels/routes the workspace configured for YOUR feature. */
|
|
142
|
+
notify: {
|
|
143
|
+
/** Is at least one usable channel routed to this target? */
|
|
144
|
+
hasRoute(itemId?: number): Promise<boolean>;
|
|
145
|
+
/** Delivers to the configured channels. Resolves `true` if at least one accepted. */
|
|
146
|
+
send(
|
|
147
|
+
alert: { subject: string; body: string; payload?: Record<string, unknown> },
|
|
148
|
+
opts?: { itemId?: number }
|
|
149
|
+
): Promise<boolean>;
|
|
150
|
+
};
|
|
151
|
+
/** Requires capability `'mail.accounts'`. Open-tier accounts, metadata only, never credentials. */
|
|
152
|
+
mail: {
|
|
153
|
+
listAccounts(): Promise<
|
|
154
|
+
ReadonlyArray<{ id: number; label: string; address: string | null }>
|
|
155
|
+
>;
|
|
156
|
+
};
|
|
157
|
+
/** Requires capability `'members.read'`. */
|
|
158
|
+
members: {
|
|
159
|
+
list(): Promise<ReadonlyArray<{ userId: number; name: string; isOwner: boolean }>>;
|
|
160
|
+
};
|
|
161
|
+
/** Requires capability `'devices.read'`. */
|
|
162
|
+
devices: {
|
|
163
|
+
/** Throws `not_found` unless the device exists AND belongs to this workspace. */
|
|
164
|
+
authorize(deviceId: string): Promise<SdkDevice>;
|
|
165
|
+
list(): Promise<readonly SdkDevice[]>;
|
|
166
|
+
isOnline(deviceId: string): boolean;
|
|
167
|
+
};
|
|
168
|
+
/** Requires capability `'agents'`. Same object as the service deps' `agents`. */
|
|
169
|
+
agents: AgentsFacade;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The agent-fleet sync transport (capability `'agents'`, native-id modules
|
|
174
|
+
* only). Method names and semantics mirror the app's MonitorHub exactly, so a
|
|
175
|
+
* repatriated engine swaps its hub handle for this facade and changes nothing
|
|
176
|
+
* else. Outbound calls return `false` when the agent is offline (frame
|
|
177
|
+
* dropped, never queued).
|
|
178
|
+
*/
|
|
179
|
+
export interface AgentsFacade {
|
|
180
|
+
isOnline(deviceId: string): boolean;
|
|
181
|
+
requestSyncConfig(deviceId: string, payload: AgentSyncConfigPayload): boolean;
|
|
182
|
+
requestSyncScan(deviceId: string, payload: AgentSyncScanPayload): boolean;
|
|
183
|
+
requestSyncPush(deviceId: string, payload: AgentSyncPushPayload): boolean;
|
|
184
|
+
requestSyncApplyChunk(deviceId: string, payload: AgentSyncApplyChunkPayload): boolean;
|
|
185
|
+
requestSyncApplyStart(deviceId: string, payload: AgentSyncApplyStartPayload): boolean;
|
|
186
|
+
requestSyncApplyDir(deviceId: string, payload: AgentSyncApplyDirPayload): boolean;
|
|
187
|
+
requestSyncApplyLocal(deviceId: string, payload: AgentSyncApplyLocalPayload): boolean;
|
|
188
|
+
requestSyncMove(deviceId: string, payload: AgentSyncMovePayload): boolean;
|
|
189
|
+
requestSyncDelete(deviceId: string, payload: AgentSyncDeletePayload): boolean;
|
|
190
|
+
/** Fan-out to the browsers subscribed to the payload's share. */
|
|
191
|
+
publishSyncProgress(payload: CloudSyncProgressPush): void;
|
|
192
|
+
publishSyncState(payload: CloudSyncStatePush): void;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The caller's own browser socket (capability `'agents'`): live subscriptions
|
|
197
|
+
* and chunked downloads with backpressure. Mirrors the app's MonitorTransport
|
|
198
|
+
* sync subset.
|
|
199
|
+
*/
|
|
200
|
+
export interface SdkSocketTransport {
|
|
201
|
+
subscribeSync(shareIds: number[]): void;
|
|
202
|
+
unsubscribeSync(shareIds: number[]): void;
|
|
203
|
+
/** Returns the socket's send-buffer size after the frame, for backpressure. */
|
|
204
|
+
sendSyncChunk(payload: CloudSyncChunkPush): number;
|
|
205
|
+
syncChunkBuffered(): number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Inbound agent events, dispatched by the app's agent socket layer to the
|
|
210
|
+
* modules that declare `'agents'`. Every hook is optional; an absent hook is a
|
|
211
|
+
* no-op. Hooks may fire before your service's `start()` has completed: drop
|
|
212
|
+
* quietly in that case, the agent will resend or reconcile.
|
|
213
|
+
*/
|
|
214
|
+
export interface FeatureAgentHooks {
|
|
215
|
+
onAgentConnect?(deviceId: string): void | Promise<void>;
|
|
216
|
+
onAgentOffline?(deviceId: string): void;
|
|
217
|
+
/** `deviceId` est l'identite AUTHENTIFIEE du socket ; le payload en porte une copie non fiable. */
|
|
218
|
+
onSyncChanged?(deviceId: string, payload: AgentSyncChangedPayload): void;
|
|
219
|
+
onSyncIndex?(deviceId: string, payload: AgentSyncIndexPayload): void;
|
|
220
|
+
onSyncChunk?(deviceId: string, payload: AgentSyncChunkPayload): void;
|
|
221
|
+
onSyncAck?(deviceId: string, payload: AgentSyncAckPayload): void;
|
|
222
|
+
onSyncOpResult?(deviceId: string, payload: AgentSyncOpResultPayload): void;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Raw bytes under the SERVER key (the `Encryption.encryptWithKey` wire format,
|
|
227
|
+
* byte-compatible with what native code wrote). For wrapping module-owned key
|
|
228
|
+
* material; never for user data, which goes through ciphers and the store.
|
|
229
|
+
*/
|
|
230
|
+
export interface SdkServerKeys {
|
|
231
|
+
sealBytes(plain: Uint8Array): string;
|
|
232
|
+
/** null when the sealed blob cannot be opened (tampered, or server keys changed). */
|
|
233
|
+
openBytes(sealed: string): Uint8Array | null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A workspace device, as the devices facade reveals it. */
|
|
237
|
+
export interface SdkDevice {
|
|
238
|
+
id: string;
|
|
239
|
+
name: string;
|
|
240
|
+
online: boolean;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** What a handler receives. One request, one workspace, rights pre-resolved. */
|
|
244
|
+
export interface SdkFeatureContext<Repo = unknown> {
|
|
245
|
+
userId: number;
|
|
246
|
+
workspaceId: number;
|
|
247
|
+
workspace: { id: number; kind: 'personal' | 'shared'; name: string };
|
|
248
|
+
isOwner: boolean;
|
|
249
|
+
/** Caller's level on THIS feature. `read` is already guaranteed by the dispatcher. */
|
|
250
|
+
canWrite: boolean;
|
|
251
|
+
/** Extra permission of type `toggle`. Absent from the grant = false; owner = true. */
|
|
252
|
+
canExtra(key: string): boolean;
|
|
253
|
+
/** Extra permission of type `choice`. Absent = the spec's default; owner = ownerValue. */
|
|
254
|
+
extraValue(key: string): string;
|
|
255
|
+
/** Your repo, built once per process by `server.createRepo`. */
|
|
256
|
+
repo: Repo;
|
|
257
|
+
/** Per-feature, per-workspace KV storage. */
|
|
258
|
+
store: FeatureStore;
|
|
259
|
+
/** Cipher for your own tables. Default `'server'`; see {@link StorageEncryption}. */
|
|
260
|
+
cipher(mode?: 'server' | 'private'): SdkCipher;
|
|
261
|
+
/** Native features, gated by your manifest's `nativeCapabilities`. */
|
|
262
|
+
deveye: DevEyeFacade;
|
|
263
|
+
/** The caller's socket (capability `'agents'`); every method throws `forbidden` otherwise. */
|
|
264
|
+
transport: SdkSocketTransport;
|
|
265
|
+
/** Fire-and-forget audit line; actor, IP and workspace are pre-bound. */
|
|
266
|
+
audit(entry: {
|
|
267
|
+
action: string;
|
|
268
|
+
description: string;
|
|
269
|
+
level?: LogLevelName;
|
|
270
|
+
metadata?: Record<string, unknown> | null;
|
|
271
|
+
}): void;
|
|
272
|
+
logger: SdkLogger;
|
|
273
|
+
requestId: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* One command your module handles. The dispatcher validates `input` before the
|
|
278
|
+
* handler and `output` after it, enforces `access` first, and broadcasts your
|
|
279
|
+
* feature's live topic after a successful `mutates` command so every member's
|
|
280
|
+
* client re-fetches your declared resources.
|
|
281
|
+
*/
|
|
282
|
+
export interface SdkFeatureDefinition<
|
|
283
|
+
Repo = unknown,
|
|
284
|
+
Cmd extends string = string,
|
|
285
|
+
I extends ZodType = ZodType,
|
|
286
|
+
O extends ZodType = ZodType
|
|
287
|
+
> {
|
|
288
|
+
command: Cmd;
|
|
289
|
+
input: I;
|
|
290
|
+
output: O;
|
|
291
|
+
/**
|
|
292
|
+
* `level` defaults to `'read'`; `extras` are ALL required. Your feature id
|
|
293
|
+
* is implied: you cannot gate on another feature's rights.
|
|
294
|
+
*/
|
|
295
|
+
access?: { level?: FeatureAccess; extras?: readonly string[] };
|
|
296
|
+
/** This command changes data other members can see. */
|
|
297
|
+
mutates?: boolean;
|
|
298
|
+
handler(ctx: SdkFeatureContext<Repo>, input: z.output<I>): Promise<z.input<O>>;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Identity helper for type inference, mirroring the native `defineFeature`. */
|
|
302
|
+
export function defineSdkFeature<Repo, Cmd extends string, I extends ZodType, O extends ZodType>(
|
|
303
|
+
def: SdkFeatureDefinition<Repo, Cmd, I, O>
|
|
304
|
+
): SdkFeatureDefinition<Repo, Cmd, I, O> {
|
|
305
|
+
return def;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A background worker. Started during boot (awaited, before the agent socket
|
|
310
|
+
* layer registers), stopped on shutdown.
|
|
311
|
+
*/
|
|
312
|
+
export interface FeatureService {
|
|
313
|
+
start(): void | Promise<void>;
|
|
314
|
+
stop(): void | Promise<void>;
|
|
315
|
+
/** Inbound agent events this module wants (requires capability `'agents'`). */
|
|
316
|
+
agentHooks?: FeatureAgentHooks;
|
|
317
|
+
/**
|
|
318
|
+
* Named contracts offered to the host app (see `sdk/providers.ts`): the
|
|
319
|
+
* inversion for public code that needs a module's data. The app looks a
|
|
320
|
+
* provider up at call time and degrades cleanly when the module is absent.
|
|
321
|
+
*/
|
|
322
|
+
providers?: Readonly<Record<string, unknown>>;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* What `createService` receives. Note what is absent: no session, no guarded
|
|
327
|
+
* cipher, no way to read `'private'` data. Background work runs sessionless.
|
|
328
|
+
*/
|
|
329
|
+
export interface FeatureServiceDeps<Repo = unknown> {
|
|
330
|
+
repo: Repo;
|
|
331
|
+
/** Workspaces where your feature is currently granted to at least the owner (i.e. all of them). */
|
|
332
|
+
listWorkspaceIds(): Promise<number[]>;
|
|
333
|
+
storeFor(workspaceId: number): SessionlessFeatureStore;
|
|
334
|
+
/** Open tier only. */
|
|
335
|
+
cipherFor(workspaceId: number): SdkCipher;
|
|
336
|
+
/** Sessionless-safe facade subset. */
|
|
337
|
+
deveyeFor(workspaceId: number): Pick<DevEyeFacade, 'notify'>;
|
|
338
|
+
/** Devices of one workspace, sessionless (capability `'devices.read'`). */
|
|
339
|
+
devicesFor(workspaceId: number): Pick<DevEyeFacade['devices'], 'list' | 'isOnline'>;
|
|
340
|
+
/**
|
|
341
|
+
* Audit line recorded as the SYSTEM (no session). `userId` attributes the
|
|
342
|
+
* line to a user when the work concerns their data.
|
|
343
|
+
*/
|
|
344
|
+
audit(entry: {
|
|
345
|
+
action: string;
|
|
346
|
+
description: string;
|
|
347
|
+
level?: LogLevelName;
|
|
348
|
+
userId?: number;
|
|
349
|
+
metadata?: Record<string, unknown> | null;
|
|
350
|
+
}): void;
|
|
351
|
+
/** The agent-fleet transport (capability `'agents'`). */
|
|
352
|
+
agents: AgentsFacade;
|
|
353
|
+
/** Raw key wrapping under the server key. */
|
|
354
|
+
keys: SdkServerKeys;
|
|
355
|
+
/**
|
|
356
|
+
* The app's standard loop: setInterval + reentrancy guard + unref, the
|
|
357
|
+
* exact pattern of every native service. Use it instead of rolling your own.
|
|
358
|
+
*/
|
|
359
|
+
createTicker(opts: { intervalMs: number; tick(): Promise<void> }): FeatureService;
|
|
360
|
+
logger: SdkLogger;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Your package's `./server` export.
|
|
365
|
+
*/
|
|
366
|
+
export interface FeatureServer<Repo = unknown> {
|
|
367
|
+
/** Built once per process, shared by handlers and the service. */
|
|
368
|
+
createRepo?(q: SdkQueryable): Repo;
|
|
369
|
+
features: readonly SdkFeatureDefinition<Repo, string, ZodType, ZodType>[];
|
|
370
|
+
/**
|
|
371
|
+
* Absolute path to this module's `migrations/` directory (derive it from
|
|
372
|
+
* `import.meta.url`). Files are `001_name.sql`, applied in order, recorded
|
|
373
|
+
* as `<feature-id>/<filename>`, after all core migrations. Never edit a
|
|
374
|
+
* shipped migration; add a new number.
|
|
375
|
+
*/
|
|
376
|
+
migrationsDir?: string;
|
|
377
|
+
createService?(deps: FeatureServiceDeps<Repo>): FeatureService;
|
|
378
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { ZodType } from 'zod';
|
|
2
|
+
import type {
|
|
3
|
+
DevEyeFacade,
|
|
4
|
+
FeatureStore,
|
|
5
|
+
SdkCipher,
|
|
6
|
+
SdkFeatureContext,
|
|
7
|
+
SdkLogger,
|
|
8
|
+
StorageEncryption
|
|
9
|
+
} from './server';
|
|
10
|
+
|
|
11
|
+
/**
|
|
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
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const ctx = createTestContext({ repo: fakeRepo() });
|
|
18
|
+
* const out = await myFeature.features[0].handler(ctx, { name: 'x' });
|
|
19
|
+
* assert.equal(ctx.recorded.notifications.length, 1);
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const identityCipher: SdkCipher = {
|
|
24
|
+
encrypt: (plaintext) => Promise.resolve(plaintext),
|
|
25
|
+
decrypt: (blob) => Promise.resolve(blob),
|
|
26
|
+
tryDecrypt: (blob) => Promise.resolve(blob)
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const silentLogger: SdkLogger = {
|
|
30
|
+
debug: () => undefined,
|
|
31
|
+
info: () => undefined,
|
|
32
|
+
warn: () => undefined,
|
|
33
|
+
error: () => undefined
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The in-memory store, with its rows exposed so tests can assert on modes. */
|
|
37
|
+
export interface TestFeatureStore extends FeatureStore {
|
|
38
|
+
rows: Map<string, { value: string; mode: StorageEncryption }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function memoryStore(): TestFeatureStore {
|
|
42
|
+
const rows = new Map<string, { value: string; mode: StorageEncryption }>();
|
|
43
|
+
return {
|
|
44
|
+
rows,
|
|
45
|
+
put(key, value, opts) {
|
|
46
|
+
rows.set(key, { value, mode: opts?.encryption ?? 'server' });
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
},
|
|
49
|
+
putJson<T>(
|
|
50
|
+
key: string,
|
|
51
|
+
_schema: ZodType<T>,
|
|
52
|
+
value: T,
|
|
53
|
+
opts?: { encryption?: StorageEncryption }
|
|
54
|
+
) {
|
|
55
|
+
rows.set(key, { value: JSON.stringify(value), mode: opts?.encryption ?? 'server' });
|
|
56
|
+
return Promise.resolve();
|
|
57
|
+
},
|
|
58
|
+
get: (key) => Promise.resolve(rows.get(key)?.value ?? null),
|
|
59
|
+
getJson<T>(key: string, schema: ZodType<T>) {
|
|
60
|
+
const row = rows.get(key);
|
|
61
|
+
return Promise.resolve(row ? schema.parse(JSON.parse(row.value)) : null);
|
|
62
|
+
},
|
|
63
|
+
remove(key) {
|
|
64
|
+
rows.delete(key);
|
|
65
|
+
return Promise.resolve();
|
|
66
|
+
},
|
|
67
|
+
keys: (prefix) =>
|
|
68
|
+
Promise.resolve([...rows.keys()].filter((k) => !prefix || k.startsWith(prefix)))
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface RecordedCalls {
|
|
73
|
+
notifications: { subject: string; body: string; itemId?: number }[];
|
|
74
|
+
audits: { action: string; description: string }[];
|
|
75
|
+
/** Outbound agent frames, as `{ method, deviceId }` (payloads dropped for brevity). */
|
|
76
|
+
agentRequests: { method: string; deviceId: string }[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
|
|
80
|
+
const req = (method: string) => (deviceId: string) => {
|
|
81
|
+
recorded.agentRequests.push({ method, deviceId });
|
|
82
|
+
return true;
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
isOnline: () => true,
|
|
86
|
+
requestSyncConfig: req('requestSyncConfig'),
|
|
87
|
+
requestSyncScan: req('requestSyncScan'),
|
|
88
|
+
requestSyncPush: req('requestSyncPush'),
|
|
89
|
+
requestSyncApplyChunk: req('requestSyncApplyChunk'),
|
|
90
|
+
requestSyncApplyStart: req('requestSyncApplyStart'),
|
|
91
|
+
requestSyncApplyDir: req('requestSyncApplyDir'),
|
|
92
|
+
requestSyncApplyLocal: req('requestSyncApplyLocal'),
|
|
93
|
+
requestSyncMove: req('requestSyncMove'),
|
|
94
|
+
requestSyncDelete: req('requestSyncDelete'),
|
|
95
|
+
publishSyncProgress: () => undefined,
|
|
96
|
+
publishSyncState: () => undefined
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface TestContext<Repo> extends SdkFeatureContext<Repo> {
|
|
101
|
+
recorded: RecordedCalls;
|
|
102
|
+
store: TestFeatureStore;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface TestContextOverrides<Repo> {
|
|
106
|
+
repo?: Repo;
|
|
107
|
+
userId?: number;
|
|
108
|
+
workspaceId?: number;
|
|
109
|
+
kind?: 'personal' | 'shared';
|
|
110
|
+
isOwner?: boolean;
|
|
111
|
+
canWrite?: boolean;
|
|
112
|
+
/** Extra permissions the caller holds, as the grant would carry them. */
|
|
113
|
+
extras?: Record<string, boolean | string>;
|
|
114
|
+
/** What `deveye.notify.hasRoute` answers. Default true. */
|
|
115
|
+
hasRoute?: boolean;
|
|
116
|
+
/** Override facade members entirely when the defaults are not enough. */
|
|
117
|
+
deveye?: Partial<DevEyeFacade>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createTestContext<Repo = undefined>(
|
|
121
|
+
overrides: TestContextOverrides<Repo> = {}
|
|
122
|
+
): TestContext<Repo> {
|
|
123
|
+
const recorded: RecordedCalls = { notifications: [], audits: [], agentRequests: [] };
|
|
124
|
+
const extras = overrides.extras ?? {};
|
|
125
|
+
const workspaceId = overrides.workspaceId ?? 1;
|
|
126
|
+
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
|
+
},
|
|
138
|
+
mail: { listAccounts: () => Promise.resolve([]) },
|
|
139
|
+
members: {
|
|
140
|
+
list: () =>
|
|
141
|
+
Promise.resolve([{ userId: overrides.userId ?? 1, name: 'Test', isOwner: true }])
|
|
142
|
+
},
|
|
143
|
+
devices: {
|
|
144
|
+
authorize: (id) => Promise.resolve({ id, name: 'Test device', online: true }),
|
|
145
|
+
list: () => Promise.resolve([]),
|
|
146
|
+
isOnline: () => true
|
|
147
|
+
},
|
|
148
|
+
agents: recordingAgents(recorded),
|
|
149
|
+
...overrides.deveye
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
recorded,
|
|
153
|
+
userId: overrides.userId ?? 1,
|
|
154
|
+
workspaceId,
|
|
155
|
+
workspace: { id: workspaceId, kind: overrides.kind ?? 'personal', name: 'Test' },
|
|
156
|
+
isOwner: overrides.isOwner ?? true,
|
|
157
|
+
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
|
+
},
|
|
163
|
+
repo: overrides.repo as Repo,
|
|
164
|
+
store: memoryStore(),
|
|
165
|
+
cipher: () => identityCipher,
|
|
166
|
+
deveye,
|
|
167
|
+
transport: {
|
|
168
|
+
subscribeSync: () => undefined,
|
|
169
|
+
unsubscribeSync: () => undefined,
|
|
170
|
+
sendSyncChunk: () => 0,
|
|
171
|
+
syncChunkBuffered: () => 0
|
|
172
|
+
},
|
|
173
|
+
audit: (entry) => {
|
|
174
|
+
recorded.audits.push({ action: entry.action, description: entry.description });
|
|
175
|
+
},
|
|
176
|
+
logger: silentLogger,
|
|
177
|
+
requestId: 'test'
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dotted-numeric version helpers shared by the server (which decides whether to
|
|
3
|
+
* offer/push an agent self-update) and the web client (which decides whether to
|
|
4
|
+
* show the affordance). Keeping a single implementation here means both ends agree
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* Non-numeric segments are treated as 0 and missing segments as 0, so `1.2` and
|
|
9
|
+
* `1.2.0` compare equal. No pre-release/build-metadata handling: DevEye versions
|
|
10
|
+
* come from a single `package.json`, so plain dotted integers are enough.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Compare dotted numeric versions: <0 if a<b, >0 if a>b, 0 if equal. */
|
|
14
|
+
export function compareVersions(a: string, b: string): number {
|
|
15
|
+
const pa = a.split('.').map((p) => parseInt(p, 10) || 0);
|
|
16
|
+
const pb = b.split('.').map((p) => parseInt(p, 10) || 0);
|
|
17
|
+
const len = Math.max(pa.length, pb.length);
|
|
18
|
+
for (let i = 0; i < len; i++) {
|
|
19
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
20
|
+
if (diff !== 0) return diff;
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether `candidate` is strictly newer than `current`. */
|
|
26
|
+
export function isNewerVersion(candidate: string, current: string): boolean {
|
|
27
|
+
return compareVersions(candidate, current) > 0;
|
|
28
|
+
}
|