@threahq/bot-runtime-client 0.1.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 +99 -0
- package/archive-grace.d.ts +112 -0
- package/attachment-files.d.ts +15 -0
- package/crypto.d.ts +206 -0
- package/index.d.ts +14 -0
- package/index.js +2344 -0
- package/index.js.map +19 -0
- package/invocation-control.d.ts +128 -0
- package/keyring.d.ts +242 -0
- package/package.json +49 -0
- package/sealed-stream-client.d.ts +145 -0
- package/sealed.d.ts +305 -0
- package/transport-test-helpers.d.ts +53 -0
- package/transport.d.ts +132 -0
- package/types.d.ts +243 -0
- package/user-key.d.ts +53 -0
- package/ws-hint.d.ts +17 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AttachmentRef } from "./crypto.js";
|
|
2
|
+
import { type BotIdentityKey, type SealingState } from "./sealed.js";
|
|
3
|
+
export declare const BOT_INVOCATION_CANCELLATION_REASONS: readonly ["source_deleted", "routing_changed", "input_restart", "input_stale", "key_grant_lost"];
|
|
4
|
+
export type InputUpdateDisposition = "applied" | "restart-required";
|
|
5
|
+
export type BotInvocationCancellationReason = (typeof BOT_INVOCATION_CANCELLATION_REASONS)[number];
|
|
6
|
+
export interface InvocationInputUpdate {
|
|
7
|
+
sourceRevision: number;
|
|
8
|
+
delivery: "plaintext" | "sealed";
|
|
9
|
+
promptMarkdown: string;
|
|
10
|
+
attachmentRefs: AttachmentRef[];
|
|
11
|
+
sealing?: SealingState;
|
|
12
|
+
}
|
|
13
|
+
export interface InvocationCancellation {
|
|
14
|
+
invocationId: string;
|
|
15
|
+
sourceRevision: number;
|
|
16
|
+
reason: BotInvocationCancellationReason;
|
|
17
|
+
}
|
|
18
|
+
export interface InvocationControlCallbacks {
|
|
19
|
+
onInputUpdated(update: InvocationInputUpdate, signal: AbortSignal): Promise<InputUpdateDisposition> | InputUpdateDisposition;
|
|
20
|
+
onCancelled(): Promise<void> | void;
|
|
21
|
+
/** The authoritative claim no longer exists, without a typed backend cancellation. */
|
|
22
|
+
onClaimLost?(): Promise<void> | void;
|
|
23
|
+
}
|
|
24
|
+
export interface ObserveClaimParams {
|
|
25
|
+
invocationId: string;
|
|
26
|
+
claimToken: string;
|
|
27
|
+
sourceRevision: number;
|
|
28
|
+
claimTtlSeconds: number;
|
|
29
|
+
instanceId?: string;
|
|
30
|
+
callbacks: InvocationControlCallbacks;
|
|
31
|
+
sealed?: {
|
|
32
|
+
identities: BotIdentityKey[];
|
|
33
|
+
streamId: string;
|
|
34
|
+
callbackToken: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export interface ObservedClaimHandle {
|
|
38
|
+
sync(): Promise<void>;
|
|
39
|
+
unregister(): void;
|
|
40
|
+
dispose(): void;
|
|
41
|
+
}
|
|
42
|
+
export type InvocationControlState = {
|
|
43
|
+
invocationId: string;
|
|
44
|
+
status: "active";
|
|
45
|
+
claimExpiresAt: string;
|
|
46
|
+
sourceRevision: number;
|
|
47
|
+
update?: unknown;
|
|
48
|
+
} | {
|
|
49
|
+
invocationId: string;
|
|
50
|
+
status: "cancelled";
|
|
51
|
+
claimExpiresAt: null;
|
|
52
|
+
sourceRevision: number;
|
|
53
|
+
reason: BotInvocationCancellationReason;
|
|
54
|
+
};
|
|
55
|
+
export type ControlSyncResult = {
|
|
56
|
+
kind: "control";
|
|
57
|
+
state: InvocationControlState;
|
|
58
|
+
} | {
|
|
59
|
+
kind: "not_found";
|
|
60
|
+
} | {
|
|
61
|
+
kind: "retry";
|
|
62
|
+
} | {
|
|
63
|
+
kind: "aborted";
|
|
64
|
+
};
|
|
65
|
+
export interface InvocationControlSyncRequest {
|
|
66
|
+
invocationId: string;
|
|
67
|
+
instanceId?: string;
|
|
68
|
+
claimToken: string;
|
|
69
|
+
claimTtlSeconds: number;
|
|
70
|
+
knownSourceRevision: number;
|
|
71
|
+
minimumSourceRevision: number;
|
|
72
|
+
restartRequiredRevision?: number;
|
|
73
|
+
ackTimeoutMs: number;
|
|
74
|
+
signal: AbortSignal;
|
|
75
|
+
}
|
|
76
|
+
export interface InvocationControlScheduler {
|
|
77
|
+
setTimeout(callback: () => void, delayMs: number): unknown;
|
|
78
|
+
clearTimeout(handle: unknown): void;
|
|
79
|
+
}
|
|
80
|
+
interface InvocationControlManagerOptions {
|
|
81
|
+
retryDelayMs?: number;
|
|
82
|
+
minRenewDelayMs?: number;
|
|
83
|
+
now?: () => number;
|
|
84
|
+
scheduler?: InvocationControlScheduler;
|
|
85
|
+
}
|
|
86
|
+
export declare class InvocationControlManager {
|
|
87
|
+
private readonly hooks;
|
|
88
|
+
private readonly observations;
|
|
89
|
+
private readonly pendingTerminalGenerations;
|
|
90
|
+
private queue;
|
|
91
|
+
private stopped;
|
|
92
|
+
private generation;
|
|
93
|
+
private readonly retryDelayMs;
|
|
94
|
+
private readonly minRenewDelayMs;
|
|
95
|
+
private readonly now;
|
|
96
|
+
private readonly scheduler;
|
|
97
|
+
constructor(hooks: {
|
|
98
|
+
sync(request: InvocationControlSyncRequest): Promise<ControlSyncResult>;
|
|
99
|
+
socketReady(): boolean;
|
|
100
|
+
log(message: string): void;
|
|
101
|
+
}, options?: InvocationControlManagerOptions);
|
|
102
|
+
observe(params: ObserveClaimParams): ObservedClaimHandle;
|
|
103
|
+
hint(payload: unknown, cancelled: boolean): void;
|
|
104
|
+
bootstrap(recentCancellations: unknown[], callback: () => void): Promise<void>;
|
|
105
|
+
wake(): void;
|
|
106
|
+
enqueueAdapter(callback: () => void): Promise<void>;
|
|
107
|
+
stop(): void;
|
|
108
|
+
private syncAndDrain;
|
|
109
|
+
private requestSync;
|
|
110
|
+
private runDrain;
|
|
111
|
+
private runSync;
|
|
112
|
+
private prepareUpdate;
|
|
113
|
+
private markRestartPending;
|
|
114
|
+
private highestLocallyKnownRevision;
|
|
115
|
+
private terminalizeCancellation;
|
|
116
|
+
private makeTerminal;
|
|
117
|
+
private unregister;
|
|
118
|
+
private teardown;
|
|
119
|
+
private scrub;
|
|
120
|
+
private schedule;
|
|
121
|
+
private isCurrent;
|
|
122
|
+
private enqueue;
|
|
123
|
+
private awaitStableControls;
|
|
124
|
+
}
|
|
125
|
+
export declare function parseCancellationReason(value: unknown): BotInvocationCancellationReason | undefined;
|
|
126
|
+
export declare function parseInvocationCancellation(value: unknown): InvocationCancellation | undefined;
|
|
127
|
+
export declare function parseRevision(value: unknown): number | undefined;
|
|
128
|
+
export {};
|
package/keyring.d.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The E2E keyring a bot runtime holds: the X25519 identity keys an owner wraps
|
|
3
|
+
* a sealed stream's key to, and where they live on the operator's machine.
|
|
4
|
+
*
|
|
5
|
+
* A runtime used to hold exactly one key per install. It now advertises a
|
|
6
|
+
* keyring, so several runtimes on one box can share a single key (the default —
|
|
7
|
+
* one key per host) and a key can be pinned to one stream. The server stores
|
|
8
|
+
* the advertised set as the instance's complete keyring and wraps every sealed
|
|
9
|
+
* stream key to each eligible member.
|
|
10
|
+
*
|
|
11
|
+
* Keys are secrets, so where they are kept is the operator's explicit choice:
|
|
12
|
+
* the OS keychain (driven through its command-line tool, which survives a
|
|
13
|
+
* runtime being rebuilt and reinstalled) or a `0600` file. There is no silent
|
|
14
|
+
* fallback between the two — an unavailable keychain is an error naming both
|
|
15
|
+
* options, not a quiet downgrade to disk.
|
|
16
|
+
*/
|
|
17
|
+
export declare const E2E_KEY_SCOPES: readonly ["host", "identity", "instance", "stream"];
|
|
18
|
+
export type E2eKeyScope = (typeof E2E_KEY_SCOPES)[number];
|
|
19
|
+
export declare const E2E_KEY_STORE_KINDS: readonly ["keychain", "file"];
|
|
20
|
+
export type E2eKeyStoreKind = (typeof E2E_KEY_STORE_KINDS)[number];
|
|
21
|
+
/** A key as it is persisted and as it rides presence: public half plus the private key, base64. */
|
|
22
|
+
export interface E2eKeyRecord {
|
|
23
|
+
keyId: string;
|
|
24
|
+
publicKey: string;
|
|
25
|
+
privateKey: string;
|
|
26
|
+
}
|
|
27
|
+
/** A held key, the account it came from, and the stream it is pinned to, if any. */
|
|
28
|
+
export interface HeldE2eKey extends E2eKeyRecord {
|
|
29
|
+
account: string;
|
|
30
|
+
streamId?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A place a key record is kept. `createExclusive` never overwrites: when
|
|
34
|
+
* another process wrote the account first its record is returned instead, so
|
|
35
|
+
* two runtimes racing to mint a shared key converge on one.
|
|
36
|
+
*/
|
|
37
|
+
export interface E2eKeyStore {
|
|
38
|
+
readonly kind: E2eKeyStoreKind;
|
|
39
|
+
/** Where the keys live, for the boot log — a path or the keychain service. */
|
|
40
|
+
readonly describe: string;
|
|
41
|
+
read(account: string): E2eKeyRecord | undefined;
|
|
42
|
+
createExclusive(account: string, record: E2eKeyRecord): E2eKeyRecord;
|
|
43
|
+
/**
|
|
44
|
+
* Replace whatever is filed under `account`. For a person acting on their own
|
|
45
|
+
* key — unlocking it on this machine, or replacing it after a rotation. A
|
|
46
|
+
* runtime converging with its peers on a shared key wants `createExclusive`,
|
|
47
|
+
* which never clobbers the winner of that race.
|
|
48
|
+
*/
|
|
49
|
+
write(account: string, record: E2eKeyRecord): void;
|
|
50
|
+
/** Forget the account. Silent when nothing is filed there. */
|
|
51
|
+
remove(account: string): void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The account a scope's default key is filed under, or `null` under `stream`,
|
|
55
|
+
* where there is no default: each key is minted for one sealed stream on its
|
|
56
|
+
* grant and filed under {@link e2eStreamKeyAccount}.
|
|
57
|
+
*
|
|
58
|
+
* `host` hashes the hostname because `~/.threa` can be a home directory shared
|
|
59
|
+
* across machines, and a key that followed the home directory would put every
|
|
60
|
+
* box on one identity without the operator ever choosing that.
|
|
61
|
+
*/
|
|
62
|
+
export declare function e2eKeyAccount(params: {
|
|
63
|
+
scope: E2eKeyScope;
|
|
64
|
+
hostname: string;
|
|
65
|
+
instanceId: string;
|
|
66
|
+
/** Secret that identifies the bot (its API key); hashed, never stored. */
|
|
67
|
+
identitySeed: string;
|
|
68
|
+
}): string | null;
|
|
69
|
+
/** The account one stream's key is filed under. */
|
|
70
|
+
export declare function e2eStreamKeyAccount(streamId: string): string;
|
|
71
|
+
/**
|
|
72
|
+
* The account a person's own identity key is filed under, once they unlock it
|
|
73
|
+
* on this machine. Separate from the bot scopes above: this is the key the web
|
|
74
|
+
* app minted from their passphrase, and a CLI holding it reads their streams as
|
|
75
|
+
* them, not as a runtime.
|
|
76
|
+
*/
|
|
77
|
+
export declare function e2eUserKeyAccount(workspaceId: string, userId: string): string;
|
|
78
|
+
export declare class FileKeyStore implements E2eKeyStore {
|
|
79
|
+
readonly kind: "file";
|
|
80
|
+
readonly describe: string;
|
|
81
|
+
private readonly dir;
|
|
82
|
+
constructor(opts: {
|
|
83
|
+
dir: string;
|
|
84
|
+
});
|
|
85
|
+
private path;
|
|
86
|
+
read(account: string): E2eKeyRecord | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Whether this directory already serves any key. The per-stream policy has no
|
|
89
|
+
* single account to probe for — its keys are named after streams it has not
|
|
90
|
+
* been granted yet — so store selection asks this instead.
|
|
91
|
+
*/
|
|
92
|
+
hasAny(): boolean;
|
|
93
|
+
createExclusive(account: string, record: E2eKeyRecord): E2eKeyRecord;
|
|
94
|
+
write(account: string, record: E2eKeyRecord): void;
|
|
95
|
+
remove(account: string): void;
|
|
96
|
+
}
|
|
97
|
+
interface CommandResult {
|
|
98
|
+
status: number;
|
|
99
|
+
stdout: string;
|
|
100
|
+
stderr: string;
|
|
101
|
+
/** The command could not be run at all (binary missing). */
|
|
102
|
+
unavailable: boolean;
|
|
103
|
+
}
|
|
104
|
+
export declare const KEYCHAIN_COMMAND_TIMEOUT_MS = 5000;
|
|
105
|
+
export declare function runKeychainCommand(command: string, args: string[], input?: string, timeoutMs?: number): CommandResult;
|
|
106
|
+
export type CommandRunner = (command: string, args: string[], input?: string) => CommandResult;
|
|
107
|
+
export declare class MacKeychainStore implements E2eKeyStore {
|
|
108
|
+
readonly kind: "keychain";
|
|
109
|
+
readonly describe = "macOS keychain (service threa-e2e)";
|
|
110
|
+
private readonly exec;
|
|
111
|
+
constructor(opts?: {
|
|
112
|
+
exec?: CommandRunner;
|
|
113
|
+
});
|
|
114
|
+
read(account: string): E2eKeyRecord | undefined;
|
|
115
|
+
createExclusive(account: string, record: E2eKeyRecord): E2eKeyRecord;
|
|
116
|
+
write(account: string, record: E2eKeyRecord): void;
|
|
117
|
+
remove(account: string): void;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The freedesktop Secret Service, driven through `secret-tool`. `store`
|
|
121
|
+
* overwrites, so exclusivity is a read before the write and a read after it:
|
|
122
|
+
* the value that comes back is the one every process on this box will use.
|
|
123
|
+
*/
|
|
124
|
+
export declare class SecretServiceStore implements E2eKeyStore {
|
|
125
|
+
readonly kind: "keychain";
|
|
126
|
+
readonly describe = "Secret Service keyring (service threa-e2e)";
|
|
127
|
+
private readonly exec;
|
|
128
|
+
constructor(opts?: {
|
|
129
|
+
exec?: CommandRunner;
|
|
130
|
+
});
|
|
131
|
+
read(account: string): E2eKeyRecord | undefined;
|
|
132
|
+
createExclusive(account: string, record: E2eKeyRecord): E2eKeyRecord;
|
|
133
|
+
write(account: string, record: E2eKeyRecord): void;
|
|
134
|
+
remove(account: string): void;
|
|
135
|
+
}
|
|
136
|
+
export interface ResolveKeyStoreInput {
|
|
137
|
+
/** The operator's explicit choice. Unset lets an available keychain win. */
|
|
138
|
+
requested?: E2eKeyStoreKind;
|
|
139
|
+
platform: NodeJS.Platform;
|
|
140
|
+
/** Where a file store keeps its keys. */
|
|
141
|
+
dir: string;
|
|
142
|
+
/** Already-persisted key material, when found: an existing file store keeps serving it. */
|
|
143
|
+
hasExistingFileKey: boolean;
|
|
144
|
+
exec?: CommandRunner;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Pick the store for this machine. An explicit `keychain` that cannot run is an
|
|
148
|
+
* error rather than a quiet move to disk (INV-11), and with nothing explicit a
|
|
149
|
+
* box without a working keychain is asked to choose instead of being given one.
|
|
150
|
+
*/
|
|
151
|
+
export declare function resolveKeyStore(input: ResolveKeyStoreInput): E2eKeyStore;
|
|
152
|
+
export interface E2eKeyringOptions {
|
|
153
|
+
store: E2eKeyStore;
|
|
154
|
+
/**
|
|
155
|
+
* The account the unscoped default key is filed under; see
|
|
156
|
+
* {@link e2eKeyAccount}. `null` selects the per-stream policy: no default
|
|
157
|
+
* key, one minted per sealed stream the bot is granted.
|
|
158
|
+
*/
|
|
159
|
+
account: string | null;
|
|
160
|
+
/** Mints a fresh record when the account is empty. */
|
|
161
|
+
mint: () => Promise<E2eKeyRecord>;
|
|
162
|
+
/**
|
|
163
|
+
* A single-key file from before the keyring. When the account holds nothing
|
|
164
|
+
* and this does, the old key is adopted under the new account: its id is the
|
|
165
|
+
* address of every wrap an owner already made, so minting a fresh one instead
|
|
166
|
+
* would strand every sealed stream the runtime serves.
|
|
167
|
+
*/
|
|
168
|
+
legacy?: () => E2eKeyRecord | undefined;
|
|
169
|
+
log?: (message: string) => void;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* This runtime's keyring. `ensure()` loads or mints the default key once;
|
|
173
|
+
* `ensureForStream()` adds a stream-pinned key under the per-stream policy. The
|
|
174
|
+
* result is what rides `bot:hello` and every presence write, where the server
|
|
175
|
+
* reads it as the instance's complete set.
|
|
176
|
+
*/
|
|
177
|
+
export declare class E2eKeyring {
|
|
178
|
+
private readonly opts;
|
|
179
|
+
private readonly log;
|
|
180
|
+
private held;
|
|
181
|
+
private inFlight;
|
|
182
|
+
private loaded;
|
|
183
|
+
constructor(opts: E2eKeyringOptions);
|
|
184
|
+
/** The loaded keys; empty until `ensure()` resolves. */
|
|
185
|
+
get current(): HeldE2eKey[];
|
|
186
|
+
ensure(): Promise<HeldE2eKey[]>;
|
|
187
|
+
/**
|
|
188
|
+
* The key this runtime reads `streamId` with. Under the default policy that
|
|
189
|
+
* is the unscoped key, which already covers every stream, so this is a no-op.
|
|
190
|
+
* Under the per-stream policy it mints one key for this stream — the owner's
|
|
191
|
+
* next re-wrap addresses the stream key to it.
|
|
192
|
+
*/
|
|
193
|
+
ensureForStream(streamId: string): Promise<HeldE2eKey[]>;
|
|
194
|
+
/**
|
|
195
|
+
* Forget this stream's key: drop it from the held set and from the store.
|
|
196
|
+
* Under the default policy the key covers every stream this runtime serves,
|
|
197
|
+
* so a revoke on one of them must leave it alone — only a key minted FOR the
|
|
198
|
+
* revoked stream is dead, and only once its wraps are gone server-side.
|
|
199
|
+
*/
|
|
200
|
+
dropStream(streamId: string): E2eKeyRecord[];
|
|
201
|
+
/**
|
|
202
|
+
* The key a wrap for `streamId` must be addressed to, once `ensureForStream`
|
|
203
|
+
* has resolved. Under the default policy that is the unscoped key whatever
|
|
204
|
+
* the stream; under the per-stream policy picking the first held key would
|
|
205
|
+
* address another stream's.
|
|
206
|
+
*/
|
|
207
|
+
forStream(streamId: string): HeldE2eKey | undefined;
|
|
208
|
+
/**
|
|
209
|
+
* The keyring as it rides presence. `publicKey`/`publicKeyId` carry the
|
|
210
|
+
* default key as well: a server from before the registry reads only those,
|
|
211
|
+
* and both name the same key, so a mixed-version rollout addresses one key
|
|
212
|
+
* either way. Under the per-stream policy there is no default key, so those
|
|
213
|
+
* two are omitted rather than naming a stream key an old server would
|
|
214
|
+
* register as covering everything.
|
|
215
|
+
*/
|
|
216
|
+
presenceFields(): {
|
|
217
|
+
e2eKeys: {
|
|
218
|
+
keyId: string;
|
|
219
|
+
publicKey: string;
|
|
220
|
+
streamId?: string;
|
|
221
|
+
}[];
|
|
222
|
+
publicKey?: string;
|
|
223
|
+
publicKeyId?: string;
|
|
224
|
+
} | Record<string, never>;
|
|
225
|
+
/**
|
|
226
|
+
* Load or mint one account's key and fold it into the held set. Concurrent
|
|
227
|
+
* callers for the same account share one attempt: boot presence and
|
|
228
|
+
* `bot:hello` both ensure, and a grant can arrive while either is in flight,
|
|
229
|
+
* so without this they would each mint past the cache check and race the
|
|
230
|
+
* store.
|
|
231
|
+
*/
|
|
232
|
+
private loadAccount;
|
|
233
|
+
private mintAccount;
|
|
234
|
+
private createRecord;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Read the single-key BIK file a runtime used before keyrings. Its `publicKeyId`
|
|
238
|
+
* is the address of every wrap the owner already made for this install, so the
|
|
239
|
+
* record is adopted under the configured scope rather than replaced.
|
|
240
|
+
*/
|
|
241
|
+
export declare function readLegacyBikFile(path: string): E2eKeyRecord | undefined;
|
|
242
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@threahq/bot-runtime-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Protocol client for Threa's bot runtime: the /bot WebSocket transport (presence, claim renewal, trace steps, work nudges) with an HTTP fallback, plus the sealed-turn crypto an end-to-end-encrypted bot needs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/threahq/threa.git",
|
|
9
|
+
"directory": "extensions/bot-runtime-client"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://threa.io/developers",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/threahq/threa/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"threa",
|
|
17
|
+
"bot",
|
|
18
|
+
"agent",
|
|
19
|
+
"runtime",
|
|
20
|
+
"socket.io",
|
|
21
|
+
"e2ee"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"main": "./index.js",
|
|
26
|
+
"types": "./index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./index.d.ts",
|
|
30
|
+
"import": "./index.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@hpke/core": "^1.9.0",
|
|
39
|
+
"@hpke/dhkem-x25519": "^1.6.0",
|
|
40
|
+
"hash-wasm": "^4.12.0",
|
|
41
|
+
"ulid": "^2.3.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"socket.io-client": "^4.8.1"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read and write an end-to-end-encrypted stream over Threa's public API.
|
|
3
|
+
*
|
|
4
|
+
* The sealed-turn path in `./sealed` covers a bot answering an invocation: the
|
|
5
|
+
* backend hands it ciphertext and SSK wraps on the claim. Everything else — a
|
|
6
|
+
* CLI reading its owner's scratchpad, a bot posting into a sealed stream it was
|
|
7
|
+
* granted but was not invoked in — has to fetch the wraps itself, unwrap the
|
|
8
|
+
* stream key, and open or seal each body. That is this client: the crypto is
|
|
9
|
+
* the same module, but the keys come from the caller's own keyring and the
|
|
10
|
+
* transport is the public HTTP API.
|
|
11
|
+
*
|
|
12
|
+
* Threads inherit their root scratchpad's key and carry no wraps of their own,
|
|
13
|
+
* so every id is resolved to its root before any key work — pass a thread id
|
|
14
|
+
* and it still reads and seals correctly.
|
|
15
|
+
*/
|
|
16
|
+
import { type AttachmentRef, type StreamEnvelope, type WebCryptoKey } from "./crypto.js";
|
|
17
|
+
import type { E2eKeyring } from "./keyring.js";
|
|
18
|
+
/** One key this client can open a wrap with. */
|
|
19
|
+
export interface SealedKeyIdentity {
|
|
20
|
+
/** The id wraps are addressed to — a BIK's `publicKeyId`, or a user's UIK key id. */
|
|
21
|
+
keyId: string;
|
|
22
|
+
privateKey: WebCryptoKey;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Where the client's private keys come from. A bot passes its runtime keyring;
|
|
26
|
+
* an interactive client passes whatever it unlocked. Called per stream because
|
|
27
|
+
* the per-stream key policy mints one key per sealed stream.
|
|
28
|
+
*/
|
|
29
|
+
export interface SealedKeySource {
|
|
30
|
+
keysForStream(streamId: string): Promise<SealedKeyIdentity[]>;
|
|
31
|
+
}
|
|
32
|
+
/** Bridge a runtime's {@link E2eKeyring} into a {@link SealedKeySource}. */
|
|
33
|
+
export declare function keyringKeySource(keyring: E2eKeyring): SealedKeySource;
|
|
34
|
+
/**
|
|
35
|
+
* One message from a sealed stream. `contentMarkdown` is the opened body;
|
|
36
|
+
* it is `null` exactly when this client could not open the row, and
|
|
37
|
+
* `unreadableReason` then says why — a row sealed under the pre-stream-key
|
|
38
|
+
* scheme, or a generation no key here is wrapped to. Neither is fatal for the
|
|
39
|
+
* rest of the page, so the row is reported rather than thrown.
|
|
40
|
+
*/
|
|
41
|
+
export interface SealedStreamMessage {
|
|
42
|
+
id: string;
|
|
43
|
+
sequence: string;
|
|
44
|
+
authorId: string;
|
|
45
|
+
authorType: string;
|
|
46
|
+
authorDisplayName?: string;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
contentMarkdown: string | null;
|
|
49
|
+
attachmentRefs: AttachmentRef[];
|
|
50
|
+
unreadableReason?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface SealedStreamPage {
|
|
53
|
+
messages: SealedStreamMessage[];
|
|
54
|
+
hasMore: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface SealedStreamClientOptions {
|
|
57
|
+
/** Workspace API origin, e.g. `https://eu.threa.io`. */
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
apiKey: string;
|
|
60
|
+
workspaceId: string;
|
|
61
|
+
keys: SealedKeySource;
|
|
62
|
+
/**
|
|
63
|
+
* The actor id bound into outgoing message AAD. Resolved from
|
|
64
|
+
* `GET /me` when omitted, which is what a caller holding only a key knows.
|
|
65
|
+
*/
|
|
66
|
+
senderId?: string;
|
|
67
|
+
fetch?: typeof globalThis.fetch;
|
|
68
|
+
}
|
|
69
|
+
/** The `sealed` field a message row carries in place of readable content. */
|
|
70
|
+
export interface SealedMessageBody {
|
|
71
|
+
ciphertext: string;
|
|
72
|
+
envelope: StreamEnvelope;
|
|
73
|
+
}
|
|
74
|
+
/** What opening one {@link SealedMessageBody} produced. */
|
|
75
|
+
export interface OpenedSealedBody {
|
|
76
|
+
contentMarkdown: string | null;
|
|
77
|
+
attachmentRefs: AttachmentRef[];
|
|
78
|
+
unreadableReason?: string;
|
|
79
|
+
}
|
|
80
|
+
/** A non-2xx from the API, carrying the `code` the wire named. */
|
|
81
|
+
export declare class SealedStreamApiError extends Error {
|
|
82
|
+
readonly status: number;
|
|
83
|
+
readonly code: string;
|
|
84
|
+
constructor(message: string, status: number, code: string);
|
|
85
|
+
}
|
|
86
|
+
export declare class SealedStreamClient {
|
|
87
|
+
private readonly opts;
|
|
88
|
+
private readonly doFetch;
|
|
89
|
+
private readonly roots;
|
|
90
|
+
private readonly ssks;
|
|
91
|
+
private readonly generations;
|
|
92
|
+
private sender?;
|
|
93
|
+
constructor(opts: SealedStreamClientOptions);
|
|
94
|
+
/**
|
|
95
|
+
* One page of decrypted messages, newest-last. `before`/`after` take the
|
|
96
|
+
* `sequence` of a message already held, exactly as the plaintext list does.
|
|
97
|
+
*/
|
|
98
|
+
readMessages(streamId: string, opts?: {
|
|
99
|
+
limit?: number;
|
|
100
|
+
before?: string;
|
|
101
|
+
after?: string;
|
|
102
|
+
}): Promise<SealedStreamPage>;
|
|
103
|
+
/**
|
|
104
|
+
* Seal `contentMarkdown` under the stream key and post it. The returned
|
|
105
|
+
* `messageId` is the server's row id; `clientMessageId` is the id this client
|
|
106
|
+
* minted, bound into the body's AAD and reusable to retry the send.
|
|
107
|
+
*/
|
|
108
|
+
sendMessage(streamId: string, contentMarkdown: string, opts?: {
|
|
109
|
+
attachmentRefs?: AttachmentRef[];
|
|
110
|
+
clientMessageId?: string;
|
|
111
|
+
}): Promise<{
|
|
112
|
+
messageId: string;
|
|
113
|
+
clientMessageId: string;
|
|
114
|
+
}>;
|
|
115
|
+
/**
|
|
116
|
+
* Open one sealed body for a caller that fetched the row itself — a client
|
|
117
|
+
* reading the plaintext list route, where sealed rows arrive beside an opaque
|
|
118
|
+
* placeholder. `streamId` may be the thread the row lives in; its root's key
|
|
119
|
+
* is what opens it.
|
|
120
|
+
*
|
|
121
|
+
* A body this client cannot open comes back with a null `contentMarkdown` and
|
|
122
|
+
* a reason rather than throwing, so one unreadable generation never costs the
|
|
123
|
+
* caller the rest of the page.
|
|
124
|
+
*/
|
|
125
|
+
openSealedBody(streamId: string, sealed: SealedMessageBody): Promise<OpenedSealedBody>;
|
|
126
|
+
private openBody;
|
|
127
|
+
private openMessage;
|
|
128
|
+
/**
|
|
129
|
+
* The stream key for one generation. Wraps are fetched once per stream and
|
|
130
|
+
* every generation they cover is unwrapped in that pass, so a page spanning a
|
|
131
|
+
* rotation costs one round trip.
|
|
132
|
+
*/
|
|
133
|
+
private streamKey;
|
|
134
|
+
/**
|
|
135
|
+
* Never cached: sealing under a stale generation after the owner rolled the
|
|
136
|
+
* key would hand the revoked generation's holders a readable message, and the
|
|
137
|
+
* send path validates only the envelope's shape.
|
|
138
|
+
*/
|
|
139
|
+
private currentGeneration;
|
|
140
|
+
private loadWraps;
|
|
141
|
+
/** A thread's key lives on its root; a root resolves to itself. */
|
|
142
|
+
private resolveRoot;
|
|
143
|
+
private resolveSenderId;
|
|
144
|
+
private request;
|
|
145
|
+
}
|