@argentic/chest-sdk 0.1.1 → 0.3.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.
Files changed (57) hide show
  1. package/README.md +573 -37
  2. package/client/index.ts +12 -5
  3. package/client/src/ai.ts +374 -0
  4. package/client/src/api.ts +85 -0
  5. package/client/src/chest.ts +80 -0
  6. package/client/src/errors.ts +58 -3
  7. package/client/src/events.ts +228 -0
  8. package/client/src/files.ts +93 -88
  9. package/client/src/member.ts +61 -17
  10. package/client/src/members.ts +167 -0
  11. package/client/src/notifications.ts +148 -0
  12. package/client/src/testing.ts +564 -0
  13. package/dist/index.d.ts +5 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +12 -5
  16. package/dist/index.js.map +1 -1
  17. package/dist/src/ai.d.ts +131 -0
  18. package/dist/src/ai.d.ts.map +1 -0
  19. package/dist/src/ai.js +290 -0
  20. package/dist/src/ai.js.map +1 -0
  21. package/dist/src/api.d.ts +11 -0
  22. package/dist/src/api.d.ts.map +1 -0
  23. package/dist/src/api.js +93 -0
  24. package/dist/src/api.js.map +1 -0
  25. package/dist/src/chest.d.ts +10 -0
  26. package/dist/src/chest.d.ts.map +1 -0
  27. package/dist/src/chest.js +49 -0
  28. package/dist/src/chest.js.map +1 -0
  29. package/dist/src/errors.d.ts +19 -0
  30. package/dist/src/errors.d.ts.map +1 -1
  31. package/dist/src/errors.js +49 -3
  32. package/dist/src/errors.js.map +1 -1
  33. package/dist/src/events.d.ts +56 -0
  34. package/dist/src/events.d.ts.map +1 -0
  35. package/dist/src/events.js +193 -0
  36. package/dist/src/events.js.map +1 -0
  37. package/dist/src/files.d.ts +17 -1
  38. package/dist/src/files.d.ts.map +1 -1
  39. package/dist/src/files.js +95 -92
  40. package/dist/src/files.js.map +1 -1
  41. package/dist/src/member.d.ts +10 -3
  42. package/dist/src/member.d.ts.map +1 -1
  43. package/dist/src/member.js +34 -11
  44. package/dist/src/member.js.map +1 -1
  45. package/dist/src/members.d.ts +34 -0
  46. package/dist/src/members.d.ts.map +1 -0
  47. package/dist/src/members.js +146 -0
  48. package/dist/src/members.js.map +1 -0
  49. package/dist/src/notifications.d.ts +25 -0
  50. package/dist/src/notifications.d.ts.map +1 -0
  51. package/dist/src/notifications.js +121 -0
  52. package/dist/src/notifications.js.map +1 -0
  53. package/dist/src/testing.d.ts +101 -0
  54. package/dist/src/testing.d.ts.map +1 -0
  55. package/dist/src/testing.js +552 -0
  56. package/dist/src/testing.js.map +1 -0
  57. package/package.json +40 -4
