@argentic/chest-sdk 0.4.1 → 0.5.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 (76) hide show
  1. package/README.md +500 -73
  2. package/client/index.ts +11 -7
  3. package/client/src/api.ts +15 -8
  4. package/client/src/errors.ts +50 -0
  5. package/client/src/eventrules.ts +117 -0
  6. package/client/src/events.ts +181 -39
  7. package/client/src/files.ts +37 -7
  8. package/client/src/member.ts +15 -9
  9. package/client/src/members.ts +34 -16
  10. package/client/src/notifications.ts +84 -27
  11. package/client/src/realtime-client.ts +516 -0
  12. package/client/src/realtime.ts +118 -0
  13. package/client/src/sealed.ts +158 -0
  14. package/client/src/signed.ts +8 -4
  15. package/client/src/testing-realtime.ts +361 -0
  16. package/client/src/testing.ts +388 -89
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +11 -7
  20. package/dist/index.js.map +1 -1
  21. package/dist/src/api.d.ts +2 -0
  22. package/dist/src/api.d.ts.map +1 -1
  23. package/dist/src/api.js +14 -7
  24. package/dist/src/api.js.map +1 -1
  25. package/dist/src/errors.d.ts +18 -0
  26. package/dist/src/errors.d.ts.map +1 -1
  27. package/dist/src/errors.js +44 -0
  28. package/dist/src/errors.js.map +1 -1
  29. package/dist/src/eventrules.d.ts +23 -0
  30. package/dist/src/eventrules.d.ts.map +1 -0
  31. package/dist/src/eventrules.js +107 -0
  32. package/dist/src/eventrules.js.map +1 -0
  33. package/dist/src/events.d.ts +31 -6
  34. package/dist/src/events.d.ts.map +1 -1
  35. package/dist/src/events.js +130 -26
  36. package/dist/src/events.js.map +1 -1
  37. package/dist/src/files.d.ts +1 -0
  38. package/dist/src/files.d.ts.map +1 -1
  39. package/dist/src/files.js +34 -3
  40. package/dist/src/files.js.map +1 -1
  41. package/dist/src/member.d.ts +1 -1
  42. package/dist/src/member.d.ts.map +1 -1
  43. package/dist/src/member.js +9 -6
  44. package/dist/src/member.js.map +1 -1
  45. package/dist/src/members.d.ts +9 -2
  46. package/dist/src/members.d.ts.map +1 -1
  47. package/dist/src/members.js +29 -13
  48. package/dist/src/members.js.map +1 -1
  49. package/dist/src/notifications.d.ts +12 -1
  50. package/dist/src/notifications.d.ts.map +1 -1
  51. package/dist/src/notifications.js +60 -19
  52. package/dist/src/notifications.js.map +1 -1
  53. package/dist/src/realtime-client.d.ts +45 -0
  54. package/dist/src/realtime-client.d.ts.map +1 -0
  55. package/dist/src/realtime-client.js +453 -0
  56. package/dist/src/realtime-client.js.map +1 -0
  57. package/dist/src/realtime.d.ts +22 -0
  58. package/dist/src/realtime.d.ts.map +1 -0
  59. package/dist/src/realtime.js +99 -0
  60. package/dist/src/realtime.js.map +1 -0
  61. package/dist/src/sealed.d.ts +20 -0
  62. package/dist/src/sealed.d.ts.map +1 -0
  63. package/dist/src/sealed.js +121 -0
  64. package/dist/src/sealed.js.map +1 -0
  65. package/dist/src/signed.d.ts.map +1 -1
  66. package/dist/src/signed.js +6 -2
  67. package/dist/src/signed.js.map +1 -1
  68. package/dist/src/testing-realtime.d.ts +54 -0
  69. package/dist/src/testing-realtime.d.ts.map +1 -0
  70. package/dist/src/testing-realtime.js +376 -0
  71. package/dist/src/testing-realtime.js.map +1 -0
  72. package/dist/src/testing.d.ts +40 -3
  73. package/dist/src/testing.d.ts.map +1 -1
  74. package/dist/src/testing.js +365 -76
  75. package/dist/src/testing.js.map +1 -1
  76. package/package.json +23 -4
