@argentic/chest-sdk 0.2.0 → 0.4.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/README.md +406 -51
- package/client/index.ts +8 -5
- package/client/src/ai.ts +374 -0
- package/client/src/api.ts +25 -3
- package/client/src/chest.ts +104 -0
- package/client/src/errors.ts +44 -0
- package/client/src/events.ts +12 -104
- package/client/src/files.ts +9 -9
- package/client/src/member.ts +26 -5
- package/client/src/members.ts +21 -16
- package/client/src/schedules.ts +87 -0
- package/client/src/signed.ts +166 -0
- package/client/src/testing.ts +290 -65
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/src/ai.d.ts +131 -0
- package/dist/src/ai.d.ts.map +1 -0
- package/dist/src/ai.js +290 -0
- package/dist/src/ai.js.map +1 -0
- package/dist/src/api.d.ts +4 -0
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +25 -2
- package/dist/src/api.js.map +1 -1
- package/dist/src/chest.d.ts +15 -0
- package/dist/src/chest.d.ts.map +1 -0
- package/dist/src/chest.js +61 -0
- package/dist/src/chest.js.map +1 -0
- package/dist/src/errors.d.ts +16 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +36 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/events.d.ts +3 -6
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +8 -102
- package/dist/src/events.js.map +1 -1
- package/dist/src/files.d.ts +1 -0
- package/dist/src/files.d.ts.map +1 -1
- package/dist/src/files.js +6 -7
- package/dist/src/files.js.map +1 -1
- package/dist/src/member.d.ts +4 -0
- package/dist/src/member.d.ts.map +1 -1
- package/dist/src/member.js +17 -5
- package/dist/src/member.js.map +1 -1
- package/dist/src/members.d.ts +1 -1
- package/dist/src/members.d.ts.map +1 -1
- package/dist/src/members.js +9 -8
- package/dist/src/members.js.map +1 -1
- package/dist/src/schedules.d.ts +15 -0
- package/dist/src/schedules.d.ts.map +1 -0
- package/dist/src/schedules.js +45 -0
- package/dist/src/schedules.js.map +1 -0
- package/dist/src/signed.d.ts +29 -0
- package/dist/src/signed.d.ts.map +1 -0
- package/dist/src/signed.js +139 -0
- package/dist/src/signed.js.map +1 -0
- package/dist/src/testing.d.ts +53 -12
- package/dist/src/testing.d.ts.map +1 -1
- package/dist/src/testing.js +265 -60
- package/dist/src/testing.js.map +1 -1
- package/package.json +27 -4
package/client/src/testing.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
|
-
import { createHash,
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
3
|
import type { AddressInfo } from "node:net";
|
|
4
|
+
import type { Alias, Provider } from "./ai.js";
|
|
5
|
+
import type { AiUnavailableReason } from "./errors.js";
|
|
4
6
|
import type { ChestEvent } from "./events.js";
|
|
5
7
|
import { groupIdPattern, memberIdPattern, type Member } from "./member.js";
|
|
6
8
|
import { forget } from "./members.js";
|
|
9
|
+
import { fakeOrigins } from "./api.js";
|
|
10
|
+
import { eventChannel, scheduleChannel, sign, signClaims, type Channel } from "./signed.js";
|
|
7
11
|
|
|
8
12
|
// For a tool's own tests, never imported by its production code: a member's
|
|
9
13
|
// assertion signed as the Chest signs it, and a Chest's API in the test's
|
|
10
|
-
// process that answers members, groups, files (stat, move, links
|
|
11
|
-
// it
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
+
// process that answers members, groups, files (stat, move, links and
|
|
15
|
+
// uploads, which it serves and takes itself as the team host would, on its
|
|
16
|
+
// own origin), badges, notifications, AI (chat,
|
|
17
|
+
// streamed or not, embeddings, models, usage: deterministic answers, no
|
|
18
|
+
// provider) and the acknowledgment of an erasure with the Chest's bounds,
|
|
19
|
+
// quotas and errors; and that delivers an event or a run of a schedule to
|
|
20
|
+
// the tool, signed as the Chest signs them.
|
|
14
21
|
//
|
|
15
22
|
// import { fakeChest, withMember } from "@argentic/chest-sdk/testing";
|
|
16
23
|
// const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications"] });
|
|
17
24
|
// const response = await app(withMember(new Request("http://tool/chest"), camille));
|
|
18
25
|
// assert.equal(chest.notifications[0]?.member, camille.id);
|
|
19
26
|
// assert.equal(await chest.emit({ type: "access.revoked", data: { id: camille.id } }, request => app(request)), 204);
|
|
27
|
+
// assert.equal(await chest.run("morning", request => app(request)), 204);
|
|
20
28
|
// await chest.close();
|
|
21
29
|
|
|
22
30
|
// A group as a fake Chest keeps it: its identifier, its name, and the
|
|
@@ -24,24 +32,51 @@ import { forget } from "./members.js";
|
|
|
24
32
|
export type FakeGroup = { id: string; name: string; members: string[] };
|
|
25
33
|
// A file as a fake Chest keeps it.
|
|
26
34
|
export type FakeFile = { data: Uint8Array; type: string; updated: string };
|
|
35
|
+
// Someone the tool had who no longer has it: a member of the Chest without
|
|
36
|
+
// access to it, a member who left ("former", by default), or one whose data
|
|
37
|
+
// was erased (their name gone).
|
|
38
|
+
export type FakeFormer = { id: string; name?: string; status?: "no_access" | "former" | "erased" };
|
|
27
39
|
// A notification as a fake Chest keeps it: the member it went to, its text
|
|
28
40
|
// cleaned as the Chest cleans it, its path (/chest when not said) and its
|
|
29
41
|
// key.
|
|
30
42
|
export type FakeNotification = { member: string; title: string; body?: string; path: string; key?: string };
|
|
43
|
+
// An alias a fake Chest maps: the model behind it, its provider (openrouter
|
|
44
|
+
// by default) and its prices in US dollars per million tokens (1 and 2 by
|
|
45
|
+
// default).
|
|
46
|
+
export type FakeAiModel = { alias: Alias; model: string; provider?: Provider; input?: number; output?: number };
|
|
47
|
+
// What a fake model answers: text, or text and tool calls (an id of call_…
|
|
48
|
+
// by default; arguments are JSON text).
|
|
49
|
+
export type FakeAiReply = string | { text?: string; toolCalls?: { name: string; arguments: string; id?: string }[] };
|
|
50
|
+
// The AI of a fake Chest: the aliases the tool declared, each mapped (all
|
|
51
|
+
// four by default: fake-default, fake-fast, fake-smart, fake-embedding); what
|
|
52
|
+
// its models answer to a chat, given the request as the tool sent it (the
|
|
53
|
+
// last user message's text, echoed, by default); the tool's monthly cap in
|
|
54
|
+
// euros (5 by default; a call is refused cap_reached once the spending
|
|
55
|
+
// reaches it, 0 refuses at once); and a reason that makes chat and
|
|
56
|
+
// embeddings unavailable.
|
|
57
|
+
export type FakeAi = { models?: FakeAiModel[]; reply?: (request: Record<string, unknown>) => FakeAiReply; cap?: number; unavailable?: AiUnavailableReason };
|
|
58
|
+
// A call of the tool to the AI of a fake Chest: its path and its body as
|
|
59
|
+
// sent (null for a GET).
|
|
60
|
+
export type FakeAiCall = { path: string; body: unknown };
|
|
31
61
|
|
|
32
|
-
// What a fake Chest is given: the members who have the tool, those
|
|
33
|
-
//
|
|
62
|
+
// What a fake Chest is given: the members who have the tool, those it had
|
|
63
|
+
// who no longer have it (FakeFormer), its groups, the
|
|
34
64
|
// capabilities its version holds (a capability left out answers 403;
|
|
35
|
-
// members, files and
|
|
65
|
+
// members, files, notifications and ai by default, members.email to read the
|
|
36
66
|
// addresses), the events it receives (["member.*"] by default, [] to answer
|
|
37
|
-
// an acknowledgment 403)
|
|
67
|
+
// an acknowledgment 403), the files it keeps, its AI, and what the Chest is
|
|
68
|
+
// (the chest module: "Test organization", UTC, English and euros by default;
|
|
69
|
+
// the tool at https://<tool>-chest.chest.test, its public part at
|
|
70
|
+
// https://<tool>.chest.test).
|
|
38
71
|
export type FakeChestOptions = {
|
|
39
72
|
members?: Member[];
|
|
40
|
-
former?:
|
|
73
|
+
former?: FakeFormer[];
|
|
41
74
|
groups?: FakeGroup[];
|
|
42
75
|
capabilities?: string[];
|
|
43
76
|
receives?: string[];
|
|
44
77
|
files?: Record<string, { data: Uint8Array | string; type?: string }>;
|
|
78
|
+
ai?: FakeAi;
|
|
79
|
+
chest?: { organization?: string; timeZone?: string; language?: string; currency?: string; teamUrl?: string; publicUrl?: string | null };
|
|
45
80
|
};
|
|
46
81
|
|
|
47
82
|
// An event for emit: its type and data; its id (a new evt_… by default) and
|
|
@@ -49,13 +84,20 @@ export type FakeChestOptions = {
|
|
|
49
84
|
// twice.
|
|
50
85
|
export type FakeEvent = { [K in ChestEvent["type"]]: { type: K; data: Extract<ChestEvent, { type: K }>["data"]; id?: string; occurredAt?: string } }[ChestEvent["type"]];
|
|
51
86
|
|
|
52
|
-
// A
|
|
53
|
-
//
|
|
87
|
+
// A run for run: its id (a new run_… by default: name it to deliver the same
|
|
88
|
+
// run twice), the time it stands for (now by default) and its attempt (1 by
|
|
89
|
+
// default).
|
|
90
|
+
export type FakeRun = { id?: string; scheduledAt?: string; attempt?: number };
|
|
91
|
+
|
|
92
|
+
// A fake Chest in the test's process: its address — its API, and the origin
|
|
93
|
+
// where it serves the links and takes the uploads it signs —, the token and
|
|
94
|
+
// the tool it set in the environment, what it keeps (members, groups and files a test
|
|
54
95
|
// changes or reads; the notifications the tool sent, in the order sent, a
|
|
55
96
|
// replaced one last; each member's badge; the erasures the tool
|
|
56
|
-
// acknowledged), emit, which delivers an event to the tool — POST
|
|
97
|
+
// acknowledged; its calls to AI, in order), emit, which delivers an event to the tool — POST
|
|
57
98
|
// /chest-events of its address, or a handler of Web Requests — and says the
|
|
58
|
-
// status it answered,
|
|
99
|
+
// status it answered, run, which delivers a run of a schedule the same way
|
|
100
|
+
// on /chest-schedules, and close, which stops it and restores the
|
|
59
101
|
// environment. Its members are those who have the tool: the others are
|
|
60
102
|
// skipped.
|
|
61
103
|
export type FakeChest = {
|
|
@@ -68,45 +110,43 @@ export type FakeChest = {
|
|
|
68
110
|
notifications: FakeNotification[];
|
|
69
111
|
badges: Map<string, number>;
|
|
70
112
|
acknowledged: string[];
|
|
113
|
+
ai: FakeAiCall[];
|
|
71
114
|
emit(event: FakeEvent, to: string | ((request: Request) => Response | Promise<Response>)): Promise<number>;
|
|
115
|
+
run(name: string, to: string | ((request: Request) => Response | Promise<Response>), run?: FakeRun): Promise<number>;
|
|
72
116
|
close(): Promise<void>;
|
|
73
117
|
};
|
|
74
118
|
|
|
75
|
-
|
|
119
|
+
// What an assertion is signed with and says besides the member: the token
|
|
120
|
+
// (CHEST_TOKEN by default) and the tool (CHEST_TOOL by default) it is for,
|
|
121
|
+
// and when it is issued (now by default). The member is signed as given: a
|
|
122
|
+
// language or a zone the Chest would never send makes member() refuse the
|
|
123
|
+
// assertion, as it refuses the Chest's.
|
|
124
|
+
export type AssertionOptions = { token?: string; tool?: string; now?: Date };
|
|
76
125
|
|
|
77
126
|
// signAssertion is the Chest-Member value the Chest's front would send for
|
|
78
|
-
// that member: HS256 under the key of the token
|
|
79
|
-
//
|
|
80
|
-
export function signAssertion(member: Member, options:
|
|
127
|
+
// that member: HS256 under the key of the token, for the tool, valid 60
|
|
128
|
+
// seconds from when it is issued.
|
|
129
|
+
export function signAssertion(member: Member, options: AssertionOptions = {}): string {
|
|
81
130
|
const token = options.token ?? process.env["CHEST_TOKEN"];
|
|
82
131
|
const tool = options.tool ?? process.env["CHEST_TOOL"];
|
|
83
132
|
if (!token || !tool) throw new Error("signAssertion needs a token and a tool: start a fakeChest, or name them");
|
|
84
133
|
if (!memberIdPattern.test(member.id) || !member.groups.every(g => groupIdPattern.test(g))) throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
|
|
85
134
|
const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
86
|
-
|
|
135
|
+
// Under the label of the assertion's shape, as the Chest signs it and
|
|
136
|
+
// member() reads it.
|
|
137
|
+
return signClaims("Chest-Member v2", {
|
|
87
138
|
iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
|
|
88
139
|
given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
|
|
89
|
-
admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, ...(member.email === undefined ? {} : { email: member.email }),
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// the label of the assertion's shape under the text of the token.
|
|
93
|
-
const key = createHmac("sha256", Buffer.from(token, "utf8")).update("Chest-Member v2").digest();
|
|
94
|
-
return body + "." + createHmac("sha256", key).update(body).digest("base64url");
|
|
140
|
+
admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, time_zone: member.timeZone, ...(member.email === undefined ? {} : { email: member.email }),
|
|
141
|
+
language: member.language,
|
|
142
|
+
}, token);
|
|
95
143
|
}
|
|
96
144
|
|
|
97
|
-
// signEvent is the Chest-Event value the Chest would send with that body:
|
|
98
|
-
// HS256 under the key the token derives for events, for the tool, naming the
|
|
99
|
-
// event and the digest of the body, valid 60 seconds.
|
|
100
|
-
function signEvent(id: string, body: string, options: { token: string; tool: string }): string {
|
|
101
|
-
const iat = Math.floor(Date.now() / 1000);
|
|
102
|
-
const signed = encode({ alg: "HS256", typ: "JWT" }) + "." + encode({ aud: options.tool, iat, exp: iat + 60, jti: id, digest: createHash("sha256").update(body).digest("base64url") });
|
|
103
|
-
const key = createHmac("sha256", Buffer.from(options.token, "utf8")).update("Chest-Event v1").digest();
|
|
104
|
-
return signed + "." + createHmac("sha256", key).update(signed).digest("base64url");
|
|
105
|
-
}
|
|
106
145
|
|
|
107
|
-
// withMember is the request carrying that member's assertion
|
|
108
|
-
// Request, or the same Node request
|
|
109
|
-
|
|
146
|
+
// withMember is the request carrying that member's assertion, signed with
|
|
147
|
+
// the options of signAssertion: a new Web Request, or the same Node request
|
|
148
|
+
// with its header set.
|
|
149
|
+
export function withMember<R extends Request | IncomingMessage>(request: R, member: Member, options: AssertionOptions = {}): R {
|
|
110
150
|
const assertion = signAssertion(member, options);
|
|
111
151
|
if (request instanceof Request) {
|
|
112
152
|
const headers = new Headers(request.headers);
|
|
@@ -121,6 +161,19 @@ export function withMember<R extends Request | IncomingMessage>(request: R, memb
|
|
|
121
161
|
const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
|
|
122
162
|
const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
|
|
123
163
|
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}(\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}){0,7}$/u;
|
|
164
|
+
// Its links and uploads: their paths on the team host, their lives in
|
|
165
|
+
// seconds, the images it makes thumbnails of, the first bytes an upload of
|
|
166
|
+
// a type must start with, the endings of the names it chooses (chest/toolfiles).
|
|
167
|
+
const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/", linkLife = 900, uploadLife = 900;
|
|
168
|
+
const mediaPattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
|
|
169
|
+
const thumbnailed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
170
|
+
const starts = (...heads: string[]) => (data: Buffer): boolean => heads.some(head => data.subarray(0, head.length).equals(Buffer.from(head, "latin1")));
|
|
171
|
+
const signatures: Record<string, (data: Buffer) => boolean> = {
|
|
172
|
+
"image/jpeg": starts("\xff\xd8\xff"), "image/png": starts("\x89PNG\r\n\x1a\n"), "image/gif": starts("GIF87a", "GIF89a"),
|
|
173
|
+
"image/webp": data => data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP", "application/pdf": starts("%PDF-"),
|
|
174
|
+
};
|
|
175
|
+
const extensions: Record<string, string> = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", "image/heic": ".heic", "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json", "application/zip": ".zip", "video/mp4": ".mp4", "audio/mpeg": ".mp3" };
|
|
176
|
+
const newToken = (): string => randomBytes(18).toString("base64url") + "." + randomBytes(12).toString("base64url");
|
|
124
177
|
// Its notifications: recipients and badges a call, text, quotas.
|
|
125
178
|
const maxRecipients = 500, maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999;
|
|
126
179
|
const recipientsPerHour = 1000, itemsPerDay = 100, badgesPerMinute = 600;
|
|
@@ -130,6 +183,22 @@ const cleanTitle = (s: string): string => s.replace(/[\t\r\n]/gu, " ").replace(/
|
|
|
130
183
|
const cleanText = (s: string): string => s.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[^\P{Cc}\n]/gu, "").replace(reordering, "").trim();
|
|
131
184
|
const isPath = (p: unknown): boolean => typeof p === "string" && p.length <= maxPath && /^\/chest([/?#][\x21-\x5b\x5d-\x7e]*)?$/u.test(p) && !p.includes("//") && !p.split(/[?#]/u)[0]!.split("/").some(x => /^(\.|%2e){1,2}$/iu.test(x));
|
|
132
185
|
|
|
186
|
+
// Its AI: aliases in order, bounds of a request, requests a minute.
|
|
187
|
+
const aliasOrder: readonly Alias[] = ["default", "fast", "smart", "embedding"];
|
|
188
|
+
const chatKeys = ["model", "messages", "max_tokens", "stream", "temperature", "top_p", "stop", "tools", "tool_choice", "response_format", "parallel_tool_calls", "seed", "reasoning_effort", "member"];
|
|
189
|
+
const maxAiBody = 10 << 20, maxAiOutput = 128000, maxInputs = 256, aiPerMinute = 60, fakeDimensions = 8;
|
|
190
|
+
// A fake count of tokens: one per 4 characters.
|
|
191
|
+
const tokensOf = (text: string): number => Math.ceil(text.length / 4);
|
|
192
|
+
// A vector of a text, the same for the same text: unit length.
|
|
193
|
+
function vectorOf(text: string, dimensions: number): number[] {
|
|
194
|
+
const values: number[] = [];
|
|
195
|
+
for (let block = 0; values.length < dimensions; block++) {
|
|
196
|
+
for (const b of createHash("sha256").update(block + ":" + text).digest()) values.push(b / 127.5 - 1);
|
|
197
|
+
}
|
|
198
|
+
const kept = values.slice(0, dimensions), norm = Math.hypot(...kept) || 1;
|
|
199
|
+
return kept.map(x => x / norm);
|
|
200
|
+
}
|
|
201
|
+
|
|
133
202
|
const fold = (s: string): string => s.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
|
|
134
203
|
|
|
135
204
|
function send(response: ServerResponse, status: number, value?: unknown, headers: Record<string, string> = {}): void {
|
|
@@ -150,10 +219,11 @@ async function body(request: IncomingMessage, limit: number): Promise<Buffer | n
|
|
|
150
219
|
|
|
151
220
|
// fakeChest starts a Chest's API on 127.0.0.1 and points the environment at
|
|
152
221
|
// it: CHEST_API, CHEST_TOKEN (a new one) and CHEST_TOOL ("tool" unless the
|
|
153
|
-
// environment names one)
|
|
154
|
-
//
|
|
222
|
+
// environment names one), and what the Chest is (CHEST_ORGANIZATION,
|
|
223
|
+
// CHEST_TIME_ZONE, CHEST_LANGUAGE, as options.chest says). What member(),
|
|
224
|
+
// chest and the modules of the SDK read is then this Chest's.
|
|
155
225
|
export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChest> {
|
|
156
|
-
const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications"]);
|
|
226
|
+
const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications", "ai"]);
|
|
157
227
|
const email = capabilities.has("members.email");
|
|
158
228
|
const tool = process.env["CHEST_TOOL"] || "tool";
|
|
159
229
|
const token = randomBytes(32).toString("base64url");
|
|
@@ -164,12 +234,12 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
164
234
|
const receives = options.receives ?? ["member.*"];
|
|
165
235
|
// The erasures the tool was told of, by emit: those it may acknowledge.
|
|
166
236
|
const erasures = new Set<string>();
|
|
167
|
-
const chest: FakeChest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], emit: async () => 0, close: async () => {} };
|
|
237
|
+
const chest: FakeChest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], ai: [], emit: async () => 0, run: async () => 0, close: async () => {} };
|
|
168
238
|
const former = [...(options.former ?? [])];
|
|
169
239
|
let window = 0, calls = 0;
|
|
170
|
-
const shown = (m: Member) => ({ id: m.id, first_name: m.firstName, last_name: m.lastName, name: m.name, photo: m.photo, role: m.role, admin: m.isAdmin, builder: m.isBuilder, groups: m.groups, ...(email && m.email !== undefined ? { email: m.email } : {}) });
|
|
240
|
+
const shown = (m: Member) => ({ id: m.id, first_name: m.firstName, last_name: m.lastName, name: m.name, photo: m.photo, role: m.role, admin: m.isAdmin, builder: m.isBuilder, groups: m.groups, language: m.language, time_zone: m.timeZone, ...(email && m.email !== undefined ? { email: m.email } : {}) });
|
|
171
241
|
const key = (m: Member) => fold(m.name) + "\u0000" + m.id;
|
|
172
|
-
const described = (name: string, f: FakeFile) => ({ name, type: f.type, size: f.data.byteLength, updated: f.updated });
|
|
242
|
+
const described = (name: string, f: FakeFile) => ({ name, type: f.type, size: f.data.byteLength, sha256: createHash("sha256").update(f.data).digest("hex"), updated: f.updated });
|
|
173
243
|
|
|
174
244
|
async function members(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
175
245
|
if (!capabilities.has("members")) return send(response, 403, { error: "capability_not_granted" });
|
|
@@ -200,7 +270,8 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
200
270
|
for (const id of new Set(ids as string[])) {
|
|
201
271
|
const m = chest.members.find(x => x.id === id), f = former.find(x => x.id === id);
|
|
202
272
|
if (m) answer.members.push(shown(m));
|
|
203
|
-
else if (f?.erased) answer.former.push({ id, status: "erased" });
|
|
273
|
+
else if (f?.status === "erased") answer.former.push({ id, status: "erased" });
|
|
274
|
+
else if (f?.status === "no_access") answer.former.push({ id, name: f.name ?? "Test member", status: "no_access" });
|
|
204
275
|
else if (f) answer.former.push({ id, ...(f.name ? { name: f.name } : {}), status: "former" });
|
|
205
276
|
else answer.unknown.push(id);
|
|
206
277
|
}
|
|
@@ -223,23 +294,30 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
223
294
|
const names = [...files.keys()].filter(n => n.startsWith(prefix) && n > after).sort();
|
|
224
295
|
return send(response, 200, { files: names.slice(0, 1000).map(n => described(n, files.get(n)!)), next: names.length > 1000 ? names[999] : null });
|
|
225
296
|
}
|
|
226
|
-
if (request.method === "POST" && url.pathname === "/files/url") {
|
|
227
|
-
const raw = await body(request, 4096);
|
|
228
|
-
let name: unknown;
|
|
229
|
-
try { name = (JSON.parse(raw?.toString() ?? "") as { name?: unknown }).name; } catch { name = undefined; }
|
|
230
|
-
if (typeof name !== "string") return send(response, 400, { error: "invalid_body" });
|
|
231
|
-
if (!files.has(name)) return send(response, 404, { error: "not_found" });
|
|
232
|
-
return send(response, 200, { url: `https://${tool}-chest.chest.test/_chest/files/${Buffer.from(name).toString("base64url")}.fake`, expires_in: 900 });
|
|
233
|
-
}
|
|
234
|
-
if (request.method === "POST" && (url.pathname === "/files/move" || url.pathname === "/files/upload-url")) {
|
|
297
|
+
if (request.method === "POST" && (url.pathname === "/files/url" || url.pathname === "/files/move" || url.pathname === "/files/upload-url")) {
|
|
235
298
|
const raw = await body(request, 4096);
|
|
236
299
|
let command: Record<string, unknown> = {};
|
|
237
300
|
try { command = JSON.parse(raw?.toString() ?? "") as Record<string, unknown>; } catch { command = {}; }
|
|
301
|
+
if (url.pathname === "/files/url") {
|
|
302
|
+
const { name, thumbnail, download } = command;
|
|
303
|
+
if (typeof name !== "string" || !(thumbnail === undefined || thumbnail === 256 || thumbnail === 1024) || !(download === undefined || typeof download === "boolean")) return send(response, 400, { error: "invalid_body" });
|
|
304
|
+
const object = files.get(name);
|
|
305
|
+
if (!object) return send(response, 404, { error: "not_found" });
|
|
306
|
+
if (thumbnail !== undefined && !thumbnailed.includes(object.type)) return send(response, 400, { error: "no_thumbnail" });
|
|
307
|
+
const token = newToken();
|
|
308
|
+
links.set(token, { name, object, until: Date.now() + linkLife * 1000, download: download === true });
|
|
309
|
+
return send(response, 200, { url: `${chest.api}${linkPath}${token}`, expires_in: linkLife });
|
|
310
|
+
}
|
|
238
311
|
if (url.pathname === "/files/upload-url") {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (typeof
|
|
242
|
-
|
|
312
|
+
const { name, max_size: maxSize, types, expires_in: expiresIn } = command;
|
|
313
|
+
const folder = typeof name === "string" && name.endsWith("/");
|
|
314
|
+
if (typeof name !== "string" || !namePattern.test(folder ? name.slice(0, -1) : name)) return send(response, 400, { error: "invalid_name" });
|
|
315
|
+
if (!(types === undefined || (Array.isArray(types) && types.length <= 8 && types.every(t => typeof t === "string" && mediaPattern.test(t))))) return send(response, 400, { error: "invalid_type" });
|
|
316
|
+
if (!(maxSize === undefined || (typeof maxSize === "number" && Number.isSafeInteger(maxSize) && maxSize > 0)) || !(expiresIn === undefined || (typeof expiresIn === "number" && Number.isInteger(expiresIn) && expiresIn > 0 && expiresIn <= uploadLife))) return send(response, 400, { error: "invalid_body" });
|
|
317
|
+
if ((maxSize ?? 0) > maxObject) return send(response, 413, { error: "too_large" });
|
|
318
|
+
const token = newToken(), life = (expiresIn as number | undefined) ?? uploadLife;
|
|
319
|
+
uploads.set(token, { name, maxSize: (maxSize as number | undefined) ?? maxObject, types: (types as string[] | undefined) ?? [], until: Date.now() + life * 1000 });
|
|
320
|
+
return send(response, 200, { url: `${chest.api}${uploadPath}${token}`, method: "PUT", expires_in: life });
|
|
243
321
|
}
|
|
244
322
|
const from = command["from"], to = command["to"];
|
|
245
323
|
if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to)) return send(response, 400, { error: "invalid_name" });
|
|
@@ -271,6 +349,39 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
271
349
|
send(response, 404, { error: "not_found" });
|
|
272
350
|
}
|
|
273
351
|
|
|
352
|
+
// The team host's part of the files, on the fake's own origin: a link it
|
|
353
|
+
// signed opens the content it was signed for, for its life, as long as
|
|
354
|
+
// the file is that content — a thumbnail is the image itself, a fake does
|
|
355
|
+
// not reduce it —; an upload token takes one file, once, within its life,
|
|
356
|
+
// of the types it names and up to its size, whose first bytes are those of
|
|
357
|
+
// its type, named by the Chest in a folder. Nobody's session is checked:
|
|
358
|
+
// the Chest's own front does that.
|
|
359
|
+
const links = new Map<string, { name: string; object: FakeFile; until: number; download: boolean }>();
|
|
360
|
+
const uploads = new Map<string, { name: string; maxSize: number; types: string[]; until: number }>();
|
|
361
|
+
async function front(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
362
|
+
if (request.method === "GET" && url.pathname.startsWith(linkPath) && !url.pathname.startsWith(uploadPath)) {
|
|
363
|
+
const link = links.get(url.pathname.slice(linkPath.length));
|
|
364
|
+
if (!link || link.until < Date.now() || files.get(link.name) !== link.object) return send(response, 404, { error: "not_found" });
|
|
365
|
+
const headers: Record<string, string> = { "Content-Type": link.object.type, "Content-Length": String(link.object.data.byteLength), "X-Content-Type-Options": "nosniff" };
|
|
366
|
+
if (link.download) headers["Content-Disposition"] = `attachment; filename="${link.name.split("/").pop()}"`;
|
|
367
|
+
return void response.writeHead(200, headers).end(link.object.data);
|
|
368
|
+
}
|
|
369
|
+
if (request.method !== "PUT" || !url.pathname.startsWith(uploadPath)) return send(response, 404, { error: "not_found" });
|
|
370
|
+
const token = url.pathname.slice(uploadPath.length), grant = uploads.get(token);
|
|
371
|
+
uploads.delete(token);
|
|
372
|
+
if (!grant || grant.until < Date.now()) return send(response, 403, { error: "invalid_token" });
|
|
373
|
+
const type = (request.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
|
|
374
|
+
if (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !grant.types.some(t => t === type || (t.endsWith("/*") && type.startsWith(t.slice(0, -1)))))) return send(response, 415, { error: "type_refused" });
|
|
375
|
+
const data = await body(request, grant.maxSize);
|
|
376
|
+
if (data === null) return send(response, 413, { error: "too_large" });
|
|
377
|
+
if (signatures[type] && !signatures[type]!(data)) return send(response, 400, { error: "type_mismatch" });
|
|
378
|
+
const name = grant.name.endsWith("/") ? grant.name + randomBytes(10).toString("hex") + (extensions[type] ?? "") : grant.name;
|
|
379
|
+
const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
|
|
380
|
+
if (total + data.length > maxTotal || (!files.has(name) && files.size >= maxObjects)) return send(response, 429, { error: "quota_exceeded" });
|
|
381
|
+
files.set(name, { data: new Uint8Array(data), type, updated: new Date().toISOString() });
|
|
382
|
+
send(response, 201, { name, type, size: data.length });
|
|
383
|
+
}
|
|
384
|
+
|
|
274
385
|
// The windows of the notification quotas, each from the first call it
|
|
275
386
|
// counts: the tool's recipients this hour, each member's items this day,
|
|
276
387
|
// the tool's badge writes this minute.
|
|
@@ -369,6 +480,100 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
369
480
|
send(response, 200, { delivered: kept, skipped: ids.filter(id => !access(id)) });
|
|
370
481
|
}
|
|
371
482
|
|
|
483
|
+
// Its AI: the declared aliases, the spending this month, the requests this
|
|
484
|
+
// minute.
|
|
485
|
+
const mapped = (options.ai?.models ?? aliasOrder.map((alias): FakeAiModel => ({ alias, model: "fake-" + alias }))).map(m => ({ alias: m.alias, model: m.model, provider: m.provider ?? "openrouter", input: m.input ?? 1, output: m.output ?? 2 }));
|
|
486
|
+
const cap = options.ai?.cap ?? 5;
|
|
487
|
+
const aiMinute: Window = { start: 0, count: 0 };
|
|
488
|
+
let spent = 0;
|
|
489
|
+
const month = () => new Date().toISOString().slice(0, 7);
|
|
490
|
+
const resets = () => {
|
|
491
|
+
const now = new Date();
|
|
492
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString().replace(".000Z", "Z");
|
|
493
|
+
};
|
|
494
|
+
const spend = (m: { input: number; output: number }, input: number, output: number): number => {
|
|
495
|
+
const cost = Math.round((input * m.input + output * m.output) / 1e6 * 1e6) / 1e6;
|
|
496
|
+
spent = Math.round((spent + cost) * 1e6) / 1e6;
|
|
497
|
+
return cost;
|
|
498
|
+
};
|
|
499
|
+
// echo is the text of the last user message: its text, or its text parts.
|
|
500
|
+
const echo = (request: Record<string, unknown>): string => {
|
|
501
|
+
const last = [...request["messages"] as { role?: unknown; content?: unknown }[]].reverse().find(m => m?.role === "user")?.content;
|
|
502
|
+
return typeof last === "string" ? last : Array.isArray(last) ? last.map(p => (p as { text?: unknown })?.text).filter(t => typeof t === "string").join("") : "";
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
async function aiRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
506
|
+
const raw = request.method === "POST" ? await body(request, maxAiBody) : null;
|
|
507
|
+
let command: Record<string, unknown> | null = null;
|
|
508
|
+
try {
|
|
509
|
+
const value = JSON.parse(raw?.toString() ?? "") as unknown;
|
|
510
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) command = value as Record<string, unknown>;
|
|
511
|
+
} catch {
|
|
512
|
+
command = null;
|
|
513
|
+
}
|
|
514
|
+
chest.ai.push({ path: url.pathname, body: command });
|
|
515
|
+
if (!capabilities.has("ai")) return send(response, 403, { error: "capability_not_granted" });
|
|
516
|
+
if (request.method === "GET" && url.pathname === "/ai/models") return send(response, 200, { models: [...mapped].sort((a, b) => aliasOrder.indexOf(a.alias) - aliasOrder.indexOf(b.alias)) });
|
|
517
|
+
if (request.method === "GET" && url.pathname === "/ai/usage") return send(response, 200, { month: month(), spent, cap, resets: resets() });
|
|
518
|
+
if (request.method !== "POST" || (url.pathname !== "/ai/chat" && url.pathname !== "/ai/embeddings")) return send(response, 404, { error: "not_found" });
|
|
519
|
+
if (raw === null) return send(response, 413, { error: "too_large" });
|
|
520
|
+
const chat = url.pathname === "/ai/chat";
|
|
521
|
+
if (!command || !Object.keys(command).every(k => chat ? chatKeys.includes(k) : ["model", "input", "dimensions", "member"].includes(k))) return send(response, 400, { error: "invalid_body" });
|
|
522
|
+
const model = mapped.find(m => m.alias === command!["model"]);
|
|
523
|
+
if (!model) return send(response, 403, { error: "model_not_allowed" });
|
|
524
|
+
const member = command["member"];
|
|
525
|
+
if (member !== undefined && (typeof member !== "string" || !memberIdPattern.test(member))) return send(response, 400, { error: "invalid_body" });
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
// refused says the refusal of a valid request, if any: the rate, the
|
|
528
|
+
// provider, the cap.
|
|
529
|
+
const refused = (): boolean => {
|
|
530
|
+
const busy = live(aiMinute, 60_000, now) && aiMinute.count >= aiPerMinute;
|
|
531
|
+
if (busy) send(response, 429, { error: "rate_limited" }, wait(aiMinute, 60_000, now));
|
|
532
|
+
else {
|
|
533
|
+
count(aiMinute, 60_000, now, 1);
|
|
534
|
+
if (options.ai?.unavailable) send(response, options.ai.unavailable === "provider_key_invalid" ? 502 : 503, { error: options.ai.unavailable });
|
|
535
|
+
else if (spent >= cap) send(response, 402, { error: "cap_reached", scope: "tool", resets: resets() });
|
|
536
|
+
}
|
|
537
|
+
return response.headersSent;
|
|
538
|
+
};
|
|
539
|
+
if (!chat) {
|
|
540
|
+
const input = command["input"], texts = typeof input === "string" ? [input] : input, dimensions = command["dimensions"] ?? fakeDimensions;
|
|
541
|
+
if (!Array.isArray(texts) || texts.length < 1 || texts.length > maxInputs || !texts.every(t => typeof t === "string") || typeof dimensions !== "number" || !Number.isInteger(dimensions) || dimensions < 1 || dimensions > 4096) return send(response, 400, { error: "invalid_body" });
|
|
542
|
+
if (refused()) return;
|
|
543
|
+
const used = texts.reduce((sum: number, t: string) => sum + tokensOf(t), 0);
|
|
544
|
+
const cost = spend(model, used, 0);
|
|
545
|
+
return send(response, 200, { object: "list", data: texts.map((t: string, index) => ({ object: "embedding", index, embedding: vectorOf(t, dimensions) })), model: model.model, usage: { prompt_tokens: used, total_tokens: used, cost } });
|
|
546
|
+
}
|
|
547
|
+
const messages = command["messages"], limit = command["max_tokens"] ?? 4096;
|
|
548
|
+
if (!Array.isArray(messages) || messages.length < 1 || !messages.every(m => m !== null && typeof m === "object" && typeof (m as { role?: unknown }).role === "string") || typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > maxAiOutput || !(command["stream"] === undefined || typeof command["stream"] === "boolean")) return send(response, 400, { error: "invalid_body" });
|
|
549
|
+
if (refused()) return;
|
|
550
|
+
const given = options.ai?.reply ? options.ai.reply(command) : echo(command);
|
|
551
|
+
const text = typeof given === "string" ? given : given.text ?? "";
|
|
552
|
+
const calls = (typeof given === "string" ? [] : given.toolCalls ?? []).map((c, i) => ({ id: c.id ?? `call_${i + 1}`, type: "function" as const, function: { name: c.name, arguments: c.arguments } }));
|
|
553
|
+
const input = tokensOf(JSON.stringify(messages)), output = tokensOf(text + calls.map(c => c.function.name + c.function.arguments).join(""));
|
|
554
|
+
const usage = { prompt_tokens: input, completion_tokens: output, total_tokens: input + output, prompt_tokens_details: { cached_tokens: 0 }, cost: spend(model, input, output) };
|
|
555
|
+
const finish = calls.length ? "tool_calls" : "stop";
|
|
556
|
+
const head = { id: "chatcmpl-fake", created: Math.floor(now / 1000), model: model.model };
|
|
557
|
+
if (command["stream"] !== true) {
|
|
558
|
+
return send(response, 200, { ...head, object: "chat.completion", choices: [{ index: 0, message: { role: "assistant", content: calls.length && !text ? null : text, ...(calls.length ? { tool_calls: calls } : {}) }, finish_reason: finish }], usage });
|
|
559
|
+
}
|
|
560
|
+
// Streamed: the role, the text word by word, each call's name then its
|
|
561
|
+
// arguments in two halves, the finish reason, the usage, [DONE].
|
|
562
|
+
const chunk = (choices: unknown[], extra: Record<string, unknown> = {}) => `data: ${JSON.stringify({ ...head, object: "chat.completion.chunk", choices, ...extra })}\n\n`;
|
|
563
|
+
const delta = (d: Record<string, unknown>, finishReason: string | null = null) => chunk([{ index: 0, delta: d, finish_reason: finishReason }]);
|
|
564
|
+
const parts = [delta({ role: "assistant", content: "" })];
|
|
565
|
+
for (const word of text.match(/\S+\s*|\s+/gu) ?? []) parts.push(delta({ content: word }));
|
|
566
|
+
calls.forEach((c, index) => {
|
|
567
|
+
const half = Math.ceil(c.function.arguments.length / 2);
|
|
568
|
+
parts.push(delta({ tool_calls: [{ index, id: c.id, type: "function", function: { name: c.function.name, arguments: "" } }] }));
|
|
569
|
+
for (const piece of [c.function.arguments.slice(0, half), c.function.arguments.slice(half)]) if (piece) parts.push(delta({ tool_calls: [{ index, function: { arguments: piece } }] }));
|
|
570
|
+
});
|
|
571
|
+
parts.push(delta({}, finish), chunk([], { usage }), "data: [DONE]\n\n");
|
|
572
|
+
response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
|
|
573
|
+
for (const part of parts) response.write(part);
|
|
574
|
+
response.end();
|
|
575
|
+
}
|
|
576
|
+
|
|
372
577
|
// The acknowledgment of an erasure the tool was told of (emit).
|
|
373
578
|
async function erasuresRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
374
579
|
if (!receives.includes("member.*")) return send(response, 403, { error: "capability_not_granted" });
|
|
@@ -383,7 +588,9 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
383
588
|
|
|
384
589
|
const server = createServer((request, response) => {
|
|
385
590
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
386
|
-
const route = url.pathname.startsWith("/
|
|
591
|
+
const route = url.pathname.startsWith("/_chest/files/") ? front
|
|
592
|
+
: url.pathname.startsWith("/erasures/") ? erasuresRoute
|
|
593
|
+
: url.pathname.startsWith("/ai/") ? aiRoute
|
|
387
594
|
: url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
|
|
388
595
|
: url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
|
|
389
596
|
: url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
|
|
@@ -391,20 +598,38 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
391
598
|
route(request, response, url).catch(() => { if (!response.headersSent) send(response, 503, { error: "unavailable" }); else response.destroy(); });
|
|
392
599
|
});
|
|
393
600
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
394
|
-
const saved = Object.fromEntries(["CHEST_API", "CHEST_TOKEN", "CHEST_TOOL"].map(name => [name, process.env[name]]));
|
|
601
|
+
const saved = Object.fromEntries(["CHEST_API", "CHEST_TOKEN", "CHEST_TOOL", "CHEST_ORGANIZATION", "CHEST_TIME_ZONE", "CHEST_LANGUAGE", "CHEST_CURRENCY", "CHEST_TEAM_URL", "CHEST_PUBLIC_URL"].map(name => [name, process.env[name]]));
|
|
395
602
|
chest.api = "http://127.0.0.1:" + (server.address() as AddressInfo).port;
|
|
396
|
-
|
|
603
|
+
// Its links are taken by the files module of this process, for as long as
|
|
604
|
+
// it runs (api.ts, fakeOrigins).
|
|
605
|
+
fakeOrigins.add(chest.api);
|
|
606
|
+
const publicUrl = options.chest?.publicUrl === undefined ? `https://${tool}.chest.test` : options.chest.publicUrl;
|
|
607
|
+
Object.assign(process.env, { CHEST_API: chest.api, CHEST_TOKEN: token, CHEST_TOOL: tool, CHEST_ORGANIZATION: options.chest?.organization ?? "Test organization", CHEST_TIME_ZONE: options.chest?.timeZone ?? "UTC", CHEST_LANGUAGE: options.chest?.language ?? "en", CHEST_CURRENCY: options.chest?.currency ?? "EUR", CHEST_TEAM_URL: options.chest?.teamUrl ?? `https://${tool}-chest.chest.test` });
|
|
608
|
+
if (publicUrl === null) delete process.env["CHEST_PUBLIC_URL"];
|
|
609
|
+
else process.env["CHEST_PUBLIC_URL"] = publicUrl;
|
|
397
610
|
forget();
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const request = new Request(typeof to === "string" ? to.replace(/\/$/u, "") + "/chest-events" : "http://tool.test/chest-events", { method: "POST", headers: { "Content-Type": "application/json", "Chest-Event": signEvent(id, body, { token, tool }) }, body });
|
|
611
|
+
// post delivers what the Chest posts to the tool on a channel — at its
|
|
612
|
+
// address, or to a handler — and says the status it answered.
|
|
613
|
+
const post = async (path: string, channel: Channel, id: string, body: string, to: string | ((request: Request) => Response | Promise<Response>)): Promise<number> => {
|
|
614
|
+
const request = new Request((typeof to === "string" ? to.replace(/\/$/u, "") : "http://tool.test") + path, { method: "POST", headers: { "Content-Type": "application/json", [channel.header]: sign(channel, id, body, { token, tool }) }, body });
|
|
403
615
|
const answer = typeof to === "string" ? await fetch(request, { redirect: "manual" }) : await to(request);
|
|
404
616
|
await answer.body?.cancel();
|
|
405
617
|
return answer.status;
|
|
406
618
|
};
|
|
619
|
+
const newId = (prefix: string): string => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
|
|
620
|
+
chest.emit = async (event, to) => {
|
|
621
|
+
const id = event.id ?? newId("evt_");
|
|
622
|
+
const body = JSON.stringify({ id, type: event.type, occurredAt: event.occurredAt ?? new Date().toISOString(), data: event.data });
|
|
623
|
+
if (event.type === "member.erased") erasures.add(event.data.erasure);
|
|
624
|
+
return post("/chest-events", eventChannel, id, body, to);
|
|
625
|
+
};
|
|
626
|
+
chest.run = async (name, to, run = {}) => {
|
|
627
|
+
const id = run.id ?? newId("run_");
|
|
628
|
+
const body = JSON.stringify({ id, name, scheduledAt: run.scheduledAt ?? new Date().toISOString(), attempt: run.attempt ?? 1 });
|
|
629
|
+
return post("/chest-schedules", scheduleChannel, id, body, to);
|
|
630
|
+
};
|
|
407
631
|
chest.close = async () => {
|
|
632
|
+
fakeOrigins.delete(chest.api);
|
|
408
633
|
server.closeAllConnections();
|
|
409
634
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
410
635
|
for (const [name, value] of Object.entries(saved)) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
export * from "./src/errors.js";
|
|
2
2
|
export * from "./src/member.js";
|
|
3
|
+
export * from "./src/chest.js";
|
|
3
4
|
export * from "./src/database.js";
|
|
4
5
|
export * as files from "./src/files.js";
|
|
5
6
|
export * as members from "./src/members.js";
|
|
6
7
|
export * as notifications from "./src/notifications.js";
|
|
7
8
|
export * as events from "./src/events.js";
|
|
9
|
+
export * as schedules from "./src/schedules.js";
|
|
10
|
+
export * as ai from "./src/ai.js";
|
|
8
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAOA,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AACxC,OAAO,KAAK,OAAO,MAAM,kBAAkB,CAAC;AAC5C,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,MAAM,MAAM,iBAAiB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAOA,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AACxC,OAAO,KAAK,OAAO,MAAM,kBAAkB,CAAC;AAC5C,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,MAAM,MAAM,iBAAiB,CAAC;AAC1C,OAAO,KAAK,SAAS,MAAM,oBAAoB,CAAC;AAChD,OAAO,KAAK,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// The package root: every published module a tool's code uses (tool contract
|
|
2
|
-
//
|
|
3
|
-
// /database, /files, /members, /notifications, /events, /errors), which
|
|
4
|
-
// pulls in nothing else. The files, members, notifications and
|
|
5
|
-
// are namespaces here, as their names (get, list, stat, move, notify,
|
|
6
|
-
// verify…) are too plain to stand alone. @argentic/chest-sdk/testing is for a tool's tests only, and
|
|
2
|
+
// 0.4). Each one is also its own subpath (@argentic/chest-sdk/member, /chest,
|
|
3
|
+
// /database, /files, /members, /notifications, /events, /schedules, /ai, /errors), which
|
|
4
|
+
// pulls in nothing else. The files, members, notifications, events, schedules and ai
|
|
5
|
+
// APIs are namespaces here, as their names (get, list, stat, move, notify,
|
|
6
|
+
// verify, chat…) are too plain to stand alone. @argentic/chest-sdk/testing is for a tool's tests only, and
|
|
7
7
|
// is not here.
|
|
8
8
|
export * from "./src/errors.js";
|
|
9
9
|
export * from "./src/member.js";
|
|
10
|
+
export * from "./src/chest.js";
|
|
10
11
|
export * from "./src/database.js";
|
|
11
12
|
export * as files from "./src/files.js";
|
|
12
13
|
export * as members from "./src/members.js";
|
|
13
14
|
export * as notifications from "./src/notifications.js";
|
|
14
15
|
export * as events from "./src/events.js";
|
|
16
|
+
export * as schedules from "./src/schedules.js";
|
|
17
|
+
export * as ai from "./src/ai.js";
|
|
15
18
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,8EAA8E;AAC9E,yFAAyF;AACzF,qFAAqF;AACrF,2EAA2E;AAC3E,2GAA2G;AAC3G,eAAe;AACf,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AACxC,OAAO,KAAK,OAAO,MAAM,kBAAkB,CAAC;AAC5C,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,MAAM,MAAM,iBAAiB,CAAC;AAC1C,OAAO,KAAK,SAAS,MAAM,oBAAoB,CAAC;AAChD,OAAO,KAAK,EAAE,MAAM,aAAa,CAAC"}
|