@@ -0,0 +1,167 @@
1
+ import { ask, json, refusal } from "./api.js";
2
+ import { ChestError, Unavailable } from "./errors.js";
3
+ import { groupIdPattern, languagePattern, memberIdPattern, timeZonePattern, type Member } from "./member.js";
4
+
5
+ // Who has the tool, for a server tool whose chest.json declares
6
+ // "capabilities": ["members"] (and "members.email" for their addresses):
7
+ // exactly the members who have access to it at the time of the call — by a
8
+ // grant, a group, open to all, or because they run it. A member without
9
+ // access is indistinguishable from an identifier that does not exist.
10
+ //
11
+ // import * as members from "@argentic/chest-sdk/members";
12
+ // const { members: page, next } = await members.list({ q: "cam" });
13
+ // const one = await members.get("mbr_…"); // null: no such member here
14
+ // const { members: found, former, unknown } = await members.lookup(ids);
15
+ // const all = await members.groups.list(); // groups that give the tool
16
+ //
17
+ // Store member identifiers in your data, never names or addresses: resolve
18
+ // them when rendering, with lookup. Errors: CapabilityNotGranted (403),
19
+ // RateLimited (429, 600 calls a minute), Unavailable (503, or the Chest not
20
+ // reached), ChestError otherwise (invalid_id, invalid_query 400).
21
+
22
+ // A page of the list, and the cursor of the next one (null after the last).
23
+ export type MemberPage = { members: Member[]; next: string | null };
24
+ // A member who left the Chest after having the tool: "former" with the name
25
+ // they had, or "erased" without any once the owner had their data erased —
26
+ // render “Former member”.
27
+ export type FormerMember = { id: string; name: string | null; status: "former" | "erased" };
28
+ // What a lookup found: members who have the tool, former members, and
29
+ // identifiers the tool does not know.
30
+ export type Lookup = { members: Member[]; former: FormerMember[]; unknown: string[] };
31
+ // A group that gives the tool, with the identifiers of its members.
32
+ export type Group = { id: string; name: string; members: string[] };
33
+
34
+ const maxLimit = 500;
35
+ const lookupBatch = 200;
36
+ const cacheTime = 60_000;
37
+ const cacheSize = 5000;
38
+
39
+ function checkId(id: unknown): string {
40
+ if (typeof id !== "string" || !memberIdPattern.test(id)) throw new ChestError("invalid_id", 400, "invalid member identifier");
41
+ return id;
42
+ }
43
+
44
+ const text = (value: unknown, max: number): value is string => typeof value === "string" && value.length <= max;
45
+
46
+ // shown reads a member as the Chest answers it; anything else is not the
47
+ // Chest's answer.
48
+ function shown(value: unknown): Member {
49
+ const m = value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
50
+ if (!m || typeof m["id"] !== "string" || !memberIdPattern.test(m["id"]) || !text(m["first_name"], 256) || !text(m["last_name"], 256) || !text(m["name"], 520) || !(m["photo"] === null || text(m["photo"], 200)) || !(m["role"] === null || text(m["role"], 48)) || typeof m["admin"] !== "boolean" || typeof m["builder"] !== "boolean" || !Array.isArray(m["groups"]) || m["groups"].length > 16 || !m["groups"].every(g => typeof g === "string" && groupIdPattern.test(g)) || typeof m["language"] !== "string" || !languagePattern.test(m["language"]) || typeof m["time_zone"] !== "string" || !timeZonePattern.test(m["time_zone"]) || !(m["email"] === undefined || text(m["email"], 254))) throw new Unavailable();
51
+ return { id: m["id"], firstName: m["first_name"], lastName: m["last_name"], name: m["name"], photo: m["photo"], role: m["role"], isAdmin: m["admin"], isBuilder: m["builder"], groups: [...m["groups"]] as string[], language: m["language"], timeZone: m["time_zone"], ...(m["email"] === undefined ? {} : { email: m["email"] }) };
52
+ }
53
+
54
+ // list says the members who have the tool, by name then identifier, limit
55
+ // at a time (100 by default, 500 at most), after the cursor of the previous
56
+ // page. q finds the start of a first name, a last name or a name (and of an
57
+ // address with members.email), whatever its case and accents; role and group
58
+ // keep the members of that role, or of that group.
59
+ export async function list(options: { after?: string; limit?: number; q?: string; role?: string; group?: string } = {}): Promise<MemberPage> {
60
+ const query = new URLSearchParams();
61
+ if (options.after !== undefined) query.set("after", options.after);
62
+ if (options.limit !== undefined) {
63
+ if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > maxLimit) throw new ChestError("invalid_query", 400, "limit is 1 to 500");
64
+ query.set("limit", String(options.limit));
65
+ }
66
+ if (options.q !== undefined && options.q !== "") query.set("q", options.q);
67
+ if (options.role !== undefined) query.set("role", options.role);
68
+ if (options.group !== undefined) {
69
+ if (!groupIdPattern.test(options.group)) throw new ChestError("invalid_query", 400, "invalid group identifier");
70
+ query.set("group", options.group);
71
+ }
72
+ const response = await ask("members", "GET", "/members" + (query.size ? "?" + query.toString() : ""));
73
+ if (response.status !== 200) throw await refusal(response, "members");
74
+ const page = (await json(response)) as { members?: unknown; next?: unknown } | null;
75
+ if (!page || !Array.isArray(page.members) || page.members.length > maxLimit || !(page.next === null || text(page.next, 1024))) throw new Unavailable();
76
+ return { members: page.members.map(shown), next: page.next };
77
+ }
78
+
79
+ // get is the member of that identifier, or null when the tool does not know
80
+ // them: never a member, a former one, or one without access.
81
+ export async function get(id: string): Promise<Member | null> {
82
+ const response = await ask("members", "GET", "/members/" + checkId(id));
83
+ if (response.status === 404) {
84
+ await response.body?.cancel();
85
+ return null;
86
+ }
87
+ if (response.status !== 200) throw await refusal(response, "members");
88
+ return shown(await json(response));
89
+ }
90
+
91
+ // What lookup keeps in this process: each identifier's answer for a minute,
92
+ // 5000 of them at most, the oldest forgotten first — and nothing once an
93
+ // event of the members' lifecycle comes (events.handle).
94
+ type Known = { at: number } & ({ member: Member } | { former: FormerMember } | { unknown: true });
95
+ const known = new Map<string, Known>();
96
+
97
+ // forget empties what lookup keeps: the next lookup asks the Chest again.
98
+ export function forget(): void {
99
+ known.clear();
100
+ }
101
+
102
+ function keep(id: string, answer: Omit<Known, "at">): void {
103
+ known.delete(id);
104
+ known.set(id, { ...answer, at: Date.now() } as Known);
105
+ while (known.size > cacheSize) known.delete(known.keys().next().value as string);
106
+ }
107
+
108
+ // lookup resolves identifiers, each once, in the order given: the members
109
+ // who have the tool, the former members who had it, and the identifiers it
110
+ // does not know. Any number of them: the SDK asks 200 at a time, and keeps
111
+ // each answer a minute.
112
+ export async function lookup(ids: Iterable<string>): Promise<Lookup> {
113
+ const wanted = [...new Set([...ids].map(checkId))];
114
+ const now = Date.now();
115
+ const missing = wanted.filter(id => {
116
+ const k = known.get(id);
117
+ return !k || now - k.at >= cacheTime;
118
+ });
119
+ for (let i = 0; i < missing.length; i += lookupBatch) {
120
+ const batch = missing.slice(i, i + lookupBatch);
121
+ const response = await ask("members", "POST", "/members/lookup", { body: JSON.stringify({ ids: batch }), type: "application/json" });
122
+ if (response.status !== 200) throw await refusal(response, "members");
123
+ const answer = (await json(response)) as { members?: unknown; former?: unknown; unknown?: unknown } | null;
124
+ if (!answer || !Array.isArray(answer.members) || !Array.isArray(answer.former) || !Array.isArray(answer.unknown)) throw new Unavailable();
125
+ const told = new Set<string>();
126
+ for (const m of answer.members.map(shown)) {
127
+ keep(m.id, { member: m });
128
+ told.add(m.id);
129
+ }
130
+ for (const value of answer.former) {
131
+ const f = value as { id?: unknown; name?: unknown; status?: unknown } | null;
132
+ if (!f || typeof f.id !== "string" || !memberIdPattern.test(f.id) || !(f.status === "former" ? f.name === undefined || text(f.name, 520) : f.status === "erased" && f.name === undefined)) throw new Unavailable();
133
+ keep(f.id, { former: { id: f.id, name: (f.name as string | undefined) ?? null, status: f.status as FormerMember["status"] } });
134
+ told.add(f.id);
135
+ }
136
+ for (const id of answer.unknown) {
137
+ if (typeof id !== "string" || !memberIdPattern.test(id)) throw new Unavailable();
138
+ keep(id, { unknown: true });
139
+ told.add(id);
140
+ }
141
+ if (batch.some(id => !told.has(id))) throw new Unavailable();
142
+ }
143
+ const result: Lookup = { members: [], former: [], unknown: [] };
144
+ for (const id of wanted) {
145
+ const k = known.get(id);
146
+ if (k && "member" in k) result.members.push(k.member);
147
+ else if (k && "former" in k) result.former.push(k.former);
148
+ else result.unknown.push(id);
149
+ }
150
+ return result;
151
+ }
152
+
153
+ // groups are the groups of the Chest that give the tool, each with the
154
+ // identifiers of its members; nothing of the others.
155
+ export const groups = {
156
+ async list(): Promise<Group[]> {
157
+ const response = await ask("members", "GET", "/groups");
158
+ if (response.status !== 200) throw await refusal(response, "members");
159
+ const answer = (await json(response)) as { groups?: unknown } | null;
160
+ if (!answer || !Array.isArray(answer.groups) || answer.groups.length > 16) throw new Unavailable();
161
+ return answer.groups.map(value => {
162
+ const g = value as { id?: unknown; name?: unknown; members?: unknown } | null;
163
+ if (!g || typeof g.id !== "string" || !groupIdPattern.test(g.id) || !text(g.name, 256) || !Array.isArray(g.members) || g.members.length > 128 || !g.members.every(m => typeof m === "string" && memberIdPattern.test(m))) throw new Unavailable();
164
+ return { id: g.id, name: g.name, members: [...g.members] as string[] };
165
+ });
166
+ },
167
+ };
@@ -0,0 +1,148 @@
1
+ import { ask as chest, json, refusal } from "./api.js";
2
+ import { ChestError, Unavailable } from "./errors.js";
3
+ import { memberIdPattern } from "./member.js";
4
+
5
+ // Counters and notifications inside the Chest, for a server tool whose
6
+ // chest.json declares "capabilities": ["notifications"]: a badge is a count
7
+ // shown on the tool's tile for one member; a notification is an item in a
8
+ // member's inbox that opens a page of the tool. Only members who have access
9
+ // to the tool receive them; nothing leaves the Chest (no email, no push).
10
+ //
11
+ // import * as notifications from "@argentic/chest-sdk/notifications";
12
+ // const { delivered, skipped } = await notifications.notify(ids, { title: "New task", path: "/chest/tasks/42", key: "task:42" });
13
+ // await notifications.withdraw("task:42"); // the task is done: its items go
14
+ // const shown = await notifications.badge.set("mbr_…", 3); // false: no access
15
+ // await notifications.badge.setMany([{ memberId: "mbr_…", count: 0 }]);
16
+ //
17
+ // Text is plain: the Chest removes control characters, interprets neither
18
+ // Markdown nor HTML, keeps line breaks in body. A member who muted the tool
19
+ // counts as delivered: the tool never learns it. Errors: CapabilityNotGranted
20
+ // (403), QuotaExceeded (429: 1,000 recipients an hour, 100 items per member a
21
+ // day, 600 badge writes a minute), Unavailable (503, or the Chest not
22
+ // reached), ChestError otherwise (invalid_id, invalid_title, invalid_text,
23
+ // invalid_path, invalid_key, invalid_count, invalid_body 400).
24
+
25
+ // What a notification says: title (1 to 80 characters), body (280 at most),
26
+ // path (the page of the tool it opens, under /chest; /chest when not said)
27
+ // and key (a name of the tool's: an item of the same key for the same member
28
+ // is replaced, and withdraw removes it).
29
+ export type Notice = { title: string; body?: string; path?: string; key?: string };
30
+ // Who got it and who not, each identifier once in the order given: skipped
31
+ // are identifiers the Chest does not know and members without access.
32
+ export type Delivery = { delivered: string[]; skipped: string[] };
33
+ // A badge to set: a member and their count, 0 to clear it.
34
+ export type BadgeCount = { memberId: string; count: number };
35
+ // The members whose badge was set, and those skipped (no access), in the
36
+ // order given.
37
+ export type BadgeWrite = { set: string[]; skipped: string[] };
38
+
39
+ // The bounds of a Chest.
40
+ const maxMembers = 500, maxTitle = 80, maxBody = 280, maxPath = 512, maxCount = 9999;
41
+ const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
42
+ // What the Chest removes from a title before keeping it: control characters
43
+ // and the characters that reorder text.
44
+ const removed = /[\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
45
+
46
+ const ask = (method: string, path: string, value: unknown) => chest("notifications", method, path, { body: JSON.stringify(value), type: "application/json" });
47
+ // expect lets through the one answer expected; a refusal is what the Chest
48
+ // says, any other success is not the Chest's.
49
+ async function expect(response: Response, status: number): Promise<void> {
50
+ if (response.status === status) return;
51
+ if (response.status < 400) {
52
+ await response.body?.cancel();
53
+ throw new Unavailable();
54
+ }
55
+ throw await refusal(response, "notifications");
56
+ }
57
+ const length = (s: string): number => [...s].length;
58
+
59
+ function checkId(id: unknown): string {
60
+ if (typeof id !== "string" || !memberIdPattern.test(id)) throw new ChestError("invalid_id", 400, "invalid member identifier");
61
+ return id;
62
+ }
63
+ // checkIds reads 1 to 500 member identifiers, as given.
64
+ function checkIds(ids: Iterable<string>): string[] {
65
+ const all = [...ids];
66
+ if (all.length < 1 || all.length > maxMembers) throw new ChestError("invalid_body", 400, "1 to 500 member identifiers");
67
+ return all.map(checkId);
68
+ }
69
+ function checkKey(key: unknown): string {
70
+ if (typeof key !== "string" || !keyPattern.test(key)) throw new ChestError("invalid_key", 400, "a key is 1 to 64 of a-z 0-9 . _ : -");
71
+ return key;
72
+ }
73
+ function checkCount(count: unknown): number {
74
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 0 || count > maxCount) throw new ChestError("invalid_count", 400, "a count is 0 to 9999");
75
+ return count;
76
+ }
77
+ // checkPath accepts /chest, or /chest followed by '/', '?' or '#': printable
78
+ // ASCII but '\', 512 bytes at most, never '//', no '.' or '..' segment
79
+ // (written %2e either).
80
+ function checkPath(path: unknown): string {
81
+ if (typeof path !== "string" || path.length > maxPath || !/^\/chest([/?#][\x21-\x5b\x5d-\x7e]*)?$/u.test(path) || path.includes("//") || path.split(/[?#]/u)[0]!.split("/").some(s => /^(\.|%2e){1,2}$/iu.test(s))) throw new ChestError("invalid_path", 400, "a path is /chest or under it");
82
+ return path;
83
+ }
84
+
85
+ // partition checks an answer that splits the identifiers asked, each once,
86
+ // in their order, between two lists of it; anything else is not the Chest's.
87
+ function partition(answer: unknown, first: string, second: string, asked: string[]): [string[], string[]] {
88
+ const a = answer !== null && typeof answer === "object" && !Array.isArray(answer) ? answer as Record<string, unknown> : null;
89
+ const lists = [a?.[first], a?.[second]];
90
+ if (!lists.every(l => Array.isArray(l) && l.every(id => typeof id === "string"))) throw new Unavailable();
91
+ const [one, two] = lists as [string[], string[]];
92
+ const wanted = [...new Set(asked)];
93
+ const inOrder = (l: string[]) => l.every((id, i) => wanted.indexOf(id) > (i === 0 ? -1 : wanted.indexOf(l[i - 1]!)));
94
+ if (one.length + two.length !== wanted.length || !inOrder(one) || !inOrder(two) || one.some(id => two.includes(id))) throw new Unavailable();
95
+ return [[...one], [...two]];
96
+ }
97
+
98
+ // notify puts one item in the inbox of each member who has the tool, among
99
+ // 1 to 500 identifiers (each counted once). A link to path, on the tool's
100
+ // team host, opens it. With a key, the member's item of that key is replaced:
101
+ // new text, new time, first and unread again.
102
+ export async function notify(memberIds: Iterable<string>, notice: Notice): Promise<Delivery> {
103
+ const members = checkIds(memberIds);
104
+ const { title, body, path, key } = notice;
105
+ if (typeof title !== "string" || length(title) < 1 || length(title) > maxTitle || title.replace(removed, "").trim() === "") throw new ChestError("invalid_title", 400, "a title is 1 to 80 characters");
106
+ if (body !== undefined && (typeof body !== "string" || length(body) > maxBody)) throw new ChestError("invalid_text", 400, "a body is 280 characters at most");
107
+ const command = { members, title, ...(body ? { body } : {}), ...(path !== undefined ? { path: checkPath(path) } : {}), ...(key !== undefined ? { key: checkKey(key) } : {}) };
108
+ const response = await ask("POST", "/notifications", command);
109
+ await expect(response, 200);
110
+ const [delivered, skipped] = partition(await json(response), "delivered", "skipped", members);
111
+ return { delivered, skipped };
112
+ }
113
+
114
+ // withdraw removes the items of that key, from every member or from those
115
+ // named (1 to 500): the thing they were about is done. It never says what
116
+ // existed.
117
+ export async function withdraw(key: string, memberIds?: Iterable<string>): Promise<void> {
118
+ const command = { key: checkKey(key), ...(memberIds !== undefined ? { members: checkIds(memberIds) } : {}) };
119
+ const response = await ask("POST", "/notifications/withdraw", command);
120
+ await expect(response, 204);
121
+ }
122
+
123
+ // badge sets the count shown on the tool's tile for a member (0 to 9,999; 0
124
+ // clears it): a state, not an event, so setting it again changes nothing.
125
+ export const badge = {
126
+ // set is true once the member's badge is set, false when they do not have
127
+ // the tool.
128
+ async set(memberId: string, count: number): Promise<boolean> {
129
+ checkId(memberId);
130
+ const response = await ask("PUT", "/badges/" + memberId, { count: checkCount(count) });
131
+ await expect(response, 200);
132
+ return partition(await json(response), "set", "skipped", [memberId])[0].length === 1;
133
+ },
134
+ // setMany sets 1 to 500 badges, a member at most once.
135
+ async setMany(counts: Iterable<BadgeCount>): Promise<BadgeWrite> {
136
+ const all = [...counts];
137
+ if (all.length < 1 || all.length > maxMembers) throw new ChestError("invalid_body", 400, "1 to 500 badges");
138
+ const badges = all.map(b => {
139
+ if (b === null || typeof b !== "object") throw new ChestError("invalid_body", 400, "a badge is {memberId, count}");
140
+ return { member: checkId(b.memberId), count: checkCount(b.count) };
141
+ });
142
+ if (new Set(badges.map(b => b.member)).size !== badges.length) throw new ChestError("invalid_body", 400, "a member at most once");
143
+ const response = await ask("PUT", "/badges", { badges });
144
+ await expect(response, 200);
145
+ const [set, skipped] = partition(await json(response), "set", "skipped", badges.map(b => b.member));
146
+ return { set, skipped };
147
+ },
148
+ };