@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.
- package/README.md +500 -73
- package/client/index.ts +11 -7
- package/client/src/api.ts +15 -8
- package/client/src/errors.ts +50 -0
- package/client/src/eventrules.ts +117 -0
- package/client/src/events.ts +181 -39
- package/client/src/files.ts +37 -7
- package/client/src/member.ts +15 -9
- package/client/src/members.ts +34 -16
- package/client/src/notifications.ts +84 -27
- package/client/src/realtime-client.ts +516 -0
- package/client/src/realtime.ts +118 -0
- package/client/src/sealed.ts +158 -0
- package/client/src/signed.ts +8 -4
- package/client/src/testing-realtime.ts +361 -0
- package/client/src/testing.ts +388 -89
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/src/api.d.ts +2 -0
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +14 -7
- package/dist/src/api.js.map +1 -1
- package/dist/src/errors.d.ts +18 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +44 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/eventrules.d.ts +23 -0
- package/dist/src/eventrules.d.ts.map +1 -0
- package/dist/src/eventrules.js +107 -0
- package/dist/src/eventrules.js.map +1 -0
- package/dist/src/events.d.ts +31 -6
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +130 -26
- 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 +34 -3
- package/dist/src/files.js.map +1 -1
- package/dist/src/member.d.ts +1 -1
- package/dist/src/member.d.ts.map +1 -1
- package/dist/src/member.js +9 -6
- package/dist/src/member.js.map +1 -1
- package/dist/src/members.d.ts +9 -2
- package/dist/src/members.d.ts.map +1 -1
- package/dist/src/members.js +29 -13
- package/dist/src/members.js.map +1 -1
- package/dist/src/notifications.d.ts +12 -1
- package/dist/src/notifications.d.ts.map +1 -1
- package/dist/src/notifications.js +60 -19
- package/dist/src/notifications.js.map +1 -1
- package/dist/src/realtime-client.d.ts +45 -0
- package/dist/src/realtime-client.d.ts.map +1 -0
- package/dist/src/realtime-client.js +453 -0
- package/dist/src/realtime-client.js.map +1 -0
- package/dist/src/realtime.d.ts +22 -0
- package/dist/src/realtime.d.ts.map +1 -0
- package/dist/src/realtime.js +99 -0
- package/dist/src/realtime.js.map +1 -0
- package/dist/src/sealed.d.ts +20 -0
- package/dist/src/sealed.d.ts.map +1 -0
- package/dist/src/sealed.js +121 -0
- package/dist/src/sealed.js.map +1 -0
- package/dist/src/signed.d.ts.map +1 -1
- package/dist/src/signed.js +6 -2
- package/dist/src/signed.js.map +1 -1
- package/dist/src/testing-realtime.d.ts +54 -0
- package/dist/src/testing-realtime.d.ts.map +1 -0
- package/dist/src/testing-realtime.js +376 -0
- package/dist/src/testing-realtime.js.map +1 -0
- package/dist/src/testing.d.ts +40 -3
- package/dist/src/testing.d.ts.map +1 -1
- package/dist/src/testing.js +365 -76
- package/dist/src/testing.js.map +1 -1
- package/package.json +23 -4
package/client/src/member.ts
CHANGED
|
@@ -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
|
|
16
|
-
//
|
|
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;
|
|
62
|
-
// members.email.
|
|
63
|
-
const claims = ["iss", "aud", "iat", "exp", "sub", "given_name", "family_name", "name", "picture", "role", "admin", "builder", "
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/client/src/members.ts
CHANGED
|
@@ -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
|
|
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
|
|
36
|
-
|
|
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"]) ||
|
|
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
|
|
159
|
-
//
|
|
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<
|
|
162
|
-
const
|
|
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
|
|
165
|
-
if (!
|
|
166
|
-
return
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 {
|
|
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
|
|
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.
|
|
20
|
-
// (403),
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
100
|
-
// team host, opens it. With a key, the member's item of
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) };
|