@agentcomms/resend 0.22.0 → 0.23.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/dist/cli.mjs +2 -2
- package/dist/index.d.mts +1673 -1398
- package/dist/index.mjs +2 -2
- package/dist/{send-BopELDLH.mjs → send-Dnq7bTQn.mjs} +1399 -100
- package/dist/{server-A29VRQx8.mjs → server-CCzhekl9.mjs} +1 -1
- package/dist/{stdio-entry-CSaOonXX.mjs → stdio-entry-BWw-XnwO.mjs} +1 -1
- package/package.json +3 -3
|
@@ -12,7 +12,7 @@ import { execFile, spawn } from "node:child_process";
|
|
|
12
12
|
//#endregion
|
|
13
13
|
//#region src/version.ts
|
|
14
14
|
/** The package version, read from package.json at build time. */
|
|
15
|
-
const VERSION$2 = "0.
|
|
15
|
+
const VERSION$2 = "0.23.0";
|
|
16
16
|
//#endregion
|
|
17
17
|
//#region src/api/routes.ts
|
|
18
18
|
/**
|
|
@@ -201,7 +201,7 @@ const RESEND_CALLER = Object.freeze({
|
|
|
201
201
|
packageName: PACKAGE_NAME
|
|
202
202
|
});
|
|
203
203
|
//#endregion
|
|
204
|
-
//#region ../core/dist/update-check-
|
|
204
|
+
//#region ../core/dist/update-check-BrR1qAfk.mjs
|
|
205
205
|
/**
|
|
206
206
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
207
207
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
@@ -269,6 +269,16 @@ const ERROR_REGISTRY = {
|
|
|
269
269
|
retryable: false,
|
|
270
270
|
summary: "a behaviour rule does not allow this act here"
|
|
271
271
|
},
|
|
272
|
+
STANDING_REFUSED: {
|
|
273
|
+
exit: EXIT_CODES.APPROVAL,
|
|
274
|
+
retryable: false,
|
|
275
|
+
summary: "this standing approval does not cover this act"
|
|
276
|
+
},
|
|
277
|
+
STANDING_LIMIT: {
|
|
278
|
+
exit: EXIT_CODES.APPROVAL,
|
|
279
|
+
retryable: false,
|
|
280
|
+
summary: "a standing approval's limit is reached"
|
|
281
|
+
},
|
|
272
282
|
RATE_CAPPED: {
|
|
273
283
|
exit: EXIT_CODES.APPROVAL,
|
|
274
284
|
retryable: true,
|
|
@@ -1276,6 +1286,9 @@ function labelPhrase(name, word, limit) {
|
|
|
1276
1286
|
if ([...phrase].length > limit) throw new CommsError("USAGE", `the label "${phrase}" is longer than this platform's ${limit} characters`, { hint: "Set a shorter name with `label name`." });
|
|
1277
1287
|
return phrase;
|
|
1278
1288
|
}
|
|
1289
|
+
/** `<name> (<word>)`: a name holds no parenthesis or line break (`nameProblem`), so the first `(` is the word's. */
|
|
1290
|
+
const LABEL_PATTERN = `[^()\\n]{1,160} \\((?:${AGENT_LABEL_WORDS.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\)`;
|
|
1291
|
+
new RegExp(`^\\s*(?:${LABEL_PATTERN}: |Sent by ${LABEL_PATTERN}(?:$|\\s))`, "iu");
|
|
1279
1292
|
/** L3's sentence for an unlabelled message, the first line of its preview. */
|
|
1280
1293
|
const UNLABELLED_PREVIEW_LINE = "Sent as you, with no label: it will look as if you wrote it yourself";
|
|
1281
1294
|
function addressMatches(pattern, address) {
|
|
@@ -1728,12 +1741,17 @@ function sameDisclosureBinding(left, right) {
|
|
|
1728
1741
|
return false;
|
|
1729
1742
|
}
|
|
1730
1743
|
}
|
|
1731
|
-
/**
|
|
1744
|
+
/**
|
|
1745
|
+
* The lifetime profile, in milliseconds: pending on each route, approved, a download's question, and a standing
|
|
1746
|
+
* approval's child — usable once, within two minutes of the use that minted it. A standing record's own approved
|
|
1747
|
+
* lifetime is its days (`approvedMs`, checked against its limits), not one of these.
|
|
1748
|
+
*/
|
|
1732
1749
|
const APPROVAL_LIFETIMES = Object.freeze({
|
|
1733
1750
|
chat: 6e5,
|
|
1734
1751
|
confirm: 18e5,
|
|
1735
1752
|
approved: 864e5,
|
|
1736
|
-
download: 18e5
|
|
1753
|
+
download: 18e5,
|
|
1754
|
+
standingChild: 12e4
|
|
1737
1755
|
});
|
|
1738
1756
|
/**
|
|
1739
1757
|
* The sending lease (design 2026-10-05 §D1): a `sending` record whose claimant has not renewed it for this long reads
|
|
@@ -1754,8 +1772,9 @@ function unknownAtOf(record) {
|
|
|
1754
1772
|
const at = Date.parse(renewed);
|
|
1755
1773
|
return Number.isFinite(at) ? new Date(at + SENDING_LEASE_MS).toISOString() : void 0;
|
|
1756
1774
|
}
|
|
1757
|
-
/** How long a pending send or change on `route` stays open. */
|
|
1775
|
+
/** How long a pending send or change on `route` stays open; a standing child is never pending, and lasts its own. */
|
|
1758
1776
|
function pendingMsOf(route) {
|
|
1777
|
+
if (route === "standing") return APPROVAL_LIFETIMES.standingChild;
|
|
1759
1778
|
return route === "confirm" ? APPROVAL_LIFETIMES.confirm : APPROVAL_LIFETIMES.chat;
|
|
1760
1779
|
}
|
|
1761
1780
|
/** Whether `channel` is one this release knows: a manifest channel of the generated snapshot. */
|
|
@@ -1766,9 +1785,20 @@ function isKnownChannel(channel) {
|
|
|
1766
1785
|
function requireKnownChannel(channel) {
|
|
1767
1786
|
if (!isKnownChannel(channel)) throw new CommsError("UNEXPECTED", `an approval cannot be made for the channel "${channel}": this release does not know it`, { hint: "This is a bug — please report it." });
|
|
1768
1787
|
}
|
|
1788
|
+
/** Why `use` is not a child's use of its standing approval, or null: three strings, its content a SHA-256. */
|
|
1789
|
+
function standingUseProblem(use) {
|
|
1790
|
+
if (typeof use !== "object" || use === null || Array.isArray(use)) return "it names no use";
|
|
1791
|
+
const fields = use;
|
|
1792
|
+
if (Object.keys(fields).some((key) => key !== "channel" && key !== "thread" && key !== "content")) return "it names more than its use";
|
|
1793
|
+
for (const field of ["channel", "thread"]) if (typeof fields[field] !== "string" || fields[field] === "") return `its use has no ${field}`;
|
|
1794
|
+
if (typeof fields.content !== "string" || !/^[0-9a-f]{64}$/.test(fields.content)) return "its use names no content";
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1769
1797
|
/**
|
|
1770
1798
|
* The identity a record is bound to, exactly as stored. An absent `inboxSub` is left out rather than kept as
|
|
1771
|
-
* `undefined`; a send always
|
|
1799
|
+
* `undefined`; a send and a standing record always carry their send epoch. A standing record and its children carry
|
|
1800
|
+
* the standing epoch, and a child its standing id — each only when present, so every other record's identity, and its
|
|
1801
|
+
* digest, is exactly what it was before standing approvals existed.
|
|
1772
1802
|
*/
|
|
1773
1803
|
function identityOf(record) {
|
|
1774
1804
|
return {
|
|
@@ -1785,7 +1815,14 @@ function identityOf(record) {
|
|
|
1785
1815
|
bcc: [...record.expect.bcc],
|
|
1786
1816
|
subject: record.expect.subject
|
|
1787
1817
|
},
|
|
1788
|
-
...record.kind === "send" ? { sendEpoch: record.sendEpoch } : {}
|
|
1818
|
+
...record.kind === "send" || record.kind === "standing" ? { sendEpoch: record.sendEpoch } : {},
|
|
1819
|
+
...record.standingEpoch === void 0 ? {} : { standingEpoch: record.standingEpoch },
|
|
1820
|
+
...record.standingId === void 0 ? {} : { standingId: record.standingId },
|
|
1821
|
+
...record.standingUse === void 0 ? {} : { standingUse: {
|
|
1822
|
+
channel: record.standingUse.channel,
|
|
1823
|
+
thread: record.standingUse.thread,
|
|
1824
|
+
content: record.standingUse.content
|
|
1825
|
+
} }
|
|
1789
1826
|
};
|
|
1790
1827
|
}
|
|
1791
1828
|
/** One listed file, as stored: its name and size, and its rename reason and flags only when it has them. */
|
|
@@ -3295,12 +3332,12 @@ function collectFromJson(parsed, client, path, topScope = "user") {
|
|
|
3295
3332
|
function collectFromToml(text, client, path) {
|
|
3296
3333
|
const servers = parseToml(text).mcp_servers;
|
|
3297
3334
|
if (servers === void 0) return [];
|
|
3298
|
-
if (!isRecord$
|
|
3335
|
+
if (!isRecord$2(servers)) throw new TomlError("mcp_servers is not a table");
|
|
3299
3336
|
const found = [];
|
|
3300
3337
|
for (const [name, entry] of Object.entries(servers)) {
|
|
3301
|
-
if (!isRecord$
|
|
3338
|
+
if (!isRecord$2(entry)) throw new TomlError(`mcp_servers.${name} is not a table`);
|
|
3302
3339
|
if (entry.args !== void 0 && !Array.isArray(entry.args)) throw new TomlError(`mcp_servers.${name}.args is not an array`);
|
|
3303
|
-
if (entry.env !== void 0 && !isRecord$
|
|
3340
|
+
if (entry.env !== void 0 && !isRecord$2(entry.env)) throw new TomlError(`mcp_servers.${name}.env is not a table`);
|
|
3304
3341
|
found.push({
|
|
3305
3342
|
client,
|
|
3306
3343
|
path,
|
|
@@ -3308,12 +3345,12 @@ function collectFromToml(text, client, path) {
|
|
|
3308
3345
|
command: typeof entry.command === "string" ? entry.command : "",
|
|
3309
3346
|
args: Array.isArray(entry.args) ? entry.args.map(String) : [],
|
|
3310
3347
|
...typeof entry.url === "string" ? { url: entry.url } : {},
|
|
3311
|
-
...isRecord$
|
|
3348
|
+
...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
|
|
3312
3349
|
});
|
|
3313
3350
|
}
|
|
3314
3351
|
return found;
|
|
3315
3352
|
}
|
|
3316
|
-
function isRecord$
|
|
3353
|
+
function isRecord$2(value) {
|
|
3317
3354
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3318
3355
|
}
|
|
3319
3356
|
/**
|
|
@@ -3326,8 +3363,8 @@ function isRecord$1(value) {
|
|
|
3326
3363
|
*/
|
|
3327
3364
|
function codexServerFromGet(name, stdout, path) {
|
|
3328
3365
|
const answer = JSON.parse(stdout);
|
|
3329
|
-
if (!isRecord$
|
|
3330
|
-
const entry = isRecord$
|
|
3366
|
+
if (!isRecord$2(answer)) throw new SyntaxError("not an object");
|
|
3367
|
+
const entry = isRecord$2(answer.transport) ? answer.transport : answer;
|
|
3331
3368
|
const command = typeof entry.command === "string" ? entry.command : "";
|
|
3332
3369
|
const url = typeof entry.url === "string" ? entry.url : void 0;
|
|
3333
3370
|
if (!command && !url) throw new SyntaxError("neither a command nor a URL");
|
|
@@ -3339,7 +3376,7 @@ function codexServerFromGet(name, stdout, path) {
|
|
|
3339
3376
|
command,
|
|
3340
3377
|
args: Array.isArray(entry.args) ? entry.args.map(String) : [],
|
|
3341
3378
|
...url ? { url } : {},
|
|
3342
|
-
...isRecord$
|
|
3379
|
+
...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
|
|
3343
3380
|
};
|
|
3344
3381
|
return {
|
|
3345
3382
|
...server,
|
|
@@ -3445,7 +3482,7 @@ async function scanRegisteredServers(env = process.env, platform = process.platf
|
|
|
3445
3482
|
continue;
|
|
3446
3483
|
}
|
|
3447
3484
|
add(collectFromJson(parsed, file.client, file.path));
|
|
3448
|
-
if (file.client !== "claude-code" || !isRecord$
|
|
3485
|
+
if (file.client !== "claude-code" || !isRecord$2(parsed) || !isRecord$2(parsed.projects)) continue;
|
|
3449
3486
|
for (const root of Object.keys(parsed.projects)) {
|
|
3450
3487
|
if (!isAbsolute(root)) continue;
|
|
3451
3488
|
const path = join(root, ".mcp.json");
|
|
@@ -6429,7 +6466,7 @@ const BEHAVIOUR_ACTS = Object.freeze([
|
|
|
6429
6466
|
function isBehaviourAct(value) {
|
|
6430
6467
|
return typeof value === "string" && BEHAVIOUR_ACTS.includes(value);
|
|
6431
6468
|
}
|
|
6432
|
-
function isRecord$
|
|
6469
|
+
function isRecord$1(value) {
|
|
6433
6470
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6434
6471
|
}
|
|
6435
6472
|
/**
|
|
@@ -6476,7 +6513,7 @@ function accountNamed(written, names) {
|
|
|
6476
6513
|
/** One stored rule, or why it cannot be read. Reasons are fixed words: nothing from the file is repeated in them. */
|
|
6477
6514
|
function readRule(raw, index, names) {
|
|
6478
6515
|
const at = `rule ${index + 1}`;
|
|
6479
|
-
if (!isRecord$
|
|
6516
|
+
if (!isRecord$1(raw)) return { unreadable: {
|
|
6480
6517
|
account: null,
|
|
6481
6518
|
reason: `${at} is not an object`
|
|
6482
6519
|
} };
|
|
@@ -6532,7 +6569,7 @@ function readBehaviourSettings(config) {
|
|
|
6532
6569
|
rules: [],
|
|
6533
6570
|
unreadable: []
|
|
6534
6571
|
};
|
|
6535
|
-
if (!isRecord$
|
|
6572
|
+
if (!isRecord$1(stored)) return unreadableWhole("the behaviour setting is not an object");
|
|
6536
6573
|
const rules = stored.rules;
|
|
6537
6574
|
if (rules === void 0) return {
|
|
6538
6575
|
rules: [],
|
|
@@ -6909,7 +6946,7 @@ const SHOWN_WIDTH = 120;
|
|
|
6909
6946
|
* space, and cut to `SHOWN_WIDTH`. As `shownText` shows a profile's text, which this module cannot import: its module
|
|
6910
6947
|
* reads the configuration, and the configuration imports this one.
|
|
6911
6948
|
*/
|
|
6912
|
-
function shownName(value) {
|
|
6949
|
+
function shownName$1(value) {
|
|
6913
6950
|
const flat = neutralise(value).text.replace(/[\n\t]+/g, " ");
|
|
6914
6951
|
const chars = [...flat];
|
|
6915
6952
|
return chars.length <= SHOWN_WIDTH ? flat : `${chars.slice(0, 119).join("")}…`;
|
|
@@ -6922,20 +6959,20 @@ function shownName(value) {
|
|
|
6922
6959
|
* act, the place and the rule as the decision holds them, made safe the same way.
|
|
6923
6960
|
*/
|
|
6924
6961
|
function actNotAllowed(decision, shown, hint) {
|
|
6925
|
-
const account = shownName(shown.account);
|
|
6962
|
+
const account = shownName$1(shown.account);
|
|
6926
6963
|
const named = shown.place ?? decision.place;
|
|
6927
|
-
const where = named === null ? `in ${account}` : `in ${shownName(named)} (${account})`;
|
|
6964
|
+
const where = named === null ? `in ${account}` : `in ${shownName$1(named)} (${account})`;
|
|
6928
6965
|
const message = decision.allowedActs.length === 0 ? `this agent may do nothing ${where}` : `this agent may only ${inWords(decision.allowedActs)} ${where}`;
|
|
6929
|
-
const rule = decision.rule.conversation === void 0 ? { account: shownName(decision.rule.account) } : {
|
|
6930
|
-
account: shownName(decision.rule.account),
|
|
6931
|
-
conversation: shownName(decision.rule.conversation)
|
|
6966
|
+
const rule = decision.rule.conversation === void 0 ? { account: shownName$1(decision.rule.account) } : {
|
|
6967
|
+
account: shownName$1(decision.rule.account),
|
|
6968
|
+
conversation: shownName$1(decision.rule.conversation)
|
|
6932
6969
|
};
|
|
6933
6970
|
return new CommsError("ACT_NOT_ALLOWED", message, {
|
|
6934
6971
|
hint,
|
|
6935
6972
|
details: {
|
|
6936
6973
|
act: decision.act,
|
|
6937
6974
|
account,
|
|
6938
|
-
place: decision.place === null ? null : shownName(decision.place),
|
|
6975
|
+
place: decision.place === null ? null : shownName$1(decision.place),
|
|
6939
6976
|
rule,
|
|
6940
6977
|
allowedActs: [...decision.allowedActs]
|
|
6941
6978
|
}
|
|
@@ -6963,8 +7000,8 @@ function allowedHereLine(decision, shown) {
|
|
|
6963
7000
|
const acts = decision.allowedActs;
|
|
6964
7001
|
const allowed = acts === "all" ? "everything" : acts.length === 0 ? "nothing" : acts.join(", ");
|
|
6965
7002
|
const conversation = decision.rule.conversation;
|
|
6966
|
-
const place = conversation === void 0 ? "" : `, ${shownName(shown.place ?? conversation)}`;
|
|
6967
|
-
return `Allowed here: ${allowed} (rule: ${shownName(shown.account)}${place})`;
|
|
7003
|
+
const place = conversation === void 0 ? "" : `, ${shownName$1(shown.place ?? conversation)}`;
|
|
7004
|
+
return `Allowed here: ${allowed} (rule: ${shownName$1(shown.account)}${place})`;
|
|
6968
7005
|
}
|
|
6969
7006
|
/**
|
|
6970
7007
|
* `config.behaviour` with every rule that holds the account `id` renamed from `from` to `to` (design
|
|
@@ -6979,7 +7016,7 @@ function allowedHereLine(decision, shown) {
|
|
|
6979
7016
|
*/
|
|
6980
7017
|
function behaviourRenamed(config, id, from, to) {
|
|
6981
7018
|
const stored = config.behaviour;
|
|
6982
|
-
if (!isRecord$
|
|
7019
|
+
if (!isRecord$1(stored)) return stored;
|
|
6983
7020
|
const rules = stored.rules;
|
|
6984
7021
|
if (!Array.isArray(rules)) return stored;
|
|
6985
7022
|
const held = new Set([...Object.values(config.inboxes ?? {}), ...Object.values(config.accounts ?? {})].map((entry) => entry.id));
|
|
@@ -6990,7 +7027,7 @@ function behaviourRenamed(config, id, from, to) {
|
|
|
6990
7027
|
};
|
|
6991
7028
|
return {
|
|
6992
7029
|
...stored,
|
|
6993
|
-
rules: rules.map((rule) => isRecord$
|
|
7030
|
+
rules: rules.map((rule) => isRecord$1(rule) && holds(rule) ? {
|
|
6994
7031
|
...rule,
|
|
6995
7032
|
account: to
|
|
6996
7033
|
} : rule)
|
|
@@ -7567,6 +7604,7 @@ const configV2Schema = looseObject({
|
|
|
7567
7604
|
/** What version 3 adds, loose as the rest of the file is: a later release's keys inside them are kept. */
|
|
7568
7605
|
const v3Shape = {
|
|
7569
7606
|
sendEpochs: record(string().min(1), number().int().min(0)).optional(),
|
|
7607
|
+
standingEpoch: number().int().min(0).optional(),
|
|
7570
7608
|
legacyDrain: looseObject({
|
|
7571
7609
|
since: absoluteTimeSchema,
|
|
7572
7610
|
tracked: record(string().min(1), _enum(["open", ...[
|
|
@@ -7850,7 +7888,7 @@ var ConfigStore = class {
|
|
|
7850
7888
|
});
|
|
7851
7889
|
}
|
|
7852
7890
|
}
|
|
7853
|
-
const written = withSendEpochs(current, parsed.data);
|
|
7891
|
+
const written = withStandingEpoch(current, withSendEpochs(current, parsed.data));
|
|
7854
7892
|
const serialized = `${JSON.stringify(written, null, 2)}\n`;
|
|
7855
7893
|
await writeFileAtomic(this.path, serialized, 384, options.signal);
|
|
7856
7894
|
committed = written;
|
|
@@ -8033,6 +8071,46 @@ function withSendEpochs(before, after) {
|
|
|
8033
8071
|
sendEpochs: epochs
|
|
8034
8072
|
};
|
|
8035
8073
|
}
|
|
8074
|
+
/**
|
|
8075
|
+
* The standing epoch in `config` (design 2026-10-19-standing-approvals, S8): its stored value, or 0 when it has none —
|
|
8076
|
+
* or when it is not a whole number of 0 or more, which no write of this release leaves.
|
|
8077
|
+
*/
|
|
8078
|
+
function standingEpochOf(config) {
|
|
8079
|
+
const epoch = config.standingEpoch;
|
|
8080
|
+
return typeof epoch === "number" && Number.isInteger(epoch) && epoch >= 0 ? epoch : 0;
|
|
8081
|
+
}
|
|
8082
|
+
/** The change policy of every inbox and account by id, each its own or the default. */
|
|
8083
|
+
function changePoliciesById(config) {
|
|
8084
|
+
const policies = /* @__PURE__ */ new Map();
|
|
8085
|
+
for (const inbox of Object.values(config.inboxes)) policies.set(inbox.id, inbox.changePolicy ?? defaultChangePolicy(config));
|
|
8086
|
+
for (const account of Object.values(config.accounts)) policies.set(account.id, account.changePolicy ?? defaultChangePolicy(config));
|
|
8087
|
+
return policies;
|
|
8088
|
+
}
|
|
8089
|
+
/**
|
|
8090
|
+
* Whether this write turns a change policy from `chat` to `confirm`: the default's, or the effective one of an inbox or
|
|
8091
|
+
* account there before and after, by id.
|
|
8092
|
+
*/
|
|
8093
|
+
function tightensAChangePolicy(before, after) {
|
|
8094
|
+
if (defaultChangePolicy(before) === "chat" && defaultChangePolicy(after) === "confirm") return true;
|
|
8095
|
+
const was = changePoliciesById(before);
|
|
8096
|
+
for (const [id, policy] of changePoliciesById(after)) if (policy === "confirm" && was.get(id) === "chat") return true;
|
|
8097
|
+
return false;
|
|
8098
|
+
}
|
|
8099
|
+
/**
|
|
8100
|
+
* `after`, with the standing epoch this write owes (design 2026-10-19-standing-approvals, S8): on version 3, the higher
|
|
8101
|
+
* of the two it holds — never lowered, whatever the write's own copy says, nor dropped — and one more when it tightens a
|
|
8102
|
+
* change policy (`tightensAChangePolicy`). Before version 3 there is no epoch, and no standing approval: every one is
|
|
8103
|
+
* made on version 3.
|
|
8104
|
+
*/
|
|
8105
|
+
function withStandingEpoch(before, after) {
|
|
8106
|
+
if (before.version !== 3 || after.version !== 3) return after;
|
|
8107
|
+
const held = Math.max(standingEpochOf(before), standingEpochOf(after));
|
|
8108
|
+
const kept = tightensAChangePolicy(before, after) ? held + 1 : held;
|
|
8109
|
+
return kept === 0 && after.standingEpoch === void 0 ? after : {
|
|
8110
|
+
...after,
|
|
8111
|
+
standingEpoch: kept
|
|
8112
|
+
};
|
|
8113
|
+
}
|
|
8036
8114
|
async function readFileIfExists(path) {
|
|
8037
8115
|
try {
|
|
8038
8116
|
return await readFile(path, "utf8");
|
|
@@ -8717,23 +8795,53 @@ function ownerEntry(config, ownerId) {
|
|
|
8717
8795
|
*/
|
|
8718
8796
|
function liveGateOf(config, record) {
|
|
8719
8797
|
const governing = (fallback) => record.kind === "change" && record.change !== void 0 ? governingChangePolicy(config, record.change) : fallback;
|
|
8798
|
+
const standingEpoch = standingEpochOf(config);
|
|
8720
8799
|
if (record.ownerScope !== "owner") return {
|
|
8721
8800
|
owner: "none",
|
|
8722
|
-
changePolicy: governing(defaultChangePolicy(config))
|
|
8801
|
+
changePolicy: governing(defaultChangePolicy(config)),
|
|
8802
|
+
standingEpoch
|
|
8723
8803
|
};
|
|
8724
8804
|
const entry = ownerEntry(config, record.inboxId);
|
|
8725
|
-
if (entry === void 0) return {
|
|
8805
|
+
if (entry === void 0) return {
|
|
8806
|
+
owner: "removed",
|
|
8807
|
+
standingEpoch
|
|
8808
|
+
};
|
|
8726
8809
|
return {
|
|
8727
8810
|
owner: "present",
|
|
8728
8811
|
sendPolicy: entry.sendPolicy ?? config.defaults.sendPolicy,
|
|
8729
8812
|
changePolicy: governing(entry.changePolicy ?? defaultChangePolicy(config)),
|
|
8730
|
-
sendEpoch: sendEpochOf(config, record.inboxId)
|
|
8813
|
+
sendEpoch: sendEpochOf(config, record.inboxId),
|
|
8814
|
+
standingEpoch
|
|
8731
8815
|
};
|
|
8732
8816
|
}
|
|
8733
8817
|
/** The reason a record whose owner was removed is revoked with. */
|
|
8734
8818
|
const OWNER_REMOVED_REASON = "its mailbox or account was removed";
|
|
8735
8819
|
/** The reason a send prepared before a `never` is revoked with: its stored epoch is behind the live one. */
|
|
8736
8820
|
const SEND_EPOCH_REASON = "sending was turned off since this was prepared (policy: never)";
|
|
8821
|
+
/**
|
|
8822
|
+
* The reason a standing approval, or one of its unused children, is revoked with once standing approvals were ended
|
|
8823
|
+
* since it was made: its standing epoch is behind the live one (design 2026-10-19-standing-approvals, S8).
|
|
8824
|
+
*/
|
|
8825
|
+
const STANDING_EPOCH_REASON = "standing approvals were ended since this was approved";
|
|
8826
|
+
/**
|
|
8827
|
+
* The reason a standing approval is revoked with once its account's change policy is stricter than the one it was
|
|
8828
|
+
* approved under (`approvedChangePolicy`) — `changePolicy-tightened`, read from the configuration itself, whatever the
|
|
8829
|
+
* standing epoch says, so a release that knows no epoch tightening the policy still ends it (design
|
|
8830
|
+
* 2026-10-19-standing-approvals, S8, the lead's call after review).
|
|
8831
|
+
*/
|
|
8832
|
+
const CHANGE_POLICY_TIGHTENED_REASON = "the change policy was tightened since this was approved";
|
|
8833
|
+
/** Whether `live` is stricter than `approved`: `confirm` over `chat`. */
|
|
8834
|
+
function changePolicyTightened(approved, live) {
|
|
8835
|
+
return live === "confirm" && approved !== "confirm";
|
|
8836
|
+
}
|
|
8837
|
+
/**
|
|
8838
|
+
* Whether even a look writes this outcome's revocation down: a standing approval ended by a tightened change policy.
|
|
8839
|
+
* Every other revocation a look derives is from something that never goes back — an epoch, an owner's id — so the next
|
|
8840
|
+
* action can write it; a policy can be loosened again, and what it ended must stay ended (S8).
|
|
8841
|
+
*/
|
|
8842
|
+
function writtenAtALook(outcome) {
|
|
8843
|
+
return outcome.revokes && outcome.record?.kind === "standing" && outcome.reason === "the change policy was tightened since this was approved";
|
|
8844
|
+
}
|
|
8737
8845
|
/** What an outcome says of a pending or approved send under a live `never`, before anything revokes it. */
|
|
8738
8846
|
const NEVER_NOTICE = "sending is turned off (policy: never); any use revokes it";
|
|
8739
8847
|
/** The refusal of a claim, an approval or an answer by a store opened without a configuration. */
|
|
@@ -8742,7 +8850,11 @@ const NO_CONFIGURATION = "this approval store was opened without a configuration
|
|
|
8742
8850
|
const sentAtWords = (usedAt) => `sent at ${usedAt}`;
|
|
8743
8851
|
/** The refusal prefix and detail key of each kind: a send sends nothing, a change changes nothing, a question saves nothing. */
|
|
8744
8852
|
function refusalOf(kind) {
|
|
8745
|
-
const [prefix, idKey, hint] = kind === "
|
|
8853
|
+
const [prefix, idKey, hint] = kind === "standing" ? [
|
|
8854
|
+
"nothing was sent",
|
|
8855
|
+
"approvalId",
|
|
8856
|
+
"This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal."
|
|
8857
|
+
] : kind === "disclosure" ? [
|
|
8746
8858
|
"nothing was disclosed",
|
|
8747
8859
|
"approvalId",
|
|
8748
8860
|
"Prepare a new standing disclosure authorisation and show its exact activation preview to the person."
|
|
@@ -8773,7 +8885,7 @@ function refusalOf(kind) {
|
|
|
8773
8885
|
* or one a pinned surface may not see — byte for byte the same, with `approval: null`, and nothing of any record in it.
|
|
8774
8886
|
*/
|
|
8775
8887
|
function approvalNotFound(approvalId, kind) {
|
|
8776
|
-
return new CommsError("NOT_FOUND", `${kind === "disclosure" ? "nothing was disclosed" : kind === "change" ? "nothing was changed" : kind === "download" ? "nothing was saved" : "nothing was sent"}: no approval ${approvalId}`, {
|
|
8888
|
+
return new CommsError("NOT_FOUND", `${kind === "disclosure" ? "nothing was disclosed" : kind === "standing" ? "nothing was sent" : kind === "change" ? "nothing was changed" : kind === "download" ? "nothing was saved" : "nothing was sent"}: no approval ${approvalId}`, {
|
|
8777
8889
|
hint: "Check the id: an approval is only found by the surface, the kind and the owner it was prepared for.",
|
|
8778
8890
|
details: { approval: null }
|
|
8779
8891
|
});
|
|
@@ -9030,12 +9142,20 @@ function classifyV2(read, context) {
|
|
|
9030
9142
|
const ownerRemoved = read.ownerScope === "owner" && live?.owner === "removed";
|
|
9031
9143
|
const active = read.state === "pending" || read.state === "approved";
|
|
9032
9144
|
const acting = action === "approve" || action === "claim";
|
|
9033
|
-
const
|
|
9145
|
+
const sends = read.kind === "send" || read.kind === "standing";
|
|
9146
|
+
const standingBound = read.kind === "standing" || read.kind === "send" && read.route === "standing";
|
|
9147
|
+
const never = sends && live?.owner === "present" && stricterPolicy(live.sendPolicy ?? "never", read.requiredPolicy) === "never";
|
|
9034
9148
|
let derived = null;
|
|
9149
|
+
let revokedByNever = false;
|
|
9035
9150
|
if (active && ownerRemoved) derived = OWNER_REMOVED_REASON;
|
|
9036
9151
|
else if (active && never) {
|
|
9037
|
-
if (acting)
|
|
9038
|
-
|
|
9152
|
+
if (acting) {
|
|
9153
|
+
derived = live?.sendPolicy === "never" ? `sending is turned off for this ${accountNoun(read.channel)} (policy: never)` : "sending is turned off for this approval (policy: never)";
|
|
9154
|
+
revokedByNever = true;
|
|
9155
|
+
}
|
|
9156
|
+
} else if (active && sends && live?.owner === "present" && (read.sendEpoch ?? 0) !== live.sendEpoch) derived = SEND_EPOCH_REASON;
|
|
9157
|
+
else if (active && standingBound && live !== null && live.standingEpoch !== void 0 && read.standingEpoch !== live.standingEpoch) derived = STANDING_EPOCH_REASON;
|
|
9158
|
+
else if (read.kind === "standing" && read.state === "approved" && live?.owner === "present" && changePolicyTightened(read.approvedChangePolicy, live.changePolicy)) derived = CHANGE_POLICY_TIGHTENED_REASON;
|
|
9039
9159
|
const record = derived === null ? read : {
|
|
9040
9160
|
...read,
|
|
9041
9161
|
state: "revoked",
|
|
@@ -9043,7 +9163,6 @@ function classifyV2(read, context) {
|
|
|
9043
9163
|
revokedAt: at,
|
|
9044
9164
|
challengeHash: void 0
|
|
9045
9165
|
};
|
|
9046
|
-
const revokedByNever = derived !== null && derived !== "its mailbox or account was removed" && derived !== "sending was turned off since this was prepared (policy: never)";
|
|
9047
9166
|
let claimable = false;
|
|
9048
9167
|
let reason = record.reason;
|
|
9049
9168
|
if (record.state === "pending" || record.state === "approved") {
|
|
@@ -9109,6 +9228,7 @@ function isClaimable(record, live) {
|
|
|
9109
9228
|
case "change": return record.state === "approved" || record.route === "chat" && live.changePolicy === "chat";
|
|
9110
9229
|
case "download": return downloadClaimable(record, live.changePolicy);
|
|
9111
9230
|
case "disclosure": return record.state === "approved";
|
|
9231
|
+
case "standing": return false;
|
|
9112
9232
|
}
|
|
9113
9233
|
}
|
|
9114
9234
|
/**
|
|
@@ -9157,7 +9277,7 @@ function errorOf(record, approval, context) {
|
|
|
9157
9277
|
if (download) return refuse("APPROVAL_EXPIRED", downloadExpiredWords(record.approvedVia !== void 0), record, approval);
|
|
9158
9278
|
const nothing = `nothing was ${record.kind === "change" ? "changed" : "sent"} with it`;
|
|
9159
9279
|
return new CommsError("APPROVAL_EXPIRED", record.reason === "clock-anomaly" ? `the clock moved backwards; this approval was expired safely at ${record.expiredAt}; ${nothing}` : record.approvedAt !== void 0 ? `this approval expired; ${nothing}: approved at ${record.approvedAt}, expired unused at ${record.expiredAt}` : `this approval expired; ${nothing}: prepared at ${record.createdAt}, expired at ${record.expiredAt}`, {
|
|
9160
|
-
hint: record.kind === "change" ? "Prepare the change again and show the new preview to the user." : "Prepare the send again and show the new preview to the user.",
|
|
9280
|
+
hint: record.kind === "change" ? "Prepare the change again and show the new preview to the user." : record.kind === "standing" ? "This standing approval has ended. Ask the person instead; only they can set up another, at their own terminal." : "Prepare the send again and show the new preview to the user.",
|
|
9161
9281
|
details: {
|
|
9162
9282
|
approvalId: record.approvalId,
|
|
9163
9283
|
state: record.state,
|
|
@@ -9241,6 +9361,578 @@ var AuditLog = class {
|
|
|
9241
9361
|
}
|
|
9242
9362
|
};
|
|
9243
9363
|
/**
|
|
9364
|
+
* The emoji a standing approval may react with (design 2026-10-19-standing-approvals, S3 and §8.4): a curated list of
|
|
9365
|
+
* standard Unicode names, each with its code point, shipped in the code.
|
|
9366
|
+
*
|
|
9367
|
+
* Only standard names, because a custom workspace emoji's image can be replaced under the same name: the person would
|
|
9368
|
+
* have approved a picture that is no longer the one sent. And a short list rather than every Unicode name, because each
|
|
9369
|
+
* one here is a word the agent may say on the person's behalf without asking.
|
|
9370
|
+
*/
|
|
9371
|
+
const STANDARD_EMOJI = Object.freeze({
|
|
9372
|
+
eyes: "U+1F440",
|
|
9373
|
+
white_check_mark: "U+2705",
|
|
9374
|
+
heavy_check_mark: "U+2714",
|
|
9375
|
+
ballot_box_with_check: "U+2611",
|
|
9376
|
+
"+1": "U+1F44D",
|
|
9377
|
+
thumbsup: "U+1F44D",
|
|
9378
|
+
ok: "U+1F197",
|
|
9379
|
+
ok_hand: "U+1F44C",
|
|
9380
|
+
"100": "U+1F4AF",
|
|
9381
|
+
heavy_plus_sign: "U+2795",
|
|
9382
|
+
handshake: "U+1F91D",
|
|
9383
|
+
raised_hands: "U+1F64C",
|
|
9384
|
+
pray: "U+1F64F",
|
|
9385
|
+
clap: "U+1F44F",
|
|
9386
|
+
wave: "U+1F44B",
|
|
9387
|
+
memo: "U+1F4DD",
|
|
9388
|
+
pencil2: "U+270F",
|
|
9389
|
+
hourglass_flowing_sand: "U+23F3",
|
|
9390
|
+
stopwatch: "U+23F1",
|
|
9391
|
+
inbox_tray: "U+1F4E5",
|
|
9392
|
+
pushpin: "U+1F4CC",
|
|
9393
|
+
mag: "U+1F50D",
|
|
9394
|
+
bulb: "U+1F4A1",
|
|
9395
|
+
speech_balloon: "U+1F4AC",
|
|
9396
|
+
thinking_face: "U+1F914",
|
|
9397
|
+
slightly_smiling_face: "U+1F642",
|
|
9398
|
+
heart: "U+2764",
|
|
9399
|
+
tada: "U+1F389",
|
|
9400
|
+
rocket: "U+1F680",
|
|
9401
|
+
sparkles: "U+2728"
|
|
9402
|
+
});
|
|
9403
|
+
/**
|
|
9404
|
+
* The emoji among them that people read as a yes: ✅ on "can I deploy?" is taken for approval, and a Slack workflow
|
|
9405
|
+
* can start on a reaction (S3). Allowed, with a warning in the preview.
|
|
9406
|
+
*/
|
|
9407
|
+
const READS_AS_APPROVAL = Object.freeze([
|
|
9408
|
+
"white_check_mark",
|
|
9409
|
+
"heavy_check_mark",
|
|
9410
|
+
"ballot_box_with_check",
|
|
9411
|
+
"+1",
|
|
9412
|
+
"thumbsup",
|
|
9413
|
+
"ok",
|
|
9414
|
+
"ok_hand",
|
|
9415
|
+
"100",
|
|
9416
|
+
"heavy_plus_sign",
|
|
9417
|
+
"handshake"
|
|
9418
|
+
]);
|
|
9419
|
+
/** Whether `name` is one of the standard emoji a standing approval may hold, exactly as listed. */
|
|
9420
|
+
function isStandardEmoji(name) {
|
|
9421
|
+
return Object.hasOwn(STANDARD_EMOJI, name);
|
|
9422
|
+
}
|
|
9423
|
+
Object.freeze(["reaction", "reply"]);
|
|
9424
|
+
/** S3: what a standing approval holds when no content is given. */
|
|
9425
|
+
const STANDING_DEFAULT_REACTION = "eyes";
|
|
9426
|
+
/** One day, the unit a standing approval's expiry is counted in. */
|
|
9427
|
+
const STANDING_DAY_MS = 864e5;
|
|
9428
|
+
/** S6's table: each limit's default and the range a person may set it to. */
|
|
9429
|
+
const STANDING_LIMITS = Object.freeze({
|
|
9430
|
+
perThread: Object.freeze({
|
|
9431
|
+
default: 1,
|
|
9432
|
+
min: 1,
|
|
9433
|
+
max: 3
|
|
9434
|
+
}),
|
|
9435
|
+
perHour: Object.freeze({
|
|
9436
|
+
default: 10,
|
|
9437
|
+
min: 1,
|
|
9438
|
+
max: 30
|
|
9439
|
+
}),
|
|
9440
|
+
perDay: Object.freeze({
|
|
9441
|
+
default: 30,
|
|
9442
|
+
min: 1,
|
|
9443
|
+
max: 100
|
|
9444
|
+
}),
|
|
9445
|
+
perSender: Object.freeze({
|
|
9446
|
+
default: 3,
|
|
9447
|
+
min: 1,
|
|
9448
|
+
max: 10
|
|
9449
|
+
}),
|
|
9450
|
+
expiresDays: Object.freeze({
|
|
9451
|
+
default: 7,
|
|
9452
|
+
min: 1,
|
|
9453
|
+
max: 30
|
|
9454
|
+
})
|
|
9455
|
+
});
|
|
9456
|
+
const LIMIT_NAMES$1 = [
|
|
9457
|
+
"perThread",
|
|
9458
|
+
"perHour",
|
|
9459
|
+
"perDay",
|
|
9460
|
+
"perSender",
|
|
9461
|
+
"expiresDays"
|
|
9462
|
+
];
|
|
9463
|
+
const CONTENT_WORDS = Object.freeze({
|
|
9464
|
+
"too-many-reactions": `a standing approval holds at most 5 reactions`,
|
|
9465
|
+
"too-many-replies": `a standing approval holds at most 5 replies`,
|
|
9466
|
+
"custom-emoji": "only a standard emoji from the list this release ships may be a standing reaction",
|
|
9467
|
+
empty: "a standing reply has no words",
|
|
9468
|
+
"too-long": `a standing reply is at most 200 characters`,
|
|
9469
|
+
"multi-line": "a standing reply is one line",
|
|
9470
|
+
"control-character": "a standing reply holds a control or invisible character",
|
|
9471
|
+
mention: "a standing reply mentions nobody: no @, and no <@…> or <!…>",
|
|
9472
|
+
link: "a standing reply holds no link, address or domain",
|
|
9473
|
+
placeholder: "a standing reply is sent exactly as written: it holds no placeholder",
|
|
9474
|
+
markup: "a standing reply is plain text: no formatting characters",
|
|
9475
|
+
"emoji-code": "a standing reply holds no :emoji: code; a reaction is its own content",
|
|
9476
|
+
pictograph: "a standing reply holds no emoji or pictograph, such as ✅, ✔ or 👍; a reaction is its own content",
|
|
9477
|
+
duplicate: "a standing approval holds each content once"
|
|
9478
|
+
});
|
|
9479
|
+
/** The refusal of a content, as BAD_DATA, with its reason and which content it was. */
|
|
9480
|
+
function contentRefused(reason, where) {
|
|
9481
|
+
return new CommsError("BAD_DATA", `nothing was prepared: ${CONTENT_WORDS[reason]}`, {
|
|
9482
|
+
hint: reason === "custom-emoji" ? `Choose from the standard emoji: ${Object.keys(STANDARD_EMOJI).join(", ")}.` : "Write each reply as plain words on one line, at most 200 characters: no links, mentions, markup, placeholders, emoji or emoji codes. A reply is sent exactly as written.",
|
|
9483
|
+
details: {
|
|
9484
|
+
reason,
|
|
9485
|
+
...where === void 0 ? {} : {
|
|
9486
|
+
kind: where.kind,
|
|
9487
|
+
position: where.position
|
|
9488
|
+
}
|
|
9489
|
+
}
|
|
9490
|
+
});
|
|
9491
|
+
}
|
|
9492
|
+
/** The digest of one content: its kind and exact text. */
|
|
9493
|
+
function standingContentDigest(kind, text) {
|
|
9494
|
+
return sha256Hex(canonicalJson$1({
|
|
9495
|
+
kind,
|
|
9496
|
+
text
|
|
9497
|
+
}));
|
|
9498
|
+
}
|
|
9499
|
+
/** A reaction's name, exactly as listed — `:name:` read as `name` — or the refusal of a custom one. */
|
|
9500
|
+
function reactionName(written, position) {
|
|
9501
|
+
const trimmed = written.trim();
|
|
9502
|
+
const name = /^:[^:]+:$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed;
|
|
9503
|
+
if (!isStandardEmoji(name)) throw contentRefused("custom-emoji", {
|
|
9504
|
+
kind: "reaction",
|
|
9505
|
+
position
|
|
9506
|
+
});
|
|
9507
|
+
return name;
|
|
9508
|
+
}
|
|
9509
|
+
/** Each reason a reply's text is refused for, in the order checked: the first that applies is the one given. */
|
|
9510
|
+
const REPLY_RULES = Object.freeze([
|
|
9511
|
+
["multi-line", (text) => /[\r\n\u2028\u2029]/.test(text)],
|
|
9512
|
+
["pictograph", (text) => /[\p{Extended_Pictographic}\u2713]/u.test(text)],
|
|
9513
|
+
["control-character", (text) => [...text].some((char) => char === " " || isDangerous(char.codePointAt(0) ?? 0))],
|
|
9514
|
+
["mention", (text) => /@|<[@!#]/.test(text)],
|
|
9515
|
+
["link", (text) => /:\/\/|www\.|mailto:|[A-Za-z0-9-]+\.[A-Za-z]{2,}/i.test(text)],
|
|
9516
|
+
["placeholder", (text) => /[{}[\]]/.test(text)],
|
|
9517
|
+
["markup", (text) => /[<>*_~`|]/.test(text)],
|
|
9518
|
+
["emoji-code", (text) => /:[A-Za-z0-9_+-]+:/.test(text)]
|
|
9519
|
+
]);
|
|
9520
|
+
/**
|
|
9521
|
+
* Words that read as a yes in a reply (the lead's call, after review): "OK" or "Done" on "can I deploy?" is taken for
|
|
9522
|
+
* approval as surely as ✅ is. Matched whole, in any case. Allowed, with a warning in the preview.
|
|
9523
|
+
*/
|
|
9524
|
+
const APPROVAL_WORDS = Object.freeze([
|
|
9525
|
+
["yes", /\byes\b/i],
|
|
9526
|
+
["approved", /\bapproved\b/i],
|
|
9527
|
+
["approve", /\bapprove\b/i],
|
|
9528
|
+
["go ahead", /\bgo\s+ahead\b/i],
|
|
9529
|
+
["lgtm", /\blgtm\b/i],
|
|
9530
|
+
["ok", /\bok\b/i],
|
|
9531
|
+
["done", /\bdone\b/i],
|
|
9532
|
+
["confirmed", /\bconfirmed\b/i]
|
|
9533
|
+
]);
|
|
9534
|
+
/** What a reply's reads-as-approval warning begins with: the preview shows it under that reply. */
|
|
9535
|
+
function replyWarningPrefix(text) {
|
|
9536
|
+
return `The reply "${text}" can read as approval:`;
|
|
9537
|
+
}
|
|
9538
|
+
/** S3's warning for a reply holding words that read as a yes, or null. */
|
|
9539
|
+
function replyReadsAsApproval(text) {
|
|
9540
|
+
const said = APPROVAL_WORDS.filter(([, pattern]) => pattern.test(text)).map(([word]) => `"${word}"`);
|
|
9541
|
+
if (said.length === 0) return null;
|
|
9542
|
+
return `${replyWarningPrefix(text)} it says ${said.join(", ")}. People read "OK" or "Done" on "can I deploy?" as a yes.`;
|
|
9543
|
+
}
|
|
9544
|
+
/** A reply's text, trimmed and checked (S3), or the refusal of the first rule it breaks. */
|
|
9545
|
+
function replyText(written, position) {
|
|
9546
|
+
const where = {
|
|
9547
|
+
kind: "reply",
|
|
9548
|
+
position
|
|
9549
|
+
};
|
|
9550
|
+
const text = written.trim();
|
|
9551
|
+
if (text === "") throw contentRefused("empty", where);
|
|
9552
|
+
for (const [reason, breaks] of REPLY_RULES) if (breaks(text)) throw contentRefused(reason, where);
|
|
9553
|
+
if ([...text].length > 200) throw contentRefused("too-long", where);
|
|
9554
|
+
return text;
|
|
9555
|
+
}
|
|
9556
|
+
/** S3's warning for an emoji that reads as approval, in the spec's words. */
|
|
9557
|
+
function readsAsApprovalWarning(name) {
|
|
9558
|
+
return `:${name}: can read as approval: people read ✅ on "can I deploy?" as a yes, and a Slack workflow can start on a reaction.`;
|
|
9559
|
+
}
|
|
9560
|
+
/**
|
|
9561
|
+
* The contents a person wrote, checked (S3): reactions first, then replies, each as written, each with its digest; and
|
|
9562
|
+
* the warnings the preview shows. With no content given at all, `eyes` alone. Refuses BAD_DATA, with `details.reason`,
|
|
9563
|
+
* for anything S3 does not allow.
|
|
9564
|
+
*/
|
|
9565
|
+
function checkedContents(input) {
|
|
9566
|
+
const reactions = input.reactions ?? [];
|
|
9567
|
+
const replies = input.replies ?? [];
|
|
9568
|
+
if (reactions.length > 5) throw contentRefused("too-many-reactions");
|
|
9569
|
+
if (replies.length > 5) throw contentRefused("too-many-replies");
|
|
9570
|
+
const names = reactions.length === 0 && replies.length === 0 ? [STANDING_DEFAULT_REACTION] : reactions.map((written, index) => reactionName(written, index + 1));
|
|
9571
|
+
const texts = replies.map((written, index) => replyText(written, index + 1));
|
|
9572
|
+
const contents = [...names.map((text) => ({
|
|
9573
|
+
kind: "reaction",
|
|
9574
|
+
text,
|
|
9575
|
+
digest: standingContentDigest("reaction", text)
|
|
9576
|
+
})), ...texts.map((text) => ({
|
|
9577
|
+
kind: "reply",
|
|
9578
|
+
text,
|
|
9579
|
+
digest: standingContentDigest("reply", text)
|
|
9580
|
+
}))];
|
|
9581
|
+
if (new Set(contents.map((content) => content.digest)).size !== contents.length) throw contentRefused("duplicate");
|
|
9582
|
+
return {
|
|
9583
|
+
contents,
|
|
9584
|
+
warnings: [...names.filter((name) => READS_AS_APPROVAL.includes(name)).map(readsAsApprovalWarning), ...texts.map(replyReadsAsApproval).filter((warning) => warning !== null)]
|
|
9585
|
+
};
|
|
9586
|
+
}
|
|
9587
|
+
/** S6's limits, each from the table: its default when left out, and BAD_DATA outside its range. */
|
|
9588
|
+
function checkedLimits(input) {
|
|
9589
|
+
const limits = {};
|
|
9590
|
+
for (const name of LIMIT_NAMES$1) {
|
|
9591
|
+
const { default: fallback, min, max } = STANDING_LIMITS[name];
|
|
9592
|
+
const given = input[name];
|
|
9593
|
+
const value = given ?? fallback;
|
|
9594
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new CommsError("BAD_DATA", `nothing was prepared: ${name} is a whole number from ${min} to ${max}`, {
|
|
9595
|
+
hint: `Leave it out for ${fallback}, or give a whole number from ${min} to ${max}.`,
|
|
9596
|
+
details: {
|
|
9597
|
+
reason: "limit-out-of-range",
|
|
9598
|
+
limit: name,
|
|
9599
|
+
min,
|
|
9600
|
+
max,
|
|
9601
|
+
given: given ?? null
|
|
9602
|
+
}
|
|
9603
|
+
});
|
|
9604
|
+
limits[name] = value;
|
|
9605
|
+
}
|
|
9606
|
+
return limits;
|
|
9607
|
+
}
|
|
9608
|
+
/** The canonical form of a place, as the digest binds it. */
|
|
9609
|
+
function canonicalPlaces(places) {
|
|
9610
|
+
if (places.kind === "threads-i-am-in") return { kind: "threads-i-am-in" };
|
|
9611
|
+
return {
|
|
9612
|
+
kind: "exact",
|
|
9613
|
+
places: places.places.map((place) => ({
|
|
9614
|
+
id: place.id,
|
|
9615
|
+
name: place.name,
|
|
9616
|
+
members: place.members,
|
|
9617
|
+
shared: {
|
|
9618
|
+
extShared: place.shared.extShared,
|
|
9619
|
+
pendingExtShared: place.shared.pendingExtShared,
|
|
9620
|
+
externalMembers: place.shared.externalMembers
|
|
9621
|
+
}
|
|
9622
|
+
}))
|
|
9623
|
+
};
|
|
9624
|
+
}
|
|
9625
|
+
/**
|
|
9626
|
+
* The digest a standing record is bound to: everything its binding holds but the summary — the account, every content
|
|
9627
|
+
* with its digest in order, every place as recorded, the limits, who proposed it, what it replaces, and the warnings
|
|
9628
|
+
* the person was shown.
|
|
9629
|
+
*/
|
|
9630
|
+
function standingDigest(binding) {
|
|
9631
|
+
return sha256Hex(canonicalJson$1({
|
|
9632
|
+
kind: "standing",
|
|
9633
|
+
target: {
|
|
9634
|
+
kind: binding.target.kind,
|
|
9635
|
+
name: binding.target.name,
|
|
9636
|
+
id: binding.target.id
|
|
9637
|
+
},
|
|
9638
|
+
contents: binding.contents.map((content) => ({
|
|
9639
|
+
kind: content.kind,
|
|
9640
|
+
text: content.text,
|
|
9641
|
+
digest: content.digest
|
|
9642
|
+
})),
|
|
9643
|
+
places: canonicalPlaces(binding.places),
|
|
9644
|
+
limits: {
|
|
9645
|
+
perThread: binding.limits.perThread,
|
|
9646
|
+
perHour: binding.limits.perHour,
|
|
9647
|
+
perDay: binding.limits.perDay,
|
|
9648
|
+
perSender: binding.limits.perSender,
|
|
9649
|
+
expiresDays: binding.limits.expiresDays
|
|
9650
|
+
},
|
|
9651
|
+
proposedBy: binding.proposedBy,
|
|
9652
|
+
replaces: binding.replaces ?? null,
|
|
9653
|
+
warnings: [...binding.warnings]
|
|
9654
|
+
}));
|
|
9655
|
+
}
|
|
9656
|
+
/** A narrowing that is not one: USAGE, saying what to do instead. */
|
|
9657
|
+
function notANarrowing(message) {
|
|
9658
|
+
return new CommsError("USAGE", `nothing was narrowed: ${message}`, { hint: "A narrowing keeps some of what the standing approval holds. To end it altogether, revoke it." });
|
|
9659
|
+
}
|
|
9660
|
+
/**
|
|
9661
|
+
* The standing approval as it applies after `narrowed`: only the contents whose digests it keeps, only the exact
|
|
9662
|
+
* places it keeps, and the lower of each limit — so a narrowing never raises one, whatever it says. USAGE for a digest
|
|
9663
|
+
* or a place the approval does not hold, for keeping none, and for places kept from `threads-i-am-in`, which names none.
|
|
9664
|
+
*/
|
|
9665
|
+
function effectiveStanding(binding, narrowed) {
|
|
9666
|
+
if (narrowed === void 0) return binding;
|
|
9667
|
+
let contents = binding.contents;
|
|
9668
|
+
if (narrowed.keep !== void 0) {
|
|
9669
|
+
const keep = new Set(narrowed.keep);
|
|
9670
|
+
for (const digest of keep) if (!binding.contents.some((content) => content.digest === digest)) throw notANarrowing("it keeps a content this standing approval does not hold");
|
|
9671
|
+
contents = binding.contents.filter((content) => keep.has(content.digest));
|
|
9672
|
+
if (contents.length === 0) throw notANarrowing("it would keep no content at all");
|
|
9673
|
+
}
|
|
9674
|
+
let places = binding.places;
|
|
9675
|
+
if (narrowed.places !== void 0) {
|
|
9676
|
+
if (binding.places.kind !== "exact") throw notANarrowing("threads-i-am-in names no places to keep; narrow its contents or limits, or revoke it");
|
|
9677
|
+
const keep = new Set(narrowed.places);
|
|
9678
|
+
const held = binding.places.places;
|
|
9679
|
+
for (const id of keep) if (!held.some((place) => place.id === id)) throw notANarrowing("it keeps a place this standing approval does not cover");
|
|
9680
|
+
const kept = held.filter((place) => keep.has(place.id));
|
|
9681
|
+
if (kept.length === 0) throw notANarrowing("it would keep no place at all");
|
|
9682
|
+
places = {
|
|
9683
|
+
kind: "exact",
|
|
9684
|
+
places: kept
|
|
9685
|
+
};
|
|
9686
|
+
}
|
|
9687
|
+
const limits = { ...binding.limits };
|
|
9688
|
+
for (const name of LIMIT_NAMES$1) {
|
|
9689
|
+
const lower = narrowed.limits?.[name];
|
|
9690
|
+
if (lower !== void 0 && Number.isFinite(lower)) limits[name] = Math.min(limits[name], lower);
|
|
9691
|
+
}
|
|
9692
|
+
return {
|
|
9693
|
+
...binding,
|
|
9694
|
+
contents,
|
|
9695
|
+
places,
|
|
9696
|
+
limits
|
|
9697
|
+
};
|
|
9698
|
+
}
|
|
9699
|
+
/**
|
|
9700
|
+
* The narrowing `narrowing` makes of what is in force — `binding` as `narrowed` already leaves it — as the `narrowed` to
|
|
9701
|
+
* store beside `binding`: the contents and exact places kept, and every limit, as asked. Applies at once, so it may
|
|
9702
|
+
* only narrow (S8): USAGE for anything that would widen what is in force — a higher limit, a later expiry, a content
|
|
9703
|
+
* or a place not held — and BAD_DATA for a limit outside the table.
|
|
9704
|
+
*/
|
|
9705
|
+
function narrowedFurther(binding, narrowed, narrowing) {
|
|
9706
|
+
const before = effectiveStanding(binding, narrowed);
|
|
9707
|
+
const kept = effectiveStanding(before, {
|
|
9708
|
+
...narrowing.keep === void 0 ? {} : { keep: narrowing.keep },
|
|
9709
|
+
...narrowing.places === void 0 ? {} : { places: narrowing.places }
|
|
9710
|
+
});
|
|
9711
|
+
const asked = { ...kept.limits };
|
|
9712
|
+
for (const name of LIMIT_NAMES$1) {
|
|
9713
|
+
const value = narrowing.limits?.[name];
|
|
9714
|
+
if (value !== void 0) asked[name] = value;
|
|
9715
|
+
}
|
|
9716
|
+
const after = {
|
|
9717
|
+
...kept,
|
|
9718
|
+
limits: checkedLimits(asked)
|
|
9719
|
+
};
|
|
9720
|
+
if (classifyStandingChange(before, after) === "widens") throw new CommsError("USAGE", "nothing was narrowed: this would widen the standing approval, not narrow it", {
|
|
9721
|
+
hint: "A narrowing only lowers a limit, ends it sooner, or keeps fewer contents or places. Anything more is a new standing approval, which the person approves at their terminal with its code.",
|
|
9722
|
+
details: { reason: "widens" }
|
|
9723
|
+
});
|
|
9724
|
+
return {
|
|
9725
|
+
keep: after.contents.map((content) => content.digest),
|
|
9726
|
+
...after.places.kind === "exact" ? { places: after.places.places.map((place) => place.id) } : {},
|
|
9727
|
+
limits: { ...after.limits }
|
|
9728
|
+
};
|
|
9729
|
+
}
|
|
9730
|
+
/** How many days a standing approval lasts once approved, as narrowed: the lower of its own and the narrowing's. */
|
|
9731
|
+
function standingDays(binding, narrowed) {
|
|
9732
|
+
const lower = narrowed?.limits?.expiresDays;
|
|
9733
|
+
return typeof lower === "number" && Number.isFinite(lower) ? Math.min(binding.limits.expiresDays, lower) : binding.limits.expiresDays;
|
|
9734
|
+
}
|
|
9735
|
+
/** A Slack conversation a standing approval may name exactly: a channel, a private channel or group DM, or a DM. */
|
|
9736
|
+
const STANDING_PLACE_PATTERN = /^[CGD][A-Z0-9]{2,}$/;
|
|
9737
|
+
const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9738
|
+
const isStrings$1 = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
9739
|
+
/** Why `shared` is not a recorded sharing state, or null. */
|
|
9740
|
+
function sharedProblem(shared) {
|
|
9741
|
+
if (!isRecord$3(shared)) return "a place has no sharing state";
|
|
9742
|
+
if (typeof shared.extShared !== "boolean" || typeof shared.pendingExtShared !== "boolean") return "a place has no sharing state";
|
|
9743
|
+
if (shared.externalMembers !== null && typeof shared.externalMembers !== "boolean") return "a place has no sharing state";
|
|
9744
|
+
return null;
|
|
9745
|
+
}
|
|
9746
|
+
/** Why `places` is not a standing approval's places, or null. */
|
|
9747
|
+
function placesProblem(places) {
|
|
9748
|
+
if (!isRecord$3(places)) return "it has no places";
|
|
9749
|
+
if (places.kind === "threads-i-am-in") return Object.keys(places).length === 1 ? null : "its places are not one kind";
|
|
9750
|
+
if (places.kind !== "exact" || !Array.isArray(places.places)) return "it has no places";
|
|
9751
|
+
if (places.places.length === 0) return "it names no place";
|
|
9752
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9753
|
+
for (const place of places.places) {
|
|
9754
|
+
if (!isRecord$3(place) || typeof place.id !== "string" || !STANDING_PLACE_PATTERN.test(place.id)) return "a place is not a Slack channel or DM id";
|
|
9755
|
+
if (seen.has(place.id)) return "a place is named twice";
|
|
9756
|
+
seen.add(place.id);
|
|
9757
|
+
if (place.name !== null && typeof place.name !== "string") return "a place has no name or null";
|
|
9758
|
+
if (!Number.isSafeInteger(place.members) || place.members < 0) return "a place has no member count";
|
|
9759
|
+
const shared = sharedProblem(place.shared);
|
|
9760
|
+
if (shared !== null) return shared;
|
|
9761
|
+
}
|
|
9762
|
+
return null;
|
|
9763
|
+
}
|
|
9764
|
+
/**
|
|
9765
|
+
* Why `binding` is not one a standing record may hold, or null: every content one `checkedContents` gives for its
|
|
9766
|
+
* texts, in order, with its digest; exactly the warnings those contents give (S3); every limit inside the table; places
|
|
9767
|
+
* of one kind, each a Slack id once, with its member count and sharing; an account by its id; and who proposed it. The store refuses one before writing it, and
|
|
9768
|
+
* the validator a record that holds one, so nothing a caller assembles by hand — a custom emoji, a 31-day expiry, a
|
|
9769
|
+
* reply with a link — can become a standing approval.
|
|
9770
|
+
*/
|
|
9771
|
+
function standingBindingProblem(binding) {
|
|
9772
|
+
if (!isRecord$3(binding)) return "it is not a standing approval";
|
|
9773
|
+
if (typeof binding.summary !== "string") return "it has no summary";
|
|
9774
|
+
const target = binding.target;
|
|
9775
|
+
if (!isRecord$3(target) || target.kind !== "account" || typeof target.name !== "string") return "it names no account";
|
|
9776
|
+
if (typeof target.id !== "string" || !ACCOUNT_ID_PATTERN.test(target.id)) return "it names no account by its id";
|
|
9777
|
+
if (!Array.isArray(binding.contents)) return "it has no contents";
|
|
9778
|
+
const written = binding.contents;
|
|
9779
|
+
if (!written.every((content) => isRecord$3(content) && typeof content.text === "string")) return "a content is unreadable";
|
|
9780
|
+
const contents = written;
|
|
9781
|
+
let checked;
|
|
9782
|
+
let warnings;
|
|
9783
|
+
try {
|
|
9784
|
+
({contents: checked, warnings} = checkedContents({
|
|
9785
|
+
reactions: contents.filter((content) => content.kind === "reaction").map((content) => content.text),
|
|
9786
|
+
replies: contents.filter((content) => content.kind === "reply").map((content) => content.text)
|
|
9787
|
+
}));
|
|
9788
|
+
} catch {
|
|
9789
|
+
return "a content is not one a standing approval may hold";
|
|
9790
|
+
}
|
|
9791
|
+
if (contents.length === 0) return "it holds no content";
|
|
9792
|
+
const canonical = (list) => canonicalJson$1(list.map((content) => ({
|
|
9793
|
+
kind: content.kind,
|
|
9794
|
+
text: content.text,
|
|
9795
|
+
digest: content.digest
|
|
9796
|
+
})));
|
|
9797
|
+
if (canonical(checked) !== canonical(contents)) return "its contents are not as checked, in order, with their digests";
|
|
9798
|
+
const places = placesProblem(binding.places);
|
|
9799
|
+
if (places !== null) return places;
|
|
9800
|
+
if (!isRecord$3(binding.limits)) return "it has no limits";
|
|
9801
|
+
try {
|
|
9802
|
+
if (canonicalJson$1(checkedLimits(binding.limits)) !== canonicalJson$1(binding.limits)) return "its limits are not whole and complete";
|
|
9803
|
+
} catch {
|
|
9804
|
+
return "a limit is outside the table";
|
|
9805
|
+
}
|
|
9806
|
+
if (binding.proposedBy !== "agent" && binding.proposedBy !== "person") return "it does not say who proposed it";
|
|
9807
|
+
if (binding.replaces !== void 0 && (typeof binding.replaces !== "string" || !APPROVAL_ID_PATTERN.test(binding.replaces))) return "what it replaces is not an approval id";
|
|
9808
|
+
if (!isStrings$1(binding.warnings)) return "its warnings are unreadable";
|
|
9809
|
+
if (canonicalJson$1(binding.warnings) !== canonicalJson$1(warnings)) return "its warnings are not the ones its contents give";
|
|
9810
|
+
return null;
|
|
9811
|
+
}
|
|
9812
|
+
/** A copy of `binding` holding exactly its own fields, or BAD_DATA naming why it is not one (`standingBindingProblem`). */
|
|
9813
|
+
function canonicalStandingBinding(binding) {
|
|
9814
|
+
const problem = standingBindingProblem(binding);
|
|
9815
|
+
if (problem !== null) throw new CommsError("BAD_DATA", `nothing was prepared: ${problem}`, {
|
|
9816
|
+
hint: "This is a bug — please report it.",
|
|
9817
|
+
details: { reason: "binding-invalid" }
|
|
9818
|
+
});
|
|
9819
|
+
return {
|
|
9820
|
+
summary: binding.summary,
|
|
9821
|
+
target: {
|
|
9822
|
+
kind: "account",
|
|
9823
|
+
name: binding.target.name,
|
|
9824
|
+
id: binding.target.id
|
|
9825
|
+
},
|
|
9826
|
+
contents: binding.contents.map((content) => ({
|
|
9827
|
+
kind: content.kind,
|
|
9828
|
+
text: content.text,
|
|
9829
|
+
digest: content.digest
|
|
9830
|
+
})),
|
|
9831
|
+
places: binding.places.kind === "threads-i-am-in" ? { kind: "threads-i-am-in" } : {
|
|
9832
|
+
kind: "exact",
|
|
9833
|
+
places: binding.places.places.map((place) => ({
|
|
9834
|
+
id: place.id,
|
|
9835
|
+
name: place.name,
|
|
9836
|
+
members: place.members,
|
|
9837
|
+
shared: {
|
|
9838
|
+
extShared: place.shared.extShared,
|
|
9839
|
+
pendingExtShared: place.shared.pendingExtShared,
|
|
9840
|
+
externalMembers: place.shared.externalMembers
|
|
9841
|
+
}
|
|
9842
|
+
}))
|
|
9843
|
+
},
|
|
9844
|
+
limits: { ...binding.limits },
|
|
9845
|
+
proposedBy: binding.proposedBy,
|
|
9846
|
+
...binding.replaces === void 0 ? {} : { replaces: binding.replaces },
|
|
9847
|
+
warnings: [...binding.warnings]
|
|
9848
|
+
};
|
|
9849
|
+
}
|
|
9850
|
+
/**
|
|
9851
|
+
* Why `narrowed`, stored beside `binding`, is not a narrowing of it, or null: each key what a narrowing holds, each
|
|
9852
|
+
* content and place one the binding has, and no limit above the binding's or outside the table. Not bound into the
|
|
9853
|
+
* record's digest, since it changes after approval; only ever able to narrow, so a hand that edits it can only take away.
|
|
9854
|
+
*/
|
|
9855
|
+
function standingNarrowingProblem(binding, narrowed) {
|
|
9856
|
+
if (narrowed === void 0) return null;
|
|
9857
|
+
if (!isRecord$3(narrowed)) return "it is not a narrowing";
|
|
9858
|
+
for (const key of Object.keys(narrowed)) if (![
|
|
9859
|
+
"keep",
|
|
9860
|
+
"places",
|
|
9861
|
+
"limits"
|
|
9862
|
+
].includes(key)) return "it is not a narrowing";
|
|
9863
|
+
if (narrowed.keep !== void 0 && !isStrings$1(narrowed.keep)) return "it keeps something that is not a digest";
|
|
9864
|
+
if (narrowed.places !== void 0 && !isStrings$1(narrowed.places)) return "it keeps something that is not a place";
|
|
9865
|
+
if (narrowed.limits !== void 0) {
|
|
9866
|
+
if (!isRecord$3(narrowed.limits)) return "its limits are unreadable";
|
|
9867
|
+
for (const [name, value] of Object.entries(narrowed.limits)) {
|
|
9868
|
+
if (!LIMIT_NAMES$1.includes(name)) return "it narrows a limit there is none of";
|
|
9869
|
+
const limit = name;
|
|
9870
|
+
if (!Number.isSafeInteger(value) || value < STANDING_LIMITS[limit].min) return "a limit is outside the table";
|
|
9871
|
+
if (value > binding.limits[limit]) return "it raises a limit";
|
|
9872
|
+
}
|
|
9873
|
+
}
|
|
9874
|
+
try {
|
|
9875
|
+
effectiveStanding(binding, narrowed);
|
|
9876
|
+
} catch {
|
|
9877
|
+
return "it keeps what the standing approval does not hold";
|
|
9878
|
+
}
|
|
9879
|
+
return null;
|
|
9880
|
+
}
|
|
9881
|
+
/** Whether a place recorded as `after` is one `before` approved: the same id, shared the same, and no bigger a room. */
|
|
9882
|
+
function placeCovered(before, after) {
|
|
9883
|
+
const held = before.find((place) => place.id === after.id);
|
|
9884
|
+
if (held === void 0) return false;
|
|
9885
|
+
if (canonicalJson$1(held.shared) !== canonicalJson$1(after.shared)) return false;
|
|
9886
|
+
return after.members < 50 || held.members >= 50;
|
|
9887
|
+
}
|
|
9888
|
+
/**
|
|
9889
|
+
* What `after` does to `before` (S8): `widens` with any content digest or exact place `before` does not hold — a place
|
|
9890
|
+
* shared differently, or grown to 50 or more members, is not the place it approved — a swap between exact places and
|
|
9891
|
+
* `threads-i-am-in` either way, another account, any higher limit, or a later expiry; `same` when it permits exactly
|
|
9892
|
+
* what `before` does; anything else that differs `narrows`. Unclear is a widening: it costs a person a code, never a
|
|
9893
|
+
* yes they did not give.
|
|
9894
|
+
*/
|
|
9895
|
+
function classifyStandingChange(before, after) {
|
|
9896
|
+
if (before.target.id !== after.target.id) return "widens";
|
|
9897
|
+
const held = new Set(before.contents.map((content) => content.digest));
|
|
9898
|
+
if (after.contents.some((content) => !held.has(content.digest))) return "widens";
|
|
9899
|
+
if (before.places.kind !== after.places.kind) return "widens";
|
|
9900
|
+
if (before.places.kind === "exact" && after.places.kind === "exact") {
|
|
9901
|
+
const approved = before.places.places;
|
|
9902
|
+
if (after.places.places.some((place) => !placeCovered(approved, place))) return "widens";
|
|
9903
|
+
}
|
|
9904
|
+
for (const name of LIMIT_NAMES$1) if (after.limits[name] > before.limits[name]) return "widens";
|
|
9905
|
+
const kept = new Set(after.contents.map((content) => content.digest));
|
|
9906
|
+
const fewerContents = before.contents.some((content) => !kept.has(content.digest));
|
|
9907
|
+
const afterPlaces = after.places.kind === "exact" ? after.places.places : [];
|
|
9908
|
+
const fewerPlaces = before.places.kind === "exact" && before.places.places.some((place) => !afterPlaces.some((now) => now.id === place.id));
|
|
9909
|
+
const lower = LIMIT_NAMES$1.some((name) => after.limits[name] < before.limits[name]);
|
|
9910
|
+
return fewerContents || fewerPlaces || lower ? "narrows" : "same";
|
|
9911
|
+
}
|
|
9912
|
+
Object.freeze({
|
|
9913
|
+
"not-fresh": "the message is older than 15 minutes, or than this standing approval",
|
|
9914
|
+
"not-newest": "the message is not the newest from someone else in its thread or channel",
|
|
9915
|
+
"already-acknowledged": "this message or thread was already acknowledged under a standing approval",
|
|
9916
|
+
"not-human": "the message, or its thread's first message, is not from a person",
|
|
9917
|
+
interactive: "the message has buttons or menus, or came from a workflow",
|
|
9918
|
+
"place-not-covered": "this standing approval does not cover this place",
|
|
9919
|
+
"big-room": "this place has 50 or more members, and was not approved with that count",
|
|
9920
|
+
"count-unreadable": "how many people are in this place could not be read",
|
|
9921
|
+
external: "someone from another organisation is in this place",
|
|
9922
|
+
"shared-state-changed": "who shares this place changed since it was approved",
|
|
9923
|
+
"reaction-as-person": "a standing reaction goes only as the agent app, and here it would go as you",
|
|
9924
|
+
"app-not-member": "the agent app is not a member of this place, or whether it is could not be read",
|
|
9925
|
+
"content-not-held": "this standing approval holds no such content",
|
|
9926
|
+
"clock-behind": "the clock is earlier than this account's last standing act",
|
|
9927
|
+
unreadable: "the message or its thread could not be read"
|
|
9928
|
+
});
|
|
9929
|
+
Object.freeze({
|
|
9930
|
+
perThread: "per thread",
|
|
9931
|
+
perHour: "an hour",
|
|
9932
|
+
perDay: "a day",
|
|
9933
|
+
perSender: "a day on messages from one person"
|
|
9934
|
+
});
|
|
9935
|
+
/**
|
|
9244
9936
|
* The digest a download's question is bound to: the account, the download, the request, the files it listed and the
|
|
9245
9937
|
* names it showed them under.
|
|
9246
9938
|
*
|
|
@@ -9387,6 +10079,19 @@ function refuseDisclosure(code, reason, record, hint) {
|
|
|
9387
10079
|
} : {}
|
|
9388
10080
|
});
|
|
9389
10081
|
}
|
|
10082
|
+
/**
|
|
10083
|
+
* The same refusal for a standing approval, which sends only through its children: nothing was sent under it. Its hint
|
|
10084
|
+
* never names a command that makes or widens one — only the person does that, at their terminal.
|
|
10085
|
+
*/
|
|
10086
|
+
function refuseStanding(code, reason, record, hint) {
|
|
10087
|
+
return new CommsError(code, `nothing was sent: ${reason}`, {
|
|
10088
|
+
hint: hint ?? "This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal.",
|
|
10089
|
+
details: record ? {
|
|
10090
|
+
approvalId: record.approvalId,
|
|
10091
|
+
state: record.state
|
|
10092
|
+
} : {}
|
|
10093
|
+
});
|
|
10094
|
+
}
|
|
9390
10095
|
/** The refusal in the words of the record's own kind. */
|
|
9391
10096
|
function refusalFor(record) {
|
|
9392
10097
|
switch (approvalKind(record)) {
|
|
@@ -9394,6 +10099,7 @@ function refusalFor(record) {
|
|
|
9394
10099
|
case "change": return refuseChange;
|
|
9395
10100
|
case "download": return refuseDownload;
|
|
9396
10101
|
case "disclosure": return refuseDisclosure;
|
|
10102
|
+
case "standing": return refuseStanding;
|
|
9397
10103
|
}
|
|
9398
10104
|
}
|
|
9399
10105
|
/**
|
|
@@ -9444,6 +10150,14 @@ function cancelledClaim(record) {
|
|
|
9444
10150
|
reason: "cancelled"
|
|
9445
10151
|
}
|
|
9446
10152
|
});
|
|
10153
|
+
case "standing": return new CommsError("USAGE", "cancelled: nothing was sent", {
|
|
10154
|
+
hint: "The standing approval was not used: it is as it was.",
|
|
10155
|
+
details: {
|
|
10156
|
+
approvalId: record.approvalId,
|
|
10157
|
+
state: record.state,
|
|
10158
|
+
reason: "cancelled"
|
|
10159
|
+
}
|
|
10160
|
+
});
|
|
9447
10161
|
}
|
|
9448
10162
|
}
|
|
9449
10163
|
/** The refusal of a renewal or an outcome from a call that does not hold the send's claim. It names no token. */
|
|
@@ -9563,7 +10277,11 @@ var ApprovalStore = class {
|
|
|
9563
10277
|
reason: CLOCK_ANOMALY,
|
|
9564
10278
|
expiredAt: observed.toISOString()
|
|
9565
10279
|
};
|
|
9566
|
-
|
|
10280
|
+
let deadline = record.state === "approved" && record.kind !== "download" && record.usableUntil !== void 0 ? record.usableUntil : record.expiresAt;
|
|
10281
|
+
if (record.kind === "standing" && record.state === "approved" && record.approvedAt !== void 0 && record.standing) {
|
|
10282
|
+
const narrowed = Date.parse(record.approvedAt) + standingDays(record.standing, record.narrowed) * STANDING_DAY_MS;
|
|
10283
|
+
if (narrowed < Date.parse(deadline)) deadline = new Date(narrowed).toISOString();
|
|
10284
|
+
}
|
|
9567
10285
|
if (now >= Date.parse(deadline)) return {
|
|
9568
10286
|
...record,
|
|
9569
10287
|
state: "expired",
|
|
@@ -9731,6 +10449,7 @@ var ApprovalStore = class {
|
|
|
9731
10449
|
const objectOf = (record) => approvalObjectOf(record, live, this.#now());
|
|
9732
10450
|
let next;
|
|
9733
10451
|
try {
|
|
10452
|
+
await options.before?.(current, outcome);
|
|
9734
10453
|
next = decide(current, outcome, live);
|
|
9735
10454
|
} catch (error) {
|
|
9736
10455
|
throw withApproval(error, objectOf(current));
|
|
@@ -9771,7 +10490,7 @@ var ApprovalStore = class {
|
|
|
9771
10490
|
usedSaid: options.usedSaid
|
|
9772
10491
|
});
|
|
9773
10492
|
if (found.form === "v2" && derived.form === "v2") {
|
|
9774
|
-
const current = outcome.revokes && action !== "inspect" && action !== "wait"
|
|
10493
|
+
const current = outcome.revokes && outcome.record !== null && (action !== "inspect" && action !== "wait" || writtenAtALook(outcome)) && outcome.record !== null ? outcome.record : derived.record;
|
|
9775
10494
|
if (current.state !== found.record.state) await this.#write({
|
|
9776
10495
|
...current,
|
|
9777
10496
|
updatedAt: this.#now().toISOString()
|
|
@@ -9855,6 +10574,10 @@ var ApprovalStore = class {
|
|
|
9855
10574
|
hint: "Nothing more is needed from the person: complete the same activation with the same approval.",
|
|
9856
10575
|
details: detail
|
|
9857
10576
|
});
|
|
10577
|
+
case "standing": return new CommsError("USAGE", "nothing was sent: it is approved already, and a person approves it once", {
|
|
10578
|
+
hint: "Nothing more is needed from the person: the standing approval is in force until it ends.",
|
|
10579
|
+
details: detail
|
|
10580
|
+
});
|
|
9858
10581
|
}
|
|
9859
10582
|
}
|
|
9860
10583
|
/** Issues a new challenge to show a human; only its hash is kept. */
|
|
@@ -10015,7 +10738,7 @@ var ApprovalStore = class {
|
|
|
10015
10738
|
async approve(approvalId, via, live, answer, kind = "send", _platform = process.platform, options = {}) {
|
|
10016
10739
|
if (kind === "disclosure") throw new CommsError("APPROVAL_REQUIRED", "nothing was disclosed: a standing disclosure approval is only accepted on the trusted terminal or app route");
|
|
10017
10740
|
let failure = null;
|
|
10018
|
-
const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome) => {
|
|
10741
|
+
const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome, gate) => {
|
|
10019
10742
|
if (outcome.error) throw outcome.error;
|
|
10020
10743
|
if (current.state !== "pending") throw this.#alreadyApproved(current, outcome);
|
|
10021
10744
|
if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
|
|
@@ -10032,10 +10755,22 @@ var ApprovalStore = class {
|
|
|
10032
10755
|
revokedAt: at.toISOString()
|
|
10033
10756
|
};
|
|
10034
10757
|
}
|
|
10758
|
+
if (kind === "standing" && via !== "terminal") {
|
|
10759
|
+
failure = {
|
|
10760
|
+
code: "APPROVAL_VOID",
|
|
10761
|
+
reason: "a standing approval is approved only with its code at a terminal, and this was not approved at a terminal"
|
|
10762
|
+
};
|
|
10763
|
+
return {
|
|
10764
|
+
...current,
|
|
10765
|
+
state: "revoked",
|
|
10766
|
+
reason: failure.reason,
|
|
10767
|
+
revokedAt: at.toISOString()
|
|
10768
|
+
};
|
|
10769
|
+
}
|
|
10035
10770
|
if (live.draftMessageId !== current.draftMessageId || live.contentDigest !== current.contentDigest) {
|
|
10036
10771
|
failure = {
|
|
10037
10772
|
code: "APPROVAL_VOID",
|
|
10038
|
-
reason: kind === "change" ? "the change shown is not the one the approval was prepared for" : "the draft changed after the preview was prepared"
|
|
10773
|
+
reason: kind === "change" ? "the change shown is not the one the approval was prepared for" : kind === "standing" ? "the standing approval shown is not the one that was prepared" : "the draft changed after the preview was prepared"
|
|
10039
10774
|
};
|
|
10040
10775
|
return {
|
|
10041
10776
|
...current,
|
|
@@ -10075,7 +10810,8 @@ var ApprovalStore = class {
|
|
|
10075
10810
|
approvedAt: at.toISOString(),
|
|
10076
10811
|
usableUntil: new Date(at.getTime() + (current.approvedMs ?? APPROVAL_LIFETIMES.approved)).toISOString(),
|
|
10077
10812
|
approvedBindingDigest: current.bindingDigest,
|
|
10078
|
-
challengeHash: void 0
|
|
10813
|
+
challengeHash: void 0,
|
|
10814
|
+
...kind === "standing" ? { approvedChangePolicy: gate?.changePolicy ?? "chat" } : {}
|
|
10079
10815
|
};
|
|
10080
10816
|
}, { usedSaid: options.usedSaid });
|
|
10081
10817
|
const failed = failure;
|
|
@@ -10094,6 +10830,7 @@ var ApprovalStore = class {
|
|
|
10094
10830
|
*/
|
|
10095
10831
|
async claimForSend(approvalId, live, options = {}) {
|
|
10096
10832
|
let failure = null;
|
|
10833
|
+
let parentProblem = null;
|
|
10097
10834
|
const done = await this.#transition(approvalId, {
|
|
10098
10835
|
kind: "send",
|
|
10099
10836
|
owner: live.inboxId
|
|
@@ -10114,6 +10851,7 @@ var ApprovalStore = class {
|
|
|
10114
10851
|
challengeHash: void 0
|
|
10115
10852
|
};
|
|
10116
10853
|
};
|
|
10854
|
+
if (parentProblem !== null) return voidWith("APPROVAL_VOID", parentProblem);
|
|
10117
10855
|
if (current.inboxSub && current.inboxSub !== live.inboxSub) return voidWith("APPROVAL_VOID", live.inboxSub ? "the inbox is now connected to a different account" : "the account this was prepared for could not be confirmed");
|
|
10118
10856
|
if (live.draftMessageId !== current.draftMessageId) return voidWith("APPROVAL_VOID", "the draft was edited after the preview");
|
|
10119
10857
|
if (live.contentDigest !== current.contentDigest) return voidWith("APPROVAL_VOID", "the draft content changed after the preview");
|
|
@@ -10125,7 +10863,12 @@ var ApprovalStore = class {
|
|
|
10125
10863
|
state: "sending",
|
|
10126
10864
|
sendingAt: at
|
|
10127
10865
|
};
|
|
10128
|
-
}, {
|
|
10866
|
+
}, {
|
|
10867
|
+
usedSaid: options.usedSaid,
|
|
10868
|
+
before: async (current, outcome) => {
|
|
10869
|
+
if (outcome.error === void 0 && current.route === "standing" && current.state === "approved") parentProblem = await this.#standingParentProblem(current);
|
|
10870
|
+
}
|
|
10871
|
+
});
|
|
10129
10872
|
const failed = failure;
|
|
10130
10873
|
if (failed) throw refusedAfter(refuse$1(failed.code, failed.reason, done.record), done);
|
|
10131
10874
|
const claimToken = randomBytes(16).toString("hex");
|
|
@@ -10138,6 +10881,31 @@ var ApprovalStore = class {
|
|
|
10138
10881
|
};
|
|
10139
10882
|
}
|
|
10140
10883
|
/**
|
|
10884
|
+
* Why a standing child may not be claimed now, or null (design 2026-10-19-standing-approvals, S9, the lead's call after
|
|
10885
|
+
* review): its standing approval read again, under that one's own lock — ended by any of the store's rules, an epoch
|
|
10886
|
+
* or a tightened change policy among them, or narrowed so that it no longer holds the child's content or covers its
|
|
10887
|
+
* place. Called with the child's lock held: no lock is ever taken the other way round.
|
|
10888
|
+
*/
|
|
10889
|
+
async #standingParentProblem(child) {
|
|
10890
|
+
const use = child.standingUse;
|
|
10891
|
+
if (child.standingId === void 0 || use === void 0) return "it names no standing approval it is a use of";
|
|
10892
|
+
let seen;
|
|
10893
|
+
try {
|
|
10894
|
+
seen = await this.inspect(child.standingId, {
|
|
10895
|
+
kind: "standing",
|
|
10896
|
+
owner: child.inboxId
|
|
10897
|
+
}, { action: "claim" });
|
|
10898
|
+
} catch {
|
|
10899
|
+
return "its standing approval could not be read";
|
|
10900
|
+
}
|
|
10901
|
+
const parent = seen.outcome.record;
|
|
10902
|
+
if (seen.outcome.error !== void 0 || parent === null || parent.state !== "approved" || parent.standing === void 0) return `its standing approval has ended${seen.outcome.reason === void 0 ? "" : ` (${seen.outcome.reason})`}`;
|
|
10903
|
+
const inForce = effectiveStanding(parent.standing, parent.narrowed);
|
|
10904
|
+
if (!inForce.contents.some((content) => content.digest === use.content)) return "its standing approval no longer holds what it sends";
|
|
10905
|
+
if (inForce.places.kind === "exact" && !inForce.places.places.some((place) => place.id === use.channel)) return "its standing approval no longer covers where it goes";
|
|
10906
|
+
return null;
|
|
10907
|
+
}
|
|
10908
|
+
/**
|
|
10141
10909
|
* The file system's O_EXCL is the single-use guarantee, independent of the lock. A send's marker also holds its
|
|
10142
10910
|
* claim token, the one thing that lets the claimant — and nobody else — renew its lease and record its outcome.
|
|
10143
10911
|
*/
|
|
@@ -10299,6 +11067,208 @@ var ApprovalStore = class {
|
|
|
10299
11067
|
return done.record;
|
|
10300
11068
|
}
|
|
10301
11069
|
/**
|
|
11070
|
+
* A standing approval (design 2026-10-19-standing-approvals, S8), pending, bound to `standingDigest` of its binding —
|
|
11071
|
+
* computed here, never taken from the caller — and to the send epoch of its account and the standing epoch, both as
|
|
11072
|
+
* read when it is made.
|
|
11073
|
+
*
|
|
11074
|
+
* Its route is `confirm` and its `requiredPolicy` `confirm` whatever the change policy is, because what it permits
|
|
11075
|
+
* happens with nobody asked: it is approved only with the code typed at a terminal (`approve`, `via: 'terminal'`),
|
|
11076
|
+
* never by a yes in the chat, a client's form, or an answer carried in a call. It lasts its days once approved
|
|
11077
|
+
* (`approvedMs`). Nothing ever claims it: each use mints a child (`createStandingChild`).
|
|
11078
|
+
*
|
|
11079
|
+
* The binding is checked before anything is written (`canonicalStandingBinding`): a custom emoji, a reply with a
|
|
11080
|
+
* link, a limit outside the table or a place that is no Slack id is refused, however the caller assembled it.
|
|
11081
|
+
*/
|
|
11082
|
+
async createStanding(input) {
|
|
11083
|
+
const standing = canonicalStandingBinding(input.binding);
|
|
11084
|
+
if (typeof input.inboxSub !== "string" || input.inboxSub === "") throw new CommsError("BAD_DATA", "nothing was prepared: a standing approval is made for a known account user", { hint: "This is a bug — please report it." });
|
|
11085
|
+
requireEpochs(input.sendEpoch, input.standingEpoch);
|
|
11086
|
+
requireKnownChannel("slack");
|
|
11087
|
+
await this.ensurePruned();
|
|
11088
|
+
const now = this.#now();
|
|
11089
|
+
const digest = standingDigest(standing);
|
|
11090
|
+
const pendingMs = APPROVAL_LIFETIMES.confirm;
|
|
11091
|
+
const record = this.#bound({
|
|
11092
|
+
approvalId: newApprovalId(),
|
|
11093
|
+
kind: "standing",
|
|
11094
|
+
digestVersion: 2,
|
|
11095
|
+
channel: "slack",
|
|
11096
|
+
ownerScope: "owner",
|
|
11097
|
+
route: "confirm",
|
|
11098
|
+
pendingMs,
|
|
11099
|
+
approvedMs: standing.limits.expiresDays * STANDING_DAY_MS,
|
|
11100
|
+
inboxId: standing.target.id,
|
|
11101
|
+
inboxSub: input.inboxSub,
|
|
11102
|
+
draftId: "standing",
|
|
11103
|
+
draftMessageId: digest,
|
|
11104
|
+
contentDigest: digest,
|
|
11105
|
+
sendEpoch: input.sendEpoch,
|
|
11106
|
+
standingEpoch: input.standingEpoch,
|
|
11107
|
+
policy: "confirm",
|
|
11108
|
+
requiredPolicy: "confirm",
|
|
11109
|
+
riskFlags: [],
|
|
11110
|
+
expect: {
|
|
11111
|
+
to: [],
|
|
11112
|
+
cc: [],
|
|
11113
|
+
bcc: [],
|
|
11114
|
+
subject: standing.summary
|
|
11115
|
+
},
|
|
11116
|
+
challengeAttempts: 0,
|
|
11117
|
+
state: "pending",
|
|
11118
|
+
createdAt: now.toISOString(),
|
|
11119
|
+
expiresAt: new Date(now.getTime() + pendingMs).toISOString(),
|
|
11120
|
+
updatedAt: now.toISOString(),
|
|
11121
|
+
standing
|
|
11122
|
+
});
|
|
11123
|
+
await this.#write(record);
|
|
11124
|
+
return record;
|
|
11125
|
+
}
|
|
11126
|
+
/**
|
|
11127
|
+
* Runs `use` under the standing record's own lock, with the record as it reads there and its classification for a
|
|
11128
|
+
* use (`claim`): its expiry, a revocation the configuration derives — its account removed, `never`, a send epoch or
|
|
11129
|
+
* a standing epoch behind the live one — written before `use` runs, and `outcome.error` set for every state that
|
|
11130
|
+
* refuses a use. `owner` is the account the caller acts for: any other's record is the one `NOT_FOUND`.
|
|
11131
|
+
*
|
|
11132
|
+
* What `use` decides is decided while no revocation, narrowing or approval of this record can land. `use` must not
|
|
11133
|
+
* take this record's lock again: it is held.
|
|
11134
|
+
*/
|
|
11135
|
+
async withStanding(standingId, owner, use) {
|
|
11136
|
+
const path = this.#path(standingId);
|
|
11137
|
+
const expect = {
|
|
11138
|
+
kind: "standing",
|
|
11139
|
+
owner
|
|
11140
|
+
};
|
|
11141
|
+
return withFileLock(`${path}.lock`, async () => {
|
|
11142
|
+
const config = await this.#config();
|
|
11143
|
+
const found = await this.#read(standingId, config);
|
|
11144
|
+
if (found === null || !matchesExpectation(found, expect)) throw approvalNotFound(standingId, "standing");
|
|
11145
|
+
const derived = this.#derived(found);
|
|
11146
|
+
const outcome = approvalOutcome(derived, {
|
|
11147
|
+
action: "claim",
|
|
11148
|
+
live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
|
|
11149
|
+
now: this.#now()
|
|
11150
|
+
});
|
|
11151
|
+
if (found.form !== "v2" || derived.form !== "v2" || outcome.record === null) throw outcome.error ?? integrityRefusal(found);
|
|
11152
|
+
const current = outcome.revokes ? outcome.record : derived.record;
|
|
11153
|
+
if (current.state !== found.record.state) await this.#write({
|
|
11154
|
+
...current,
|
|
11155
|
+
updatedAt: this.#now().toISOString()
|
|
11156
|
+
});
|
|
11157
|
+
return use(current, outcome);
|
|
11158
|
+
});
|
|
11159
|
+
}
|
|
11160
|
+
/**
|
|
11161
|
+
* One use of a standing approval (S9): a send record, approved the moment it is made — `route: 'standing'`,
|
|
11162
|
+
* `approvedVia: 'standing'`, `approvedAt` its creation — bound to the standing id, the standing epoch and the send
|
|
11163
|
+
* epoch, and usable once, within `APPROVAL_LIFETIMES.standingChild`. It then takes the one send path every send
|
|
11164
|
+
* takes: `claimForSend`, its O_EXCL marker, the fence and the outcome.
|
|
11165
|
+
*
|
|
11166
|
+
* Minted only by the use (`mintStandingUse`), under the account's acknowledgement ledger lock and then the standing
|
|
11167
|
+
* record's, after every check there. Checked again here, read without that lock — which the caller holds: the
|
|
11168
|
+
* standing record must be this account's, approved at a terminal, under the same epochs and the same account user,
|
|
11169
|
+
* and not ended, or nothing is written. A child never names anything but what its use was checked for.
|
|
11170
|
+
*/
|
|
11171
|
+
async createStandingChild(input) {
|
|
11172
|
+
if (!/^[0-9a-f]{64}$/.test(input.contentDigest)) throw new CommsError("UNEXPECTED", "an approval is bound to a SHA-256 of what it permits, and this is not one", { hint: "This is a bug — please report it." });
|
|
11173
|
+
if (standingUseProblem(input.use) !== null) throw new CommsError("UNEXPECTED", "a standing use’s child names where it acts and what it sends, or nothing", { hint: "This is a bug — please report it." });
|
|
11174
|
+
requireEpochs(input.sendEpoch, input.standingEpoch);
|
|
11175
|
+
requireKnownChannel("slack");
|
|
11176
|
+
const config = await this.#config();
|
|
11177
|
+
if (input.approvalId !== void 0) {
|
|
11178
|
+
if (!APPROVAL_ID_PATTERN.test(input.approvalId) || await this.#read(input.approvalId, config) !== null) throw new CommsError("UNEXPECTED", "a standing use’s child is made under a fresh id, and this is not one", { hint: "This is a bug — please report it." });
|
|
11179
|
+
}
|
|
11180
|
+
const found = await this.#read(input.standingId, config);
|
|
11181
|
+
if (found === null || !matchesExpectation(found, {
|
|
11182
|
+
kind: "standing",
|
|
11183
|
+
owner: input.inboxId
|
|
11184
|
+
})) throw approvalNotFound(input.standingId, "standing");
|
|
11185
|
+
if (found.form !== "v2") throw integrityRefusal(found);
|
|
11186
|
+
const parent = this.#derive(found.record);
|
|
11187
|
+
const live = config === null ? null : liveGateOf(config, parent);
|
|
11188
|
+
const outcome = approvalOutcome({
|
|
11189
|
+
form: "v2",
|
|
11190
|
+
record: parent
|
|
11191
|
+
}, {
|
|
11192
|
+
action: "claim",
|
|
11193
|
+
live,
|
|
11194
|
+
now: this.#now()
|
|
11195
|
+
});
|
|
11196
|
+
if (outcome.error) throw outcome.error;
|
|
11197
|
+
const standing = outcome.record ?? parent;
|
|
11198
|
+
if (standing.state === "pending") throw refuseStanding("APPROVAL_PENDING", "the standing approval waits for the person to approve it at a terminal", standing);
|
|
11199
|
+
const unbound = standing.state !== "approved" ? "the standing approval is not in force" : standing.approvedVia !== "terminal" || standing.approvedBindingDigest !== standing.bindingDigest ? "the standing approval was not approved at a terminal" : standing.standingEpoch !== input.standingEpoch || standing.sendEpoch !== input.sendEpoch ? "the use is not bound to the epochs its standing approval was made under" : standing.inboxSub !== input.inboxSub ? "the use is not for the account user its standing approval was made for" : null;
|
|
11200
|
+
if (unbound !== null) throw refuseStanding("APPROVAL_VOID", unbound, standing);
|
|
11201
|
+
const now = this.#now();
|
|
11202
|
+
const at = now.toISOString();
|
|
11203
|
+
const lifetime = APPROVAL_LIFETIMES.standingChild;
|
|
11204
|
+
const until = new Date(now.getTime() + lifetime).toISOString();
|
|
11205
|
+
const sealed = this.#bound({
|
|
11206
|
+
approvalId: input.approvalId ?? newApprovalId(),
|
|
11207
|
+
kind: "send",
|
|
11208
|
+
digestVersion: 2,
|
|
11209
|
+
channel: "slack",
|
|
11210
|
+
ownerScope: "owner",
|
|
11211
|
+
route: "standing",
|
|
11212
|
+
pendingMs: lifetime,
|
|
11213
|
+
approvedMs: lifetime,
|
|
11214
|
+
inboxId: input.inboxId,
|
|
11215
|
+
inboxSub: input.inboxSub,
|
|
11216
|
+
draftId: input.draftId,
|
|
11217
|
+
draftMessageId: input.draftMessageId,
|
|
11218
|
+
contentDigest: input.contentDigest,
|
|
11219
|
+
sendEpoch: input.sendEpoch,
|
|
11220
|
+
standingEpoch: input.standingEpoch,
|
|
11221
|
+
standingId: input.standingId,
|
|
11222
|
+
standingUse: {
|
|
11223
|
+
channel: input.use.channel,
|
|
11224
|
+
thread: input.use.thread,
|
|
11225
|
+
content: input.use.content
|
|
11226
|
+
},
|
|
11227
|
+
policy: live?.sendPolicy === "chat" ? "chat" : "confirm",
|
|
11228
|
+
requiredPolicy: "confirm",
|
|
11229
|
+
riskFlags: [...input.riskFlags],
|
|
11230
|
+
expect: {
|
|
11231
|
+
to: [...input.expect.to],
|
|
11232
|
+
cc: [...input.expect.cc],
|
|
11233
|
+
bcc: [...input.expect.bcc],
|
|
11234
|
+
subject: input.expect.subject
|
|
11235
|
+
},
|
|
11236
|
+
challengeAttempts: 0,
|
|
11237
|
+
state: "approved",
|
|
11238
|
+
createdAt: at,
|
|
11239
|
+
expiresAt: until,
|
|
11240
|
+
updatedAt: at,
|
|
11241
|
+
approvedVia: "standing",
|
|
11242
|
+
approvedAt: at,
|
|
11243
|
+
usableUntil: until
|
|
11244
|
+
});
|
|
11245
|
+
const record = {
|
|
11246
|
+
...sealed,
|
|
11247
|
+
approvedBindingDigest: sealed.bindingDigest
|
|
11248
|
+
};
|
|
11249
|
+
await this.#write(record);
|
|
11250
|
+
return record;
|
|
11251
|
+
}
|
|
11252
|
+
/**
|
|
11253
|
+
* Narrows a standing approval at once (S8): fewer contents or places, a lower limit, an earlier end. Nothing is asked
|
|
11254
|
+
* for, because nothing more is permitted: anything `classifyStandingChange` calls a widening of what is in force is
|
|
11255
|
+
* refused with USAGE, and the record is left as it was. The binding the person approved stays the record's own, and
|
|
11256
|
+
* what is narrowed is kept beside it, where it can only take away.
|
|
11257
|
+
*/
|
|
11258
|
+
async narrowStanding(standingId, owner, narrowing) {
|
|
11259
|
+
return (await this.#transition(standingId, {
|
|
11260
|
+
kind: "standing",
|
|
11261
|
+
owner
|
|
11262
|
+
}, "revoke", (current, outcome) => {
|
|
11263
|
+
if (outcome.error) throw outcome.error;
|
|
11264
|
+
if (current.state !== "pending" && current.state !== "approved" || current.standing === void 0) throw refuseStanding("APPROVAL_VOID", "the standing approval has ended, so there is nothing to narrow", current);
|
|
11265
|
+
return {
|
|
11266
|
+
...current,
|
|
11267
|
+
narrowed: narrowedFurther(current.standing, current.narrowed, narrowing)
|
|
11268
|
+
};
|
|
11269
|
+
})).record;
|
|
11270
|
+
}
|
|
11271
|
+
/**
|
|
10302
11272
|
* A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
|
|
10303
11273
|
* caller, as a change's digest is. It stands in for the draft revision too.
|
|
10304
11274
|
*
|
|
@@ -10583,13 +11553,28 @@ var ApprovalStore = class {
|
|
|
10583
11553
|
...derived.record,
|
|
10584
11554
|
updatedAt: this.#now().toISOString()
|
|
10585
11555
|
});
|
|
11556
|
+
const outcome = approvalOutcome(derived, {
|
|
11557
|
+
action: "inspect",
|
|
11558
|
+
live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
|
|
11559
|
+
now: this.#now()
|
|
11560
|
+
});
|
|
11561
|
+
if (writtenAtALook(outcome) && outcome.record !== null) {
|
|
11562
|
+
const ended = {
|
|
11563
|
+
...outcome.record,
|
|
11564
|
+
updatedAt: this.#now().toISOString()
|
|
11565
|
+
};
|
|
11566
|
+
await this.#write(ended);
|
|
11567
|
+
return {
|
|
11568
|
+
stored: {
|
|
11569
|
+
form: "v2",
|
|
11570
|
+
record: ended
|
|
11571
|
+
},
|
|
11572
|
+
outcome
|
|
11573
|
+
};
|
|
11574
|
+
}
|
|
10586
11575
|
return {
|
|
10587
11576
|
stored: derived,
|
|
10588
|
-
outcome
|
|
10589
|
-
action: "inspect",
|
|
10590
|
-
live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
|
|
10591
|
-
now: this.#now()
|
|
10592
|
-
})
|
|
11577
|
+
outcome
|
|
10593
11578
|
};
|
|
10594
11579
|
});
|
|
10595
11580
|
if (seen === null) continue;
|
|
@@ -10628,6 +11613,10 @@ var ApprovalStore = class {
|
|
|
10628
11613
|
return found.sort(byCreation);
|
|
10629
11614
|
}
|
|
10630
11615
|
};
|
|
11616
|
+
/** Both epochs a standing record or child binds: whole numbers of 0 or more, or a programming error. */
|
|
11617
|
+
function requireEpochs(sendEpoch, standingEpoch) {
|
|
11618
|
+
for (const epoch of [sendEpoch, standingEpoch]) if (!Number.isSafeInteger(epoch) || epoch < 0) throw new CommsError("UNEXPECTED", "a standing approval is bound to whole epochs, and this is not one", { hint: "This is a bug — please report it." });
|
|
11619
|
+
}
|
|
10631
11620
|
/** Oldest first; a record that cannot be read has no time to be ordered by, and follows, by id. */
|
|
10632
11621
|
function byCreation(a, b) {
|
|
10633
11622
|
const createdOf = (stored) => stored.form === "v2" ? stored.record.createdAt : stored.form === "legacy" ? stored.view.createdAt : null;
|
|
@@ -10665,7 +11654,7 @@ function sameExpectation(a, b) {
|
|
|
10665
11654
|
const CLOCK_ANOMALY = "clock-anomaly";
|
|
10666
11655
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
10667
11656
|
/** Exactly what `Date.prototype.toISOString` writes, which is all the store ever writes. */
|
|
10668
|
-
const ISO$
|
|
11657
|
+
const ISO$2 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
10669
11658
|
const STATES$1 = [
|
|
10670
11659
|
"pending",
|
|
10671
11660
|
"approved",
|
|
@@ -10676,7 +11665,10 @@ const STATES$1 = [
|
|
|
10676
11665
|
"expired",
|
|
10677
11666
|
"revoked"
|
|
10678
11667
|
];
|
|
10679
|
-
/**
|
|
11668
|
+
/**
|
|
11669
|
+
* The states each kind can reach. A change or a download is claimed straight to `used`: it is never sent. A standing
|
|
11670
|
+
* approval is never claimed at all — its children are — so it ends expired or revoked.
|
|
11671
|
+
*/
|
|
10680
11672
|
const STATES_OF = {
|
|
10681
11673
|
send: STATES$1,
|
|
10682
11674
|
change: [
|
|
@@ -10692,8 +11684,24 @@ const STATES_OF = {
|
|
|
10692
11684
|
"used",
|
|
10693
11685
|
"expired",
|
|
10694
11686
|
"revoked"
|
|
11687
|
+
],
|
|
11688
|
+
standing: [
|
|
11689
|
+
"pending",
|
|
11690
|
+
"approved",
|
|
11691
|
+
"expired",
|
|
11692
|
+
"revoked"
|
|
10695
11693
|
]
|
|
10696
11694
|
};
|
|
11695
|
+
/** A standing approval's child, approved when it is made: never pending. */
|
|
11696
|
+
const CHILD_STATES = [
|
|
11697
|
+
"approved",
|
|
11698
|
+
"sending",
|
|
11699
|
+
"used",
|
|
11700
|
+
"failed",
|
|
11701
|
+
"unknown",
|
|
11702
|
+
"expired",
|
|
11703
|
+
"revoked"
|
|
11704
|
+
];
|
|
10697
11705
|
/** The send states that went through a claim, and so carry `sendingAt`. */
|
|
10698
11706
|
const CLAIMED_SEND = [
|
|
10699
11707
|
"sending",
|
|
@@ -10701,10 +11709,14 @@ const CLAIMED_SEND = [
|
|
|
10701
11709
|
"failed",
|
|
10702
11710
|
"unknown"
|
|
10703
11711
|
];
|
|
11712
|
+
/** Whether a record is one use of a standing approval: a send on the `standing` route. */
|
|
11713
|
+
function isChild(raw) {
|
|
11714
|
+
return raw.kind === "send" && raw.route === "standing";
|
|
11715
|
+
}
|
|
10704
11716
|
/** A record's timestamp field: absent, invalid, or the time it names. */
|
|
10705
11717
|
function timeOf(value) {
|
|
10706
11718
|
if (value === void 0) return null;
|
|
10707
|
-
if (typeof value !== "string" || !ISO$
|
|
11719
|
+
if (typeof value !== "string" || !ISO$2.test(value)) return "invalid";
|
|
10708
11720
|
const at = Date.parse(value);
|
|
10709
11721
|
return Number.isFinite(at) && new Date(at).toISOString() === value ? at : "invalid";
|
|
10710
11722
|
}
|
|
@@ -10751,7 +11763,7 @@ function checkVerified(record, raw) {
|
|
|
10751
11763
|
if (raw.digestVersion !== 2) return fail("digest-version");
|
|
10752
11764
|
const kind = raw.kind;
|
|
10753
11765
|
if (kind === "disclosure") return checkDisclosure(record, raw);
|
|
10754
|
-
if (kind !== "send" && kind !== "change" && kind !== "download") return fail("wrong-shape");
|
|
11766
|
+
if (kind !== "send" && kind !== "change" && kind !== "download" && kind !== "standing") return fail("wrong-shape");
|
|
10755
11767
|
if (!STATES$1.includes(raw.state)) return fail("state-impossible");
|
|
10756
11768
|
const state = raw.state;
|
|
10757
11769
|
if (typeof raw.contentDigest !== "string" || !HEX64.test(raw.contentDigest)) return fail("content-digest-malformed");
|
|
@@ -10761,10 +11773,16 @@ function checkVerified(record, raw) {
|
|
|
10761
11773
|
if (kind === "download") {
|
|
10762
11774
|
if (record.download === void 0 || downloadDigest(record.download) !== record.contentDigest) return fail("content-digest-mismatch");
|
|
10763
11775
|
}
|
|
11776
|
+
if (kind === "standing") {
|
|
11777
|
+
if (record.standing === void 0 || standingBindingProblem(record.standing) !== null) return fail("wrong-shape");
|
|
11778
|
+
if (standingDigest(record.standing) !== record.contentDigest) return fail("content-digest-mismatch");
|
|
11779
|
+
if (standingNarrowingProblem(record.standing, raw.narrowed) !== null) return fail("narrowing-invalid");
|
|
11780
|
+
} else if (raw.standing !== void 0 || raw.narrowed !== void 0) return fail("wrong-shape");
|
|
10764
11781
|
const identity = checkIdentity(record, raw, kind);
|
|
10765
11782
|
if (identity !== null) return identity;
|
|
10766
11783
|
if (!STATES_OF[kind].includes(state)) return fail("state-impossible");
|
|
10767
|
-
|
|
11784
|
+
if (isChild(raw) && !CHILD_STATES.includes(state)) return fail("state-impossible");
|
|
11785
|
+
const lifetime = checkLifetime(record, raw, kind);
|
|
10768
11786
|
if (lifetime !== null) return lifetime;
|
|
10769
11787
|
const times = {};
|
|
10770
11788
|
for (const field of TIMESTAMPS) {
|
|
@@ -10839,9 +11857,19 @@ function checkIdentity(record, raw, kind) {
|
|
|
10839
11857
|
if (scope !== expected) return fail("identity-contradiction");
|
|
10840
11858
|
if (raw.inboxId !== (expected === "owner" ? target?.id : "")) return fail("identity-contradiction");
|
|
10841
11859
|
}
|
|
10842
|
-
|
|
10843
|
-
|
|
11860
|
+
const wholeEpoch = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
11861
|
+
if (kind === "send" || kind === "standing") {
|
|
11862
|
+
if (!wholeEpoch(raw.sendEpoch)) return fail("identity-contradiction");
|
|
10844
11863
|
} else if (raw.sendEpoch !== void 0) return fail("identity-contradiction");
|
|
11864
|
+
if (kind === "standing" || isChild(raw)) {
|
|
11865
|
+
if (raw.channel !== "slack" || !wholeEpoch(raw.standingEpoch)) return fail("identity-contradiction");
|
|
11866
|
+
} else if (raw.standingEpoch !== void 0) return fail("identity-contradiction");
|
|
11867
|
+
if (isChild(raw)) {
|
|
11868
|
+
if (typeof raw.standingId !== "string" || !APPROVAL_ID_PATTERN.test(raw.standingId)) return fail("identity-contradiction");
|
|
11869
|
+
if (raw.standingId === raw.approvalId) return fail("identity-contradiction");
|
|
11870
|
+
if (standingUseProblem(raw.standingUse) !== null) return fail("identity-contradiction");
|
|
11871
|
+
} else if (raw.standingId !== void 0 || raw.standingUse !== void 0) return fail("identity-contradiction");
|
|
11872
|
+
if (kind === "standing" && record.standing.target.id !== raw.inboxId) return fail("identity-contradiction");
|
|
10845
11873
|
if (kind === "download") {
|
|
10846
11874
|
const download = record.download;
|
|
10847
11875
|
if (download === void 0 || download.target.id !== raw.inboxId) return fail("identity-contradiction");
|
|
@@ -10851,13 +11879,31 @@ function checkIdentity(record, raw, kind) {
|
|
|
10851
11879
|
}
|
|
10852
11880
|
return null;
|
|
10853
11881
|
}
|
|
10854
|
-
/**
|
|
10855
|
-
|
|
11882
|
+
/**
|
|
11883
|
+
* The lifetime profile: what the route says, and nothing the record could have chosen for itself. A standing approval
|
|
11884
|
+
* waits as a `confirm` change does and lasts the whole days its limits name, from 1 to 30; its child lives
|
|
11885
|
+
* `standingChild`, pending and approved alike.
|
|
11886
|
+
*/
|
|
11887
|
+
function checkLifetime(record, raw, kind) {
|
|
10856
11888
|
if (kind === "download") {
|
|
10857
11889
|
if (raw.pendingMs !== APPROVAL_LIFETIMES.download) return fail("lifetime-mismatch");
|
|
10858
11890
|
if (raw.route !== void 0 || raw.approvedMs !== void 0) return fail("lifetime-mismatch");
|
|
10859
11891
|
return null;
|
|
10860
11892
|
}
|
|
11893
|
+
if (kind === "standing") {
|
|
11894
|
+
const days = record.standing.limits.expiresDays;
|
|
11895
|
+
const { min, max } = STANDING_LIMITS.expiresDays;
|
|
11896
|
+
if (raw.route !== "confirm" || raw.pendingMs !== APPROVAL_LIFETIMES.confirm) return fail("lifetime-mismatch");
|
|
11897
|
+
if (!Number.isSafeInteger(days) || days < min || days > max) return fail("lifetime-mismatch");
|
|
11898
|
+
if (raw.approvedMs !== days * 864e5) return fail("lifetime-mismatch");
|
|
11899
|
+
if (raw.policy !== "confirm" || raw.requiredPolicy !== "confirm") return fail("lifetime-mismatch");
|
|
11900
|
+
return null;
|
|
11901
|
+
}
|
|
11902
|
+
if (isChild(raw)) {
|
|
11903
|
+
const lifetime = APPROVAL_LIFETIMES.standingChild;
|
|
11904
|
+
if (raw.pendingMs !== lifetime || raw.approvedMs !== lifetime) return fail("lifetime-mismatch");
|
|
11905
|
+
return null;
|
|
11906
|
+
}
|
|
10861
11907
|
if (raw.route !== "chat" && raw.route !== "confirm") return fail("lifetime-mismatch");
|
|
10862
11908
|
if (raw.pendingMs !== pendingMsOf(raw.route)) return fail("lifetime-mismatch");
|
|
10863
11909
|
if (raw.approvedMs !== APPROVAL_LIFETIMES.approved) return fail("lifetime-mismatch");
|
|
@@ -10907,6 +11953,10 @@ function checkTimestamps(raw, kind, state, times) {
|
|
|
10907
11953
|
if (!(createdAt <= approvedAt && approvedAt < expiresAt)) return fail("timestamp-misordered");
|
|
10908
11954
|
if (usableUntil - approvedAt !== raw.approvedMs) return fail("lifetime-mismatch");
|
|
10909
11955
|
}
|
|
11956
|
+
if (isChild(raw)) {
|
|
11957
|
+
if (approvedAt === void 0) return fail("timestamp-missing");
|
|
11958
|
+
if (approvedAt !== createdAt) return fail("timestamp-misordered");
|
|
11959
|
+
}
|
|
10910
11960
|
const deadline = usableUntil ?? expiresAt;
|
|
10911
11961
|
const since = approvedAt ?? createdAt;
|
|
10912
11962
|
const { sendingAt, sendingHeartbeatAt, usedAt, sentAt, failedAt, revokedAt, expiredAt } = times;
|
|
@@ -10934,7 +11984,9 @@ function checkTimestamps(raw, kind, state, times) {
|
|
|
10934
11984
|
*/
|
|
10935
11985
|
function checkEvidence(record, raw, kind, state) {
|
|
10936
11986
|
const via = raw.approvedVia;
|
|
10937
|
-
if (via !== void 0 && via !== "terminal" && via !== "elicitation") return fail("evidence-invalid");
|
|
11987
|
+
if (via !== void 0 && via !== "terminal" && via !== "elicitation" && via !== "standing") return fail("evidence-invalid");
|
|
11988
|
+
if (via === "standing" !== (isChild(raw) && via !== void 0)) return fail("evidence-invalid");
|
|
11989
|
+
if (raw.approvedChangePolicy !== void 0 && (kind !== "standing" || raw.approvedAt === void 0)) return fail("evidence-contradictory");
|
|
10938
11990
|
if (kind === "download") {
|
|
10939
11991
|
const answer = record.download?.answer;
|
|
10940
11992
|
const answered = via !== void 0 || raw.approvedDigest !== void 0 || answer !== void 0;
|
|
@@ -10952,7 +12004,9 @@ function checkEvidence(record, raw, kind, state) {
|
|
|
10952
12004
|
if (raw.approvedDigest !== void 0) return fail("evidence-contradictory");
|
|
10953
12005
|
if (!(raw.approvedAt !== void 0)) return via !== void 0 || raw.approvedBindingDigest !== void 0 ? fail("evidence-contradictory") : null;
|
|
10954
12006
|
if (via === void 0 || raw.approvedBindingDigest === void 0) return fail("evidence-missing");
|
|
10955
|
-
if (kind === "
|
|
12007
|
+
if (kind === "standing" && raw.approvedChangePolicy !== "chat" && raw.approvedChangePolicy !== "confirm") return fail("evidence-missing");
|
|
12008
|
+
if ((kind === "change" || kind === "standing") && via !== "terminal") return fail("evidence-invalid");
|
|
12009
|
+
if (isChild(raw) && via !== "standing") return fail("evidence-invalid");
|
|
10956
12010
|
if (raw.approvedBindingDigest !== record.bindingDigest) return fail("evidence-contradictory");
|
|
10957
12011
|
return null;
|
|
10958
12012
|
}
|
|
@@ -10972,9 +12026,11 @@ const KINDS = [
|
|
|
10972
12026
|
"download",
|
|
10973
12027
|
"disclosure"
|
|
10974
12028
|
];
|
|
12029
|
+
/** Version 2 alone has standing approvals (design 2026-10-19-standing-approvals): no earlier release wrote one. */
|
|
12030
|
+
const V2_KINDS = [...KINDS, "standing"];
|
|
10975
12031
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10976
|
-
const isString = (value) => typeof value === "string";
|
|
10977
|
-
const isStrings = (value) => Array.isArray(value) && value.every(isString);
|
|
12032
|
+
const isString$1 = (value) => typeof value === "string";
|
|
12033
|
+
const isStrings = (value) => Array.isArray(value) && value.every(isString$1);
|
|
10978
12034
|
const optional = (value, check) => value === void 0 || check(value);
|
|
10979
12035
|
function unreadable(approvalId, reason) {
|
|
10980
12036
|
return {
|
|
@@ -10987,22 +12043,22 @@ function unreadable(approvalId, reason) {
|
|
|
10987
12043
|
};
|
|
10988
12044
|
}
|
|
10989
12045
|
function expectationShaped(value) {
|
|
10990
|
-
return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString(value.subject);
|
|
12046
|
+
return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString$1(value.subject);
|
|
10991
12047
|
}
|
|
10992
12048
|
/**
|
|
10993
12049
|
* The types every record shares, version 1 or 2: anything of the wrong type cannot reach the classifier. Values —
|
|
10994
12050
|
* a timestamp that is a string but not a time, a digest that is a string but not hex — are the validator's.
|
|
10995
12051
|
*/
|
|
10996
12052
|
function commonShape(raw) {
|
|
10997
|
-
if (!isString(raw.inboxId)) return "missing-ownership";
|
|
10998
|
-
if (!isString(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
|
|
12053
|
+
if (!isString$1(raw.inboxId)) return "missing-ownership";
|
|
12054
|
+
if (!isString$1(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
|
|
10999
12055
|
for (const field of [
|
|
11000
12056
|
"createdAt",
|
|
11001
12057
|
"expiresAt",
|
|
11002
12058
|
"updatedAt",
|
|
11003
12059
|
"draftId",
|
|
11004
12060
|
"draftMessageId"
|
|
11005
|
-
]) if (!isString(raw[field])) return "wrong-shape";
|
|
12061
|
+
]) if (!isString$1(raw[field])) return "wrong-shape";
|
|
11006
12062
|
if (!expectationShaped(raw.expect)) return "wrong-shape";
|
|
11007
12063
|
for (const field of [
|
|
11008
12064
|
"inboxSub",
|
|
@@ -11013,17 +12069,23 @@ function commonShape(raw) {
|
|
|
11013
12069
|
"reason",
|
|
11014
12070
|
"policy",
|
|
11015
12071
|
"requiredPolicy"
|
|
11016
|
-
]) if (!optional(raw[field], isString)) return "wrong-shape";
|
|
12072
|
+
]) if (!optional(raw[field], isString$1)) return "wrong-shape";
|
|
11017
12073
|
if (!optional(raw.riskFlags, isStrings)) return "wrong-shape";
|
|
11018
12074
|
if (!optional(raw.challengeAttempts, (value) => typeof value === "number")) return "wrong-shape";
|
|
11019
12075
|
const kind = raw.kind ?? "send";
|
|
11020
12076
|
if (kind === "change") {
|
|
11021
12077
|
const change = raw.change;
|
|
11022
|
-
if (!isObject(change) || !isString(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
|
|
12078
|
+
if (!isObject(change) || !isString$1(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
|
|
12079
|
+
}
|
|
12080
|
+
if (kind === "standing") {
|
|
12081
|
+
const standing = raw.standing;
|
|
12082
|
+
if (!isObject(standing) || !isString$1(standing.summary) || !isObject(standing.target) || !isString$1(standing.target.id) || !Array.isArray(standing.contents) || !standing.contents.every((content) => isObject(content) && isString$1(content.kind) && isString$1(content.text) && isString$1(content.digest)) || !isObject(standing.places) || !isString$1(standing.places.kind) || !isObject(standing.limits) || !isString$1(standing.proposedBy) || !optional(standing.replaces, isString$1) || !isStrings(standing.warnings)) return "wrong-shape";
|
|
12083
|
+
const narrowed = raw.narrowed;
|
|
12084
|
+
if (!optional(narrowed, (value) => isObject(value) && optional(value.keep, isStrings) && optional(value.places, isStrings) && optional(value.limits, isObject))) return "wrong-shape";
|
|
11023
12085
|
}
|
|
11024
12086
|
if (kind === "download") {
|
|
11025
12087
|
const download = raw.download;
|
|
11026
|
-
if (!isObject(download) || !isString(download.summary) || !isObject(download.target) || !isString(download.target.id) || !isStrings(download.files) || !isStrings(download.names) || !isObject(download.folders) || !isString(download.folders.downloads) || !isString(download.folders.current) || !optional(download.listing, (listing) => Array.isArray(listing) && listing.every((file) => isObject(file)))) return "wrong-shape";
|
|
12088
|
+
if (!isObject(download) || !isString$1(download.summary) || !isObject(download.target) || !isString$1(download.target.id) || !isStrings(download.files) || !isStrings(download.names) || !isObject(download.folders) || !isString$1(download.folders.downloads) || !isString$1(download.folders.current) || !optional(download.listing, (listing) => Array.isArray(listing) && listing.every((file) => isObject(file)))) return "wrong-shape";
|
|
11027
12089
|
}
|
|
11028
12090
|
return null;
|
|
11029
12091
|
}
|
|
@@ -11036,7 +12098,7 @@ function disclosureShape(raw) {
|
|
|
11036
12098
|
"expiresAt",
|
|
11037
12099
|
"updatedAt",
|
|
11038
12100
|
"bindingDigest"
|
|
11039
|
-
]) if (!isString(raw[field])) return "wrong-shape";
|
|
12101
|
+
]) if (!isString$1(raw[field])) return "wrong-shape";
|
|
11040
12102
|
for (const field of [
|
|
11041
12103
|
"pendingMs",
|
|
11042
12104
|
"approvedMs",
|
|
@@ -11052,9 +12114,9 @@ function disclosureShape(raw) {
|
|
|
11052
12114
|
"revokedAt",
|
|
11053
12115
|
"expiredAt",
|
|
11054
12116
|
"reason"
|
|
11055
|
-
]) if (!optional(raw[field], isString)) return "wrong-shape";
|
|
12117
|
+
]) if (!optional(raw[field], isString$1)) return "wrong-shape";
|
|
11056
12118
|
const disclosure = raw.disclosure;
|
|
11057
|
-
if (!isObject(disclosure) || !isString(disclosure.digest) || !isString(disclosure.activationIntentId) || !isString(disclosure.activationKind) || !Array.isArray(disclosure.versions) || !disclosure.versions.every((entry) => isObject(entry) && isString(entry.kind) && isString(entry.id) && typeof entry.version === "number")) return "wrong-shape";
|
|
12119
|
+
if (!isObject(disclosure) || !isString$1(disclosure.digest) || !isString$1(disclosure.activationIntentId) || !isString$1(disclosure.activationKind) || !Array.isArray(disclosure.versions) || !disclosure.versions.every((entry) => isObject(entry) && isString$1(entry.kind) && isString$1(entry.id) && typeof entry.version === "number")) return "wrong-shape";
|
|
11058
12120
|
for (const field of [
|
|
11059
12121
|
"channel",
|
|
11060
12122
|
"ownerScope",
|
|
@@ -11069,6 +12131,8 @@ function disclosureShape(raw) {
|
|
|
11069
12131
|
"requiredPolicy",
|
|
11070
12132
|
"riskFlags",
|
|
11071
12133
|
"approvedBindingDigest",
|
|
12134
|
+
"approvedChangePolicy",
|
|
12135
|
+
"standingUse",
|
|
11072
12136
|
"sendingAt",
|
|
11073
12137
|
"sendingHeartbeatAt",
|
|
11074
12138
|
"sentAt",
|
|
@@ -11086,16 +12150,20 @@ function v2Shape(raw) {
|
|
|
11086
12150
|
"channel",
|
|
11087
12151
|
"ownerScope",
|
|
11088
12152
|
"contentDigest"
|
|
11089
|
-
]) if (!optional(raw[field], isString)) return "wrong-shape";
|
|
12153
|
+
]) if (!optional(raw[field], isString$1)) return "wrong-shape";
|
|
11090
12154
|
for (const field of [
|
|
11091
12155
|
"pendingMs",
|
|
11092
12156
|
"approvedMs",
|
|
11093
|
-
"sendEpoch"
|
|
12157
|
+
"sendEpoch",
|
|
12158
|
+
"standingEpoch"
|
|
11094
12159
|
]) if (!optional(raw[field], (value) => typeof value === "number")) return "wrong-shape";
|
|
12160
|
+
if (!optional(raw.standingId, isString$1)) return "wrong-shape";
|
|
12161
|
+
if (!optional(raw.standingUse, isObject)) return "wrong-shape";
|
|
11095
12162
|
for (const field of [
|
|
11096
12163
|
"route",
|
|
11097
12164
|
"bindingDigest",
|
|
11098
12165
|
"approvedBindingDigest",
|
|
12166
|
+
"approvedChangePolicy",
|
|
11099
12167
|
"approvedAt",
|
|
11100
12168
|
"usableUntil",
|
|
11101
12169
|
"sendingAt",
|
|
@@ -11105,7 +12173,7 @@ function v2Shape(raw) {
|
|
|
11105
12173
|
"failedAt",
|
|
11106
12174
|
"revokedAt",
|
|
11107
12175
|
"expiredAt"
|
|
11108
|
-
]) if (!optional(raw[field], isString)) return "wrong-shape";
|
|
12176
|
+
]) if (!optional(raw[field], isString$1)) return "wrong-shape";
|
|
11109
12177
|
return null;
|
|
11110
12178
|
}
|
|
11111
12179
|
/**
|
|
@@ -11121,7 +12189,7 @@ function decodeStored(fileId, text, config, now) {
|
|
|
11121
12189
|
return unreadable(fileId, trimmed === "" || trimmed.startsWith("{") && !trimmed.endsWith("}") ? "truncated" : "invalid-json");
|
|
11122
12190
|
}
|
|
11123
12191
|
if (!isObject(raw)) return unreadable(fileId, "wrong-shape");
|
|
11124
|
-
if (!isString(raw.approvalId)) return unreadable(fileId, "wrong-shape");
|
|
12192
|
+
if (!isString$1(raw.approvalId)) return unreadable(fileId, "wrong-shape");
|
|
11125
12193
|
if (raw.kind === "disclosure") {
|
|
11126
12194
|
if (raw.digestVersion === 1) return unreadable(fileId, "wrong-shape");
|
|
11127
12195
|
const shape = disclosureShape(raw);
|
|
@@ -11143,8 +12211,8 @@ function decodeStored(fileId, text, config, now) {
|
|
|
11143
12211
|
const shape = commonShape(raw);
|
|
11144
12212
|
if (shape !== null) return unreadable(fileId, shape);
|
|
11145
12213
|
if (raw.digestVersion === 1) {
|
|
11146
|
-
if (!optional(raw.kind, (kind) => isString(kind) && KINDS.includes(kind))) return unreadable(fileId, "wrong-shape");
|
|
11147
|
-
if (!isString(raw.digest)) return unreadable(fileId, "wrong-shape");
|
|
12214
|
+
if (!optional(raw.kind, (kind) => isString$1(kind) && KINDS.includes(kind))) return unreadable(fileId, "wrong-shape");
|
|
12215
|
+
if (!isString$1(raw.digest)) return unreadable(fileId, "wrong-shape");
|
|
11148
12216
|
if (raw.approvalId !== fileId) return unreadable(fileId, "file-name-mismatch");
|
|
11149
12217
|
const record = raw;
|
|
11150
12218
|
return {
|
|
@@ -11153,7 +12221,7 @@ function decodeStored(fileId, text, config, now) {
|
|
|
11153
12221
|
record
|
|
11154
12222
|
};
|
|
11155
12223
|
}
|
|
11156
|
-
if (!isString(raw.kind) || !
|
|
12224
|
+
if (!isString$1(raw.kind) || !V2_KINDS.includes(raw.kind)) return unreadable(fileId, "missing-kind");
|
|
11157
12225
|
const v2 = v2Shape(raw);
|
|
11158
12226
|
if (v2 !== null) return unreadable(fileId, v2);
|
|
11159
12227
|
const record = raw;
|
|
@@ -11277,9 +12345,9 @@ const NEVER = Object.freeze({
|
|
|
11277
12345
|
cursor: null
|
|
11278
12346
|
});
|
|
11279
12347
|
/** Exactly what `Date.prototype.toISOString` writes. */
|
|
11280
|
-
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
12348
|
+
const ISO$1 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
11281
12349
|
function isoTime(value) {
|
|
11282
|
-
if (typeof value !== "string" || !ISO.test(value)) return null;
|
|
12350
|
+
if (typeof value !== "string" || !ISO$1.test(value)) return null;
|
|
11283
12351
|
const at = Date.parse(value);
|
|
11284
12352
|
return Number.isFinite(at) && new Date(at).toISOString() === value ? at : null;
|
|
11285
12353
|
}
|
|
@@ -11904,7 +12972,7 @@ function folding(platform) {
|
|
|
11904
12972
|
return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
|
|
11905
12973
|
}
|
|
11906
12974
|
/** The package version, read from package.json at build time. */
|
|
11907
|
-
const VERSION = "0.
|
|
12975
|
+
const VERSION = "0.23.0";
|
|
11908
12976
|
/**
|
|
11909
12977
|
* The command a person is told to run for one of this suite's own CLIs (design 2026-10-04, D1 and D5; CUE-403).
|
|
11910
12978
|
*
|
|
@@ -12079,8 +13147,8 @@ function locateCliCommand(request, runtime = {
|
|
|
12079
13147
|
const theirs = typeof manifest.version === "string" ? manifest.version : "of no version";
|
|
12080
13148
|
return fail("version", `${caller.packageName} ${version} finds ${target.packageName} ${theirs}, at ${installed}`);
|
|
12081
13149
|
}
|
|
12082
|
-
const callerWorkspace = workspaceOf(callerRoot);
|
|
12083
|
-
const targetWorkspace = workspaceOf(installed);
|
|
13150
|
+
const callerWorkspace = workspaceOf$1(callerRoot);
|
|
13151
|
+
const targetWorkspace = workspaceOf$1(installed);
|
|
12084
13152
|
const sameCheckout = callerWorkspace !== null && callerWorkspace === targetWorkspace;
|
|
12085
13153
|
const found = {
|
|
12086
13154
|
root: installed,
|
|
@@ -12490,7 +13558,7 @@ function installedPackage(start, name) {
|
|
|
12490
13558
|
* `pnpm-workspace.yaml` above it, and its `packages` globs. A package under the checkout but not one of them — an
|
|
12491
13559
|
* installed copy in its `node_modules` — is not a workspace package of it.
|
|
12492
13560
|
*/
|
|
12493
|
-
function workspaceOf(root) {
|
|
13561
|
+
function workspaceOf$1(root) {
|
|
12494
13562
|
let dir = dirname(root);
|
|
12495
13563
|
for (;;) {
|
|
12496
13564
|
const globs = workspaceGlobs(dir);
|
|
@@ -12563,7 +13631,7 @@ Object.freeze({
|
|
|
12563
13631
|
packageName: "@agentcomms/core"
|
|
12564
13632
|
});
|
|
12565
13633
|
/** A channel of this release, by its word; a programming error for a word that is none. */
|
|
12566
|
-
function entryOf(channel) {
|
|
13634
|
+
function entryOf$1(channel) {
|
|
12567
13635
|
const entry = CHANNEL_SNAPSHOT.find((each) => each.manifest.channel === channel);
|
|
12568
13636
|
if (entry === void 0) throw new TypeError(`"${channel}" is not a channel of this release`);
|
|
12569
13637
|
return entry;
|
|
@@ -12595,8 +13663,8 @@ function cliHandoffs(options) {
|
|
|
12595
13663
|
caller,
|
|
12596
13664
|
platform,
|
|
12597
13665
|
own: (words, use) => locate(own, words, use),
|
|
12598
|
-
core: (words, use) => locate(entryOf("core"), words, use),
|
|
12599
|
-
of: (channel, words, use) => locate(entryOf(channel), words, use),
|
|
13666
|
+
core: (words, use) => locate(entryOf$1("core"), words, use),
|
|
13667
|
+
of: (channel, words, use) => locate(entryOf$1(channel), words, use),
|
|
12600
13668
|
on: (other) => other === platform ? handoffs : cliHandoffs({
|
|
12601
13669
|
...options,
|
|
12602
13670
|
platform: other
|
|
@@ -13490,7 +14558,7 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
13490
14558
|
if ((await approveChangeAtTerminal(core, prepared.approvalId, options.env, options.output, streams)).state !== "approved") throw cancelled();
|
|
13491
14559
|
} else if (options.answered !== true) {
|
|
13492
14560
|
streams.stdout.write(`${prepared.preview}\n\n`);
|
|
13493
|
-
if ((await askLine(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
|
|
14561
|
+
if ((await askLine$1(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
|
|
13494
14562
|
await revokeChange(core, prepared.approvalId, "cancelled at the terminal", {
|
|
13495
14563
|
surface: "cli",
|
|
13496
14564
|
disposition: "person"
|
|
@@ -13555,7 +14623,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
13555
14623
|
}
|
|
13556
14624
|
const prompt = await beginChangeApproval(core, approvalId, { surface: "cli" });
|
|
13557
14625
|
streams.stdout.write(`${prompt.preview}\n\n`);
|
|
13558
|
-
const answer = await askLine(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
|
|
14626
|
+
const answer = await askLine$1(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
|
|
13559
14627
|
if (!answer.trim()) {
|
|
13560
14628
|
await revokeChange(core, approvalId, "cancelled at the terminal", {
|
|
13561
14629
|
surface: "cli",
|
|
@@ -13575,7 +14643,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
13575
14643
|
function cancelled() {
|
|
13576
14644
|
return new CommsError("USAGE", "cancelled: nothing was changed");
|
|
13577
14645
|
}
|
|
13578
|
-
async function askLine(streams, question) {
|
|
14646
|
+
async function askLine$1(streams, question) {
|
|
13579
14647
|
const { createInterface } = await import("node:readline/promises");
|
|
13580
14648
|
const rl = createInterface({
|
|
13581
14649
|
input: streams.stdin,
|
|
@@ -13910,6 +14978,186 @@ async function openSecretStore(kind, options) {
|
|
|
13910
14978
|
if (!keyring) throw new CommsError("SECRET_STORE_UNAVAILABLE", "secrets are kept in the system keychain, but the keychain module is missing", { hint: "Reinstall with optional dependencies, or re-add the inbox with --store file." });
|
|
13911
14979
|
return new KeychainSecretStore(keyring, options.namespace, KEYCHAIN_TIMEOUT_MS, told);
|
|
13912
14980
|
}
|
|
14981
|
+
/** Exactly what `Date.prototype.toISOString` writes. */
|
|
14982
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
14983
|
+
const OUTCOMES = [
|
|
14984
|
+
"sent",
|
|
14985
|
+
"failed",
|
|
14986
|
+
"unknown"
|
|
14987
|
+
];
|
|
14988
|
+
const isString = (value) => typeof value === "string";
|
|
14989
|
+
const isTime$2 = (value) => isString(value) && ISO.test(value) && !Number.isNaN(Date.parse(value));
|
|
14990
|
+
/** One line as an entry, or null when it is not one this release wrote. */
|
|
14991
|
+
function entryOf(line) {
|
|
14992
|
+
let raw;
|
|
14993
|
+
try {
|
|
14994
|
+
raw = JSON.parse(line);
|
|
14995
|
+
} catch {
|
|
14996
|
+
return null;
|
|
14997
|
+
}
|
|
14998
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
14999
|
+
const entry = raw;
|
|
15000
|
+
if (!isTime$2(entry.at)) return null;
|
|
15001
|
+
if (entry.kind === "use") {
|
|
15002
|
+
if (![
|
|
15003
|
+
"standingId",
|
|
15004
|
+
"childId",
|
|
15005
|
+
"contentDigest",
|
|
15006
|
+
"channel",
|
|
15007
|
+
"ts",
|
|
15008
|
+
"thread",
|
|
15009
|
+
"sender",
|
|
15010
|
+
"targetDigest"
|
|
15011
|
+
].every((field) => isString(entry[field]) && entry[field] !== "")) return null;
|
|
15012
|
+
if (entry.act !== "reaction" && entry.act !== "reply") return null;
|
|
15013
|
+
if (typeof entry.edited !== "boolean") return null;
|
|
15014
|
+
return {
|
|
15015
|
+
kind: "use",
|
|
15016
|
+
at: entry.at,
|
|
15017
|
+
standingId: entry.standingId,
|
|
15018
|
+
childId: entry.childId,
|
|
15019
|
+
act: entry.act,
|
|
15020
|
+
contentDigest: entry.contentDigest,
|
|
15021
|
+
channel: entry.channel,
|
|
15022
|
+
ts: entry.ts,
|
|
15023
|
+
thread: entry.thread,
|
|
15024
|
+
sender: entry.sender,
|
|
15025
|
+
targetDigest: entry.targetDigest,
|
|
15026
|
+
edited: entry.edited
|
|
15027
|
+
};
|
|
15028
|
+
}
|
|
15029
|
+
if (entry.kind === "outcome") {
|
|
15030
|
+
if (!isString(entry.childId) || !isString(entry.outcome) || !OUTCOMES.includes(entry.outcome)) return null;
|
|
15031
|
+
if (!isString(entry.notice) || entry.link !== void 0 && !isString(entry.link)) return null;
|
|
15032
|
+
if (entry.actAt !== void 0 && !isTime$2(entry.actAt)) return null;
|
|
15033
|
+
return {
|
|
15034
|
+
kind: "outcome",
|
|
15035
|
+
at: entry.at,
|
|
15036
|
+
...entry.actAt === void 0 ? {} : { actAt: entry.actAt },
|
|
15037
|
+
childId: entry.childId,
|
|
15038
|
+
outcome: entry.outcome,
|
|
15039
|
+
...entry.link === void 0 ? {} : { link: entry.link },
|
|
15040
|
+
notice: entry.notice
|
|
15041
|
+
};
|
|
15042
|
+
}
|
|
15043
|
+
return null;
|
|
15044
|
+
}
|
|
15045
|
+
var AckLedger = class {
|
|
15046
|
+
directory;
|
|
15047
|
+
#now;
|
|
15048
|
+
constructor(stateDir, now = () => /* @__PURE__ */ new Date()) {
|
|
15049
|
+
this.directory = join(stateDir, "standing");
|
|
15050
|
+
this.#now = now;
|
|
15051
|
+
}
|
|
15052
|
+
/** The clock this ledger was opened with: what a use is timed by, and its counts' windows measured from. */
|
|
15053
|
+
now() {
|
|
15054
|
+
return this.#now();
|
|
15055
|
+
}
|
|
15056
|
+
/** The account's ledger file. Refuses anything but an account id, so no name can reach another path. */
|
|
15057
|
+
#path(accountId) {
|
|
15058
|
+
if (typeof accountId !== "string" || !ACCOUNT_ID_PATTERN.test(accountId)) throw new CommsError("USAGE", `"${String(accountId)}" is not an account id`, { hint: "A standing approval’s ledger is kept per account, by the account’s id." });
|
|
15059
|
+
return join(this.directory, `${accountId}.jsonl`);
|
|
15060
|
+
}
|
|
15061
|
+
/** Every line, read: the entries, and the number — from 1 — of each line this release cannot read. */
|
|
15062
|
+
async #read(path) {
|
|
15063
|
+
let text = "";
|
|
15064
|
+
try {
|
|
15065
|
+
text = await readFile(path, "utf8");
|
|
15066
|
+
} catch (error) {
|
|
15067
|
+
if (error.code !== "ENOENT") throw error;
|
|
15068
|
+
}
|
|
15069
|
+
const entries = [];
|
|
15070
|
+
const unreadable = [];
|
|
15071
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
15072
|
+
if (line.trim() === "") continue;
|
|
15073
|
+
const entry = entryOf(line);
|
|
15074
|
+
if (entry === null) unreadable.push(index + 1);
|
|
15075
|
+
else entries.push(entry);
|
|
15076
|
+
}
|
|
15077
|
+
return {
|
|
15078
|
+
entries,
|
|
15079
|
+
unreadable
|
|
15080
|
+
};
|
|
15081
|
+
}
|
|
15082
|
+
/** Where an account's ledger is kept: `<state>/standing/<accountId>.jsonl`, for a person told to move it aside. */
|
|
15083
|
+
pathOf(accountId) {
|
|
15084
|
+
return this.#path(accountId);
|
|
15085
|
+
}
|
|
15086
|
+
/**
|
|
15087
|
+
* Every account's ledger on this machine that holds a line this release cannot read, with the number of each such
|
|
15088
|
+
* line, for the doctor (design 2026-10-19-standing-approvals, the lead's call after review): while one does, no
|
|
15089
|
+
* standing approval of that account can be used — what it counts cannot be known — and nothing else says so. Reads
|
|
15090
|
+
* without the lock: a look, and every line it reads was written whole.
|
|
15091
|
+
*/
|
|
15092
|
+
async unreadable() {
|
|
15093
|
+
let names;
|
|
15094
|
+
try {
|
|
15095
|
+
names = await readdir(this.directory);
|
|
15096
|
+
} catch (error) {
|
|
15097
|
+
if (error.code === "ENOENT") return [];
|
|
15098
|
+
throw error;
|
|
15099
|
+
}
|
|
15100
|
+
const found = [];
|
|
15101
|
+
for (const name of names.sort()) {
|
|
15102
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
15103
|
+
const accountId = name.slice(0, -6);
|
|
15104
|
+
if (!ACCOUNT_ID_PATTERN.test(accountId)) continue;
|
|
15105
|
+
const path = this.#path(accountId);
|
|
15106
|
+
const { unreadable } = await this.#read(path);
|
|
15107
|
+
if (unreadable.length > 0) found.push({
|
|
15108
|
+
accountId,
|
|
15109
|
+
path,
|
|
15110
|
+
lines: unreadable
|
|
15111
|
+
});
|
|
15112
|
+
}
|
|
15113
|
+
return found;
|
|
15114
|
+
}
|
|
15115
|
+
/**
|
|
15116
|
+
* Runs `fn` under the account's ledger lock, with every entry as it reads there and `append`, which writes a `use`
|
|
15117
|
+
* line under the lock already held. `fn` must never take this lock again.
|
|
15118
|
+
*
|
|
15119
|
+
* A line this release cannot read refuses the whole call, BAD_DATA, before `fn` runs: a count read without it could
|
|
15120
|
+
* be lower than the truth, and a count is never guessed downwards.
|
|
15121
|
+
*/
|
|
15122
|
+
withLock(accountId, fn) {
|
|
15123
|
+
let path;
|
|
15124
|
+
try {
|
|
15125
|
+
path = this.#path(accountId);
|
|
15126
|
+
} catch (error) {
|
|
15127
|
+
return Promise.reject(error);
|
|
15128
|
+
}
|
|
15129
|
+
return withFileLock(`${path}.lock`, async () => {
|
|
15130
|
+
const { entries, unreadable } = await this.#read(path);
|
|
15131
|
+
if (unreadable.length > 0) throw new CommsError("BAD_DATA", "nothing was sent: this account’s standing acknowledgement ledger has a line that cannot be read", {
|
|
15132
|
+
hint: "No standing approval of this account can be used until the ledger reads whole: what it counts cannot be known. Ask the person instead; the doctor says which line, and how to recover.",
|
|
15133
|
+
details: {
|
|
15134
|
+
unreadable: unreadable.length,
|
|
15135
|
+
lines: unreadable
|
|
15136
|
+
}
|
|
15137
|
+
});
|
|
15138
|
+
const append = async (entry) => {
|
|
15139
|
+
if (entry.kind !== "use" || entryOf(JSON.stringify(entry)) === null) throw new CommsError("UNEXPECTED", "a standing use is written whole, or not at all", { hint: "This is a bug — please report it." });
|
|
15140
|
+
await appendPrivateLine(path, JSON.stringify(entry), { durable: true });
|
|
15141
|
+
};
|
|
15142
|
+
return fn(entries, append);
|
|
15143
|
+
});
|
|
15144
|
+
}
|
|
15145
|
+
/** Records what became of a use's act, taking the lock itself. It never frees a count. */
|
|
15146
|
+
async recordOutcome(accountId, entry) {
|
|
15147
|
+
const path = this.#path(accountId);
|
|
15148
|
+
const written = {
|
|
15149
|
+
...entry,
|
|
15150
|
+
kind: "outcome",
|
|
15151
|
+
at: entry.at ?? this.#now().toISOString()
|
|
15152
|
+
};
|
|
15153
|
+
if (entryOf(JSON.stringify(written)) === null) throw new CommsError("UNEXPECTED", "a standing outcome is written whole, or not at all", { hint: "This is a bug — please report it." });
|
|
15154
|
+
await withFileLock(`${path}.lock`, () => appendPrivateLine(path, JSON.stringify(written), { durable: true }));
|
|
15155
|
+
}
|
|
15156
|
+
/** Every entry this release can read, oldest first, for a report: a line it cannot read is left out of it. */
|
|
15157
|
+
async entries(accountId) {
|
|
15158
|
+
return (await this.#read(this.#path(accountId))).entries;
|
|
15159
|
+
}
|
|
15160
|
+
};
|
|
13913
15161
|
var InboxStateStore = class {
|
|
13914
15162
|
directory;
|
|
13915
15163
|
constructor(stateDir) {
|
|
@@ -14744,6 +15992,7 @@ function openCore(options = {}) {
|
|
|
14744
15992
|
answered: (approvalId) => securityAnswered(paths.stateDir, approvalId)
|
|
14745
15993
|
}),
|
|
14746
15994
|
ledger: new SendLedger(paths.stateDir, now),
|
|
15995
|
+
acks: new AckLedger(paths.stateDir, now),
|
|
14747
15996
|
plans: new PlanStore(paths.stateDir, now),
|
|
14748
15997
|
taint: new TaintStore(paths.stateDir, now),
|
|
14749
15998
|
audit,
|
|
@@ -14811,9 +16060,26 @@ function stillWaiting(stored, outcome) {
|
|
|
14811
16060
|
if (outcome.state === "sending") return true;
|
|
14812
16061
|
return outcome.state === "pending" && !outcome.claimable;
|
|
14813
16062
|
}
|
|
16063
|
+
/**
|
|
16064
|
+
* A standing approval's own state, as `standing list` reads it: `never` on its account ends it at a look, before any
|
|
16065
|
+
* use writes that down. Undefined for every other kind.
|
|
16066
|
+
*/
|
|
16067
|
+
function standingStateOf(outcome) {
|
|
16068
|
+
if (outcome.record?.kind !== "standing") return void 0;
|
|
16069
|
+
const never = outcome.reason === NEVER_NOTICE;
|
|
16070
|
+
if (outcome.state === "pending" && !never) return { state: "pending" };
|
|
16071
|
+
if (outcome.state === "approved" && !never) return { state: "active" };
|
|
16072
|
+
const reason = outcome.reason ?? outcome.record.reason;
|
|
16073
|
+
return {
|
|
16074
|
+
state: "ended",
|
|
16075
|
+
...reason === void 0 ? {} : { reason }
|
|
16076
|
+
};
|
|
16077
|
+
}
|
|
14814
16078
|
/** Why a wait that has stopped waiting ended, from the classification it stopped on. */
|
|
14815
|
-
function endOf(outcome) {
|
|
16079
|
+
function endOf$1(outcome) {
|
|
14816
16080
|
if (outcome.claimable) return "claimable";
|
|
16081
|
+
const standing = standingStateOf(outcome);
|
|
16082
|
+
if (standing !== void 0) return standing.state === "active" ? "active" : "final";
|
|
14817
16083
|
if (outcome.record !== null && outcome.state === "approved") return "blocked";
|
|
14818
16084
|
return "final";
|
|
14819
16085
|
}
|
|
@@ -14868,6 +16134,7 @@ async function waitHeld(core, approvalId, waitSeconds, options) {
|
|
|
14868
16134
|
ended,
|
|
14869
16135
|
waitedSeconds
|
|
14870
16136
|
};
|
|
16137
|
+
const standing = standingStateOf(outcome);
|
|
14871
16138
|
return {
|
|
14872
16139
|
approvalId,
|
|
14873
16140
|
state: outcome.state,
|
|
@@ -14875,13 +16142,14 @@ async function waitHeld(core, approvalId, waitSeconds, options) {
|
|
|
14875
16142
|
approval,
|
|
14876
16143
|
ended,
|
|
14877
16144
|
waitedSeconds,
|
|
14878
|
-
...ended === "timeout" ? { hint: waitAgainHint(outcome.state, options.channel) } : {}
|
|
16145
|
+
...ended === "timeout" ? { hint: waitAgainHint(outcome.state, options.channel) } : {},
|
|
16146
|
+
...standing === void 0 ? {} : { standing }
|
|
14879
16147
|
};
|
|
14880
16148
|
};
|
|
14881
16149
|
if (waitSeconds === 0) return finish("now", seen.outcome);
|
|
14882
16150
|
let lastProgress = started;
|
|
14883
16151
|
for (;;) {
|
|
14884
|
-
if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf(seen.outcome), seen.outcome);
|
|
16152
|
+
if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf$1(seen.outcome), seen.outcome);
|
|
14885
16153
|
if (options.signal?.aborted) return cancelled();
|
|
14886
16154
|
if (clock.now() >= deadline) return finish("timeout", seen.outcome);
|
|
14887
16155
|
const next = lastLook + WAIT_LOOK_MS;
|
|
@@ -14909,7 +16177,7 @@ async function waitHeld(core, approvalId, waitSeconds, options) {
|
|
|
14909
16177
|
lastLook = clock.now();
|
|
14910
16178
|
seen = await core.approvals.inspect(approvalId, expect, { action: "wait" });
|
|
14911
16179
|
}
|
|
14912
|
-
if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf(seen.outcome), seen.outcome);
|
|
16180
|
+
if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf$1(seen.outcome), seen.outcome);
|
|
14913
16181
|
return finish("cancelled", seen.outcome);
|
|
14914
16182
|
}
|
|
14915
16183
|
}
|
|
@@ -15891,7 +17159,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
15891
17159
|
};
|
|
15892
17160
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
15893
17161
|
packageName: serverFacts(request.channel).packageName,
|
|
15894
|
-
version: "0.
|
|
17162
|
+
version: "0.23.0"
|
|
15895
17163
|
};
|
|
15896
17164
|
const product = {
|
|
15897
17165
|
packageName,
|
|
@@ -16039,6 +17307,12 @@ function everyServerPruneChange(core, env, request) {
|
|
|
16039
17307
|
}
|
|
16040
17308
|
};
|
|
16041
17309
|
}
|
|
17310
|
+
Object.freeze([
|
|
17311
|
+
"narrow",
|
|
17312
|
+
"revoke",
|
|
17313
|
+
"off"
|
|
17314
|
+
]);
|
|
17315
|
+
Object.keys(STANDING_LIMITS);
|
|
16042
17316
|
/**
|
|
16043
17317
|
* Bringing a machine to the latest release, from a terminal (`agentcomms update`) or a chat (`comms_update`).
|
|
16044
17318
|
*
|
|
@@ -17312,7 +18586,7 @@ function terminalUpdateHooks(core, env, options = {}) {
|
|
|
17312
18586
|
} };
|
|
17313
18587
|
}
|
|
17314
18588
|
//#endregion
|
|
17315
|
-
//#region ../core/dist/tool-arguments-
|
|
18589
|
+
//#region ../core/dist/tool-arguments-C2vEASTA.mjs
|
|
17316
18590
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
17317
18591
|
const TARGET = "draft-2020-12";
|
|
17318
18592
|
/**
|
|
@@ -27628,6 +28902,27 @@ strictObject({
|
|
|
27628
28902
|
if (manifest.skills.contract !== `skills/_shared/contract-${family}.md`) issue(["skills", "contract"], `skills/_shared/contract-${family}.md: a skill's contract is chosen by its prefix`);
|
|
27629
28903
|
}
|
|
27630
28904
|
});
|
|
28905
|
+
/** The environment values the Windows script reads the title and the body from. */
|
|
28906
|
+
const TITLE_VARIABLE = "AGENTCOMMS_NOTICE_TITLE";
|
|
28907
|
+
const BODY_VARIABLE = "AGENTCOMMS_NOTICE_BODY";
|
|
28908
|
+
Object.freeze([
|
|
28909
|
+
"-e",
|
|
28910
|
+
"on run argv",
|
|
28911
|
+
"-e",
|
|
28912
|
+
"display notification (item 1 of argv) with title (item 2 of argv)",
|
|
28913
|
+
"-e",
|
|
28914
|
+
"end run"
|
|
28915
|
+
]);
|
|
28916
|
+
[
|
|
28917
|
+
"$ErrorActionPreference = 'Stop'",
|
|
28918
|
+
"$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
|
|
28919
|
+
"$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
|
|
28920
|
+
"$text = $xml.GetElementsByTagName('text')",
|
|
28921
|
+
`$null = $text.Item(0).AppendChild($xml.CreateTextNode([string]$env:${TITLE_VARIABLE}))`,
|
|
28922
|
+
`$null = $text.Item(1).AppendChild($xml.CreateTextNode([string]$env:${BODY_VARIABLE}))`,
|
|
28923
|
+
"$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
|
|
28924
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)"
|
|
28925
|
+
].join("; ");
|
|
27631
28926
|
/**
|
|
27632
28927
|
* Email HTML is attacker-controlled. Before any of it becomes text a model reads, this removes what a human reading
|
|
27633
28928
|
* the message in a mail client would not see — the channel used by hidden-text prompt injection (e.g. zero-size or
|
|
@@ -29001,6 +30296,10 @@ async function fenceOrStop(store, approvalId, claimToken, progress) {
|
|
|
29001
30296
|
})
|
|
29002
30297
|
};
|
|
29003
30298
|
}
|
|
30299
|
+
Object.freeze({
|
|
30300
|
+
min: 5e3,
|
|
30301
|
+
max: 3e4
|
|
30302
|
+
});
|
|
29004
30303
|
Object.freeze({
|
|
29005
30304
|
complete: "not sent with any approval in the last 90 days",
|
|
29006
30305
|
window: "not sent with any of the 500 most recently changed approval records",
|