@@ -12,8 +12,12 @@ import type { IncomingMessage } from "node:http";
12
12
  // - photo is the address of their picture on the tool's team host
13
13
  // (/_chest/members/{id}/photo?v=<rev>), role one of the roles chest.json
14
14
  // declares: null when there is none.
15
- // - isBuilder says they build this tool; groups are the groups that give them
16
- // this tool ("grp_…").
15
+ // - isBuilder says they build this tool; groups are those of their groups
16
+ // the tool sees ("grp_…"): the groups that give them this tool, or all of
17
+ // their groups when the tool holds "members.groups". null when they are in
18
+ // more groups than travel with a request (about 150: the assertion keeps
19
+ // within half of a Node server's 16 KiB of headers): read them with
20
+ // members.get(member.id) — Microsoft's "groups overage", the same way.
17
21
  // - language is the language the Chest speaks to this member (their own,
18
22
  // else the Chest's default): a BCP 47 primary tag the product speaks
19
23
  // ("en", "fr"…). The tool's private part (/chest) speaks it to them; a
@@ -32,7 +36,7 @@ export type Member = {
32
36
  role: string | null;
33
37
  isAdmin: boolean;
34
38
  isBuilder: boolean;
35
- groups: string[];
39
+ groups: string[] | null;
36
40
  language: string;
37
41
  timeZone: string;
38
42
  email?: string;
@@ -58,9 +62,9 @@ export const timeZonePattern = /^(?:UTC|[A-Z][A-Za-z_]{1,31}(?:\/[A-Za-z0-9_+-]{
58
62
  // reader of the former claims still reads them. This module stands alone
59
63
  // (node:* only), so that it can be copied by itself.
60
64
  const label = "Chest-Member v2";
61
- // The claims every assertion carries; email only for a tool that holds
62
- // members.email.
63
- const claims = ["iss", "aud", "iat", "exp", "sub", "given_name", "family_name", "name", "picture", "role", "admin", "builder", "groups", "language", "time_zone"] as const;
65
+ // The claims every assertion carries; groups unless groups_overage says
66
+ // they did not fit; email only for a tool that holds members.email.
67
+ const claims = ["iss", "aud", "iat", "exp", "sub", "given_name", "family_name", "name", "picture", "role", "admin", "builder", "language", "time_zone"] as const;
64
68
  // Clocks of the Chest and of the container may differ by this much, in seconds.
65
69
  const skew = 5;
66
70
  // An assertion is a few hundred bytes; anything longer is not one.
@@ -107,13 +111,15 @@ export function member(request: IncomingMessage | Request): Member | null {
107
111
  if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) return null;
108
112
  const payload = json(encodedPayload);
109
113
  if (!payload || !claims.every(name => Object.hasOwn(payload, name))) return null;
110
- const { iss, aud, iat, exp, sub, given_name, family_name, name, email, picture, role, admin, builder, groups, language, time_zone } = payload;
114
+ const { iss, aud, iat, exp, sub, given_name, family_name, name, email, picture, role, admin, builder, groups, groups_overage, language, time_zone } = payload;
111
115
  if (typeof iss !== "string" || iss === "" || aud !== tool || typeof sub !== "string" || !memberIdPattern.test(sub)) return null;
112
116
  if (typeof iat !== "number" || !Number.isSafeInteger(iat) || typeof exp !== "number" || !Number.isSafeInteger(exp) || exp <= iat) return null;
113
117
  const now = Math.floor(Date.now() / 1000);
114
118
  if (iat > now + skew || exp <= now - skew) return null;
115
119
  if (typeof given_name !== "string" || typeof family_name !== "string" || typeof name !== "string" || typeof picture !== "string" || typeof role !== "string" || typeof admin !== "boolean" || typeof builder !== "boolean") return null;
116
- if (!Array.isArray(groups) || groups.length > 16 || !groups.every(g => typeof g === "string" && groupIdPattern.test(g)) || (email !== undefined && typeof email !== "string")) return null;
120
+ const overage = groups_overage === true && groups === undefined;
121
+ if (!overage && (groups_overage !== undefined || !Array.isArray(groups) || !groups.every(g => typeof g === "string" && groupIdPattern.test(g)))) return null;
122
+ if (email !== undefined && typeof email !== "string") return null;
117
123
  if (typeof language !== "string" || !languagePattern.test(language) || typeof time_zone !== "string" || !timeZonePattern.test(time_zone)) return null;
118
- return { id: sub, firstName: given_name, lastName: family_name, name, photo: picture === "" ? null : picture, role: role === "" ? null : role, isAdmin: admin, isBuilder: builder, groups: [...groups] as string[], language, timeZone: time_zone, ...(email === undefined ? {} : { email }) };
124
+ return { id: sub, firstName: given_name, lastName: family_name, name, photo: picture === "" ? null : picture, role: role === "" ? null : role, isAdmin: admin, isBuilder: builder, groups: overage ? null : [...groups as string[]], language, timeZone: time_zone, ...(email === undefined ? {} : { email }) };
119
125
  }
@@ -3,7 +3,8 @@ import { ChestError, Unavailable } from "./errors.js";
3
3
  import { groupIdPattern, languagePattern, memberIdPattern, timeZonePattern, type Member } from "./member.js";
4
4
 
5
5
  // Who has the tool, for a server tool whose chest.json declares
6
- // "capabilities": ["members"] (and "members.email" for their addresses):
6
+ // "capabilities": ["members"] (and "members.email" for their addresses,
7
+ // "members.groups" for every group of the Chest):
7
8
  // exactly the members who have access to it at the time of the call — by a
8
9
  // grant, a group, open to all, or because they run it. list and get see
9
10
  // only them; lookup also names those the tool had who no longer have it
@@ -13,7 +14,7 @@ import { groupIdPattern, languagePattern, memberIdPattern, timeZonePattern, type
13
14
  // const { members: page, next } = await members.list({ q: "cam" });
14
15
  // const one = await members.get("mbr_…"); // null: no such member here
15
16
  // const { members: found, former, unknown } = await members.lookup(ids);
16
- // const all = await members.groups.list(); // groups that give the tool
17
+ // const { groups, next: more } = await members.groups.list(); // the groups the tool sees, a page
17
18
  //
18
19
  // Store member identifiers in your data, never names or addresses: resolve
19
20
  // them when rendering, with lookup. Errors: CapabilityNotGranted (403),
@@ -32,8 +33,11 @@ export type FormerMember = { id: string; name: string | null; status: "no_access
32
33
  // longer have it, and identifiers the tool does not know — never had, or
33
34
  // forgotten.
34
35
  export type Lookup = { members: Member[]; former: FormerMember[]; unknown: string[] };
35
- // A group that gives the tool, with the identifiers of its members.
36
- export type Group = { id: string; name: string; members: string[] };
36
+ // A group the tool sees, and its size: how many of its members have the
37
+ // tool — members.list({ group: id }) pages them.
38
+ export type Group = { id: string; name: string; size: number };
39
+ // A page of groups, and the cursor of the next one (null after the last).
40
+ export type GroupPage = { groups: Group[]; next: string | null };
37
41
 
38
42
  const maxLimit = 500;
39
43
  const lookupBatch = 200;
@@ -51,7 +55,7 @@ const text = (value: unknown, max: number): value is string => typeof value ===
51
55
  // Chest's answer.
52
56
  function shown(value: unknown): Member {
53
57
  const m = value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
54
- 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();
58
+ 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"].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();
55
59
  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"] }) };
56
60
  }
57
61
 
@@ -155,18 +159,32 @@ export async function lookup(ids: Iterable<string>): Promise<Lookup> {
155
159
  return result;
156
160
  }
157
161
 
158
- // groups are the groups of the Chest that give the tool, each with the
159
- // identifiers of its members; nothing of the others.
162
+ // groups are the groups of the Chest the tool sees, a page at a time like
163
+ // members, by name then identifier: the groups that give it — or, with
164
+ // "members.groups", every group of the Chest, so that a tool open to
165
+ // everyone can offer "the Sales team". Each says its size; its members are
166
+ // members.list({ group }). Store group identifiers and resolve names when
167
+ // rendering, as for members; who joins or leaves one is member.updated
168
+ // naming "groups".
160
169
  export const groups = {
161
- async list(): Promise<Group[]> {
162
- const response = await ask("members", "GET", "/groups");
170
+ async list(options: { after?: string; limit?: number } = {}): Promise<GroupPage> {
171
+ const query = new URLSearchParams();
172
+ if (options.after !== undefined) query.set("after", options.after);
173
+ if (options.limit !== undefined) {
174
+ if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > maxLimit) throw new ChestError("invalid_query", 400, "limit is 1 to 500");
175
+ query.set("limit", String(options.limit));
176
+ }
177
+ const response = await ask("members", "GET", "/groups" + (query.size ? "?" + query.toString() : ""));
163
178
  if (response.status !== 200) throw await refusal(response, "members");
164
- const answer = (await json(response)) as { groups?: unknown } | null;
165
- if (!answer || !Array.isArray(answer.groups) || answer.groups.length > 16) throw new Unavailable();
166
- return answer.groups.map(value => {
167
- const g = value as { id?: unknown; name?: unknown; members?: unknown } | null;
168
- 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();
169
- return { id: g.id, name: g.name, members: [...g.members] as string[] };
170
- });
179
+ const page = (await json(response)) as { groups?: unknown; next?: unknown } | null;
180
+ if (!page || !Array.isArray(page.groups) || page.groups.length > maxLimit || !(page.next === null || text(page.next, 1024))) throw new Unavailable();
181
+ return {
182
+ groups: page.groups.map(value => {
183
+ const g = value as { id?: unknown; name?: unknown; size?: unknown } | null;
184
+ if (!g || typeof g.id !== "string" || !groupIdPattern.test(g.id) || !text(g.name, 256) || typeof g.size !== "number" || !Number.isSafeInteger(g.size) || g.size < 0) throw new Unavailable();
185
+ return { id: g.id, name: g.name, size: g.size };
186
+ }),
187
+ next: page.next,
188
+ };
171
189
  },
172
190
  };
@@ -1,32 +1,51 @@
1
1
  import { ask as chest, json, refusal } from "./api.js";
2
2
  import { ChestError, Unavailable } from "./errors.js";
3
- import { memberIdPattern } from "./member.js";
3
+ import { rolePattern } from "./eventrules.js";
4
+ import { groupIdPattern, languagePattern, memberIdPattern } from "./member.js";
4
5
 
5
6
  // Counters and notifications inside the Chest, for a server tool whose
6
7
  // chest.json declares "capabilities": ["notifications"]: a badge is a count
7
8
  // shown on the tool's tile for one member; a notification is an item in a
8
9
  // 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
+ // to the tool receive them. The tool sends nothing else: members may get
11
+ // mails of their notifications, as each of them chooses — the Chest's
12
+ // service. A notice may say the same in other languages: each member reads
13
+ // the one of theirs (member.language), the tool's own otherwise. Nothing is
14
+ // refused for its pace: beyond ten at once to a member, then one every six
15
+ // minutes, the Chest folds a tool's notices into one grouped item of that
16
+ // member's inbox ("37 new notifications", the latest shown) — the call
17
+ // still succeeds, nothing is lost.
10
18
  //
11
19
  // import * as notifications from "@argentic/chest-sdk/notifications";
12
20
  // const { delivered, skipped } = await notifications.notify(ids, { title: "New task", path: "/chest/tasks/42", key: "task:42" });
21
+ // await notifications.broadcast({ title: "New poll", translations: { fr: { title: "Nouveau sondage" } }, path: "/chest/polls/7" }, { to: { groups: ["grp_…"] }, except: [author] });
13
22
  // await notifications.withdraw("task:42"); // the task is done: its items go
14
23
  // const shown = await notifications.badge.set("mbr_…", 3); // false: no access
15
24
  // await notifications.badge.setMany([{ memberId: "mbr_…", count: 0 }]);
16
25
  //
17
26
  // Text is plain: the Chest removes control characters, interprets neither
18
27
  // 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).
28
+ // counts as delivered: the tool never learns it. Badges are a state, the
29
+ // last write wins. Errors: CapabilityNotGranted (403), Unavailable (503, or
30
+ // the Chest not reached), ChestError otherwise — only a malformed or
31
+ // oversized call
32
+ // (invalid_id, invalid_role, invalid_title, invalid_text, invalid_path,
33
+ // invalid_key, invalid_language, invalid_count, invalid_body 400).
24
34
 
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 };
35
+ // The words of a notification in one language: title (1 to 80 characters)
36
+ // and body (280 at most).
37
+ export type Words = { title: string; body?: string };
38
+ // What a notification says: its words in the tool's own language, path (the
39
+ // page of the tool it opens, under /chest; /chest when not said), key (a
40
+ // name of the tool's: an item of the same key for the same member is
41
+ // replaced, and withdraw removes it) and translations: the same words in
42
+ // other languages, by language tag ("fr"), each member reading theirs.
43
+ export type Notice = Words & { path?: string; key?: string; translations?: Record<string, Words> };
44
+ // Whom a broadcast reaches: every member who has the tool, or with to those
45
+ // in any of its groups or holding any of its roles — a group the tool does
46
+ // not see, or a role it does not declare, reaches no one —, but those of
47
+ // except (the author, those who already answered).
48
+ export type Audience = { to?: { groups?: string[]; roles?: string[] }; except?: Iterable<string> };
30
49
  // Who got it and who not, each identifier once in the order given: skipped
31
50
  // are identifiers the Chest does not know and members without access.
32
51
  export type Delivery = { delivered: string[]; skipped: string[] };
@@ -36,8 +55,10 @@ export type BadgeCount = { memberId: string; count: number };
36
55
  // order given.
37
56
  export type BadgeWrite = { set: string[]; skipped: string[] };
38
57
 
39
- // The bounds of a Chest.
40
- const maxMembers = 500, maxTitle = 80, maxBody = 280, maxPath = 512, maxCount = 9999;
58
+ // The bounds of a Chest on a call's texts and counts, and the roles a tool
59
+ // declares. No count bounds the members a call names: the Chest takes as
60
+ // many as its team holds, and refuses a body beyond (invalid_body).
61
+ const maxTitle = 80, maxBody = 280, maxPath = 512, maxCount = 9999, toolRoles = 16;
41
62
  const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
42
63
  // What the Chest removes from a title before keeping it: control characters
43
64
  // and the characters that reorder text.
@@ -60,10 +81,10 @@ function checkId(id: unknown): string {
60
81
  if (typeof id !== "string" || !memberIdPattern.test(id)) throw new ChestError("invalid_id", 400, "invalid member identifier");
61
82
  return id;
62
83
  }
63
- // checkIds reads 1 to 500 member identifiers, as given.
84
+ // checkIds reads member identifiers, one at least, as given.
64
85
  function checkIds(ids: Iterable<string>): string[] {
65
86
  const all = [...ids];
66
- if (all.length < 1 || all.length > maxMembers) throw new ChestError("invalid_body", 400, "1 to 500 member identifiers");
87
+ if (all.length < 1) throw new ChestError("invalid_body", 400, "one member identifier at least");
67
88
  return all.map(checkId);
68
89
  }
69
90
  function checkKey(key: unknown): string {
@@ -95,24 +116,60 @@ function partition(answer: unknown, first: string, second: string, asked: string
95
116
  return [[...one], [...two]];
96
117
  }
97
118
 
119
+ // checkWords reads a title and a body as the Chest bounds them.
120
+ function checkWords(words: unknown): Words {
121
+ const { title, body } = (words ?? {}) as Partial<Words>;
122
+ 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");
123
+ if (body !== undefined && (typeof body !== "string" || length(body) > maxBody)) throw new ChestError("invalid_text", 400, "a body is 280 characters at most");
124
+ return { title, ...(body ? { body } : {}) };
125
+ }
126
+
127
+ // command is a notice as the Chest reads it.
128
+ function command(notice: Notice): Record<string, unknown> {
129
+ const { path, key, translations } = notice;
130
+ const other: Record<string, Words> = {};
131
+ for (const [language, words] of Object.entries(translations ?? {})) {
132
+ if (!languagePattern.test(language)) throw new ChestError("invalid_language", 400, "a language is a tag such as fr");
133
+ other[language] = checkWords(words);
134
+ }
135
+ return { ...checkWords(notice), ...(path !== undefined ? { path: checkPath(path) } : {}), ...(key !== undefined ? { key: checkKey(key) } : {}), ...(translations !== undefined ? { translations: other } : {}) };
136
+ }
137
+
98
138
  // 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.
139
+ // the identifiers given (each counted once), in their language. A link to
140
+ // path, on the tool's team host, opens it. With a key, the member's item of
141
+ // that key is replaced: new text, new time, first and unread again.
102
142
  export async function notify(memberIds: Iterable<string>, notice: Notice): Promise<Delivery> {
103
143
  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);
144
+ const response = await ask("POST", "/notifications", { members, ...command(notice) });
109
145
  await expect(response, 200);
110
146
  const [delivered, skipped] = partition(await json(response), "delivered", "skipped", members);
111
147
  return { delivered, skipped };
112
148
  }
113
149
 
150
+ // broadcast puts one item, in their language, in the inbox of every member
151
+ // who has the tool now — or of those the audience names —, the Chest
152
+ // resolving who they are: the tool needs not see its members. It never says
153
+ // how many received it. Each recipient counts in the quotas, all or none.
154
+ export async function broadcast(notice: Notice, audience: Audience = {}): Promise<void> {
155
+ const { to } = audience;
156
+ let target: { groups?: string[]; roles?: string[] } | undefined;
157
+ if (to !== undefined) {
158
+ const groups = to.groups === undefined ? undefined : [...to.groups], roles = to.roles === undefined ? undefined : [...to.roles];
159
+ if (!groups?.length && !roles?.length) throw new ChestError("invalid_body", 400, "to names groups or roles: leave it out for everyone");
160
+ if ((roles?.length ?? 0) > toolRoles) throw new ChestError("invalid_body", 400, "16 roles at most: those the tool declares");
161
+ if (groups && !groups.every(g => typeof g === "string" && groupIdPattern.test(g))) throw new ChestError("invalid_id", 400, "invalid group identifier");
162
+ if (roles && !roles.every(r => typeof r === "string" && rolePattern.test(r))) throw new ChestError("invalid_role", 400, "invalid role");
163
+ target = { ...(groups?.length ? { groups } : {}), ...(roles?.length ? { roles } : {}) };
164
+ }
165
+ const except = audience.except === undefined ? undefined : [...audience.except];
166
+ except?.forEach(checkId);
167
+ const response = await ask("POST", "/notifications/broadcast", { ...command(notice), ...(target ? { to: target } : {}), ...(except?.length ? { except } : {}) });
168
+ await expect(response, 204);
169
+ }
170
+
114
171
  // 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
172
+ // named: the thing they were about is done. It never says what
116
173
  // existed.
117
174
  export async function withdraw(key: string, memberIds?: Iterable<string>): Promise<void> {
118
175
  const command = { key: checkKey(key), ...(memberIds !== undefined ? { members: checkIds(memberIds) } : {}) };
@@ -131,10 +188,10 @@ export const badge = {
131
188
  await expect(response, 200);
132
189
  return partition(await json(response), "set", "skipped", [memberId])[0].length === 1;
133
190
  },
134
- // setMany sets 1 to 500 badges, a member at most once.
191
+ // setMany sets badges, one at least, a member at most once.
135
192
  async setMany(counts: Iterable<BadgeCount>): Promise<BadgeWrite> {
136
193
  const all = [...counts];
137
- if (all.length < 1 || all.length > maxMembers) throw new ChestError("invalid_body", 400, "1 to 500 badges");
194
+ if (all.length < 1) throw new ChestError("invalid_body", 400, "one badge at least");
138
195
  const badges = all.map(b => {
139
196
  if (b === null || typeof b !== "object") throw new ChestError("invalid_body", 400, "a badge is {memberId, count}");
140
197
  return { member: checkId(b.memberId), count: checkCount(b.count) };