@agentcomms/whatsapp 0.22.0 → 0.24.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/{server-Dxpgrvk-.mjs → server-Btcjiqz2.mjs} +1 -1
- package/dist/{stdio-entry-BNtRdCc9.mjs → stdio-entry-pwRwRiwa.mjs} +1 -1
- package/dist/{sync-B8JMK08L.mjs → sync-DYq2CGVj.mjs} +1372 -87
- package/package.json +3 -3
|
@@ -11,7 +11,7 @@ import { execFile, spawn } from "node:child_process";
|
|
|
11
11
|
//#endregion
|
|
12
12
|
//#region src/version.ts
|
|
13
13
|
/** The package version, read from package.json at build time. */
|
|
14
|
-
const VERSION$2 = "0.
|
|
14
|
+
const VERSION$2 = "0.24.0";
|
|
15
15
|
//#endregion
|
|
16
16
|
//#region src/caller.ts
|
|
17
17
|
/** This package's name, as its manifest has it. */
|
|
@@ -28,7 +28,7 @@ const CALLER = Object.freeze({
|
|
|
28
28
|
packageName: PACKAGE_NAME
|
|
29
29
|
});
|
|
30
30
|
//#endregion
|
|
31
|
-
//#region ../core/dist/update-check-
|
|
31
|
+
//#region ../core/dist/update-check-BY5xNUZa.mjs
|
|
32
32
|
/**
|
|
33
33
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
34
34
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
@@ -96,6 +96,16 @@ const ERROR_REGISTRY = {
|
|
|
96
96
|
retryable: false,
|
|
97
97
|
summary: "a behaviour rule does not allow this act here"
|
|
98
98
|
},
|
|
99
|
+
STANDING_REFUSED: {
|
|
100
|
+
exit: EXIT_CODES.APPROVAL,
|
|
101
|
+
retryable: false,
|
|
102
|
+
summary: "this standing approval does not cover this act"
|
|
103
|
+
},
|
|
104
|
+
STANDING_LIMIT: {
|
|
105
|
+
exit: EXIT_CODES.APPROVAL,
|
|
106
|
+
retryable: false,
|
|
107
|
+
summary: "a standing approval's limit is reached"
|
|
108
|
+
},
|
|
99
109
|
RATE_CAPPED: {
|
|
100
110
|
exit: EXIT_CODES.APPROVAL,
|
|
101
111
|
retryable: true,
|
|
@@ -1019,6 +1029,9 @@ function nameProblem(name) {
|
|
|
1019
1029
|
if (FORBIDDEN_NAME_CHARACTERS.test(name)) return "the name holds a parenthesis or one of <>\"@,;:\\";
|
|
1020
1030
|
for (const char of name) if (isDangerous(char.codePointAt(0) ?? 0)) return "the name holds a hidden or control character";
|
|
1021
1031
|
}
|
|
1032
|
+
/** `<name> (<word>)`: a name holds no parenthesis or line break (`nameProblem`), so the first `(` is the word's. */
|
|
1033
|
+
const LABEL_PATTERN = `[^()\\n]{1,160} \\((?:${AGENT_LABEL_WORDS.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\)`;
|
|
1034
|
+
new RegExp(`^\\s*(?:${LABEL_PATTERN}: |Sent by ${LABEL_PATTERN}(?:$|\\s))`, "iu");
|
|
1022
1035
|
const ruleKey = (rule) => JSON.stringify([
|
|
1023
1036
|
rule.account,
|
|
1024
1037
|
rule.accountId ?? null,
|
|
@@ -1428,12 +1441,17 @@ function sameDisclosureBinding(left, right) {
|
|
|
1428
1441
|
return false;
|
|
1429
1442
|
}
|
|
1430
1443
|
}
|
|
1431
|
-
/**
|
|
1444
|
+
/**
|
|
1445
|
+
* The lifetime profile, in milliseconds: pending on each route, approved, a download's question, and a standing
|
|
1446
|
+
* approval's child — usable once, within two minutes of the use that minted it. A standing record's own approved
|
|
1447
|
+
* lifetime is its days (`approvedMs`, checked against its limits), not one of these.
|
|
1448
|
+
*/
|
|
1432
1449
|
const APPROVAL_LIFETIMES = Object.freeze({
|
|
1433
1450
|
chat: 6e5,
|
|
1434
1451
|
confirm: 18e5,
|
|
1435
1452
|
approved: 864e5,
|
|
1436
|
-
download: 18e5
|
|
1453
|
+
download: 18e5,
|
|
1454
|
+
standingChild: 12e4
|
|
1437
1455
|
});
|
|
1438
1456
|
/**
|
|
1439
1457
|
* The sending lease (design 2026-10-05 §D1): a `sending` record whose claimant has not renewed it for this long reads
|
|
@@ -1452,8 +1470,9 @@ function unknownAtOf(record) {
|
|
|
1452
1470
|
const at = Date.parse(renewed);
|
|
1453
1471
|
return Number.isFinite(at) ? new Date(at + SENDING_LEASE_MS).toISOString() : void 0;
|
|
1454
1472
|
}
|
|
1455
|
-
/** How long a pending send or change on `route` stays open. */
|
|
1473
|
+
/** How long a pending send or change on `route` stays open; a standing child is never pending, and lasts its own. */
|
|
1456
1474
|
function pendingMsOf(route) {
|
|
1475
|
+
if (route === "standing") return APPROVAL_LIFETIMES.standingChild;
|
|
1457
1476
|
return route === "confirm" ? APPROVAL_LIFETIMES.confirm : APPROVAL_LIFETIMES.chat;
|
|
1458
1477
|
}
|
|
1459
1478
|
/** Whether `channel` is one this release knows: a manifest channel of the generated snapshot. */
|
|
@@ -1464,9 +1483,20 @@ function isKnownChannel(channel) {
|
|
|
1464
1483
|
function requireKnownChannel(channel) {
|
|
1465
1484
|
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." });
|
|
1466
1485
|
}
|
|
1486
|
+
/** Why `use` is not a child's use of its standing approval, or null: three strings, its content a SHA-256. */
|
|
1487
|
+
function standingUseProblem(use) {
|
|
1488
|
+
if (typeof use !== "object" || use === null || Array.isArray(use)) return "it names no use";
|
|
1489
|
+
const fields = use;
|
|
1490
|
+
if (Object.keys(fields).some((key) => key !== "channel" && key !== "thread" && key !== "content")) return "it names more than its use";
|
|
1491
|
+
for (const field of ["channel", "thread"]) if (typeof fields[field] !== "string" || fields[field] === "") return `its use has no ${field}`;
|
|
1492
|
+
if (typeof fields.content !== "string" || !/^[0-9a-f]{64}$/.test(fields.content)) return "its use names no content";
|
|
1493
|
+
return null;
|
|
1494
|
+
}
|
|
1467
1495
|
/**
|
|
1468
1496
|
* The identity a record is bound to, exactly as stored. An absent `inboxSub` is left out rather than kept as
|
|
1469
|
-
* `undefined`; a send always
|
|
1497
|
+
* `undefined`; a send and a standing record always carry their send epoch. A standing record and its children carry
|
|
1498
|
+
* the standing epoch, and a child its standing id — each only when present, so every other record's identity, and its
|
|
1499
|
+
* digest, is exactly what it was before standing approvals existed.
|
|
1470
1500
|
*/
|
|
1471
1501
|
function identityOf(record) {
|
|
1472
1502
|
return {
|
|
@@ -1483,7 +1513,14 @@ function identityOf(record) {
|
|
|
1483
1513
|
bcc: [...record.expect.bcc],
|
|
1484
1514
|
subject: record.expect.subject
|
|
1485
1515
|
},
|
|
1486
|
-
...record.kind === "send" ? { sendEpoch: record.sendEpoch } : {}
|
|
1516
|
+
...record.kind === "send" || record.kind === "standing" ? { sendEpoch: record.sendEpoch } : {},
|
|
1517
|
+
...record.standingEpoch === void 0 ? {} : { standingEpoch: record.standingEpoch },
|
|
1518
|
+
...record.standingId === void 0 ? {} : { standingId: record.standingId },
|
|
1519
|
+
...record.standingUse === void 0 ? {} : { standingUse: {
|
|
1520
|
+
channel: record.standingUse.channel,
|
|
1521
|
+
thread: record.standingUse.thread,
|
|
1522
|
+
content: record.standingUse.content
|
|
1523
|
+
} }
|
|
1487
1524
|
};
|
|
1488
1525
|
}
|
|
1489
1526
|
/** One listed file, as stored: its name and size, and its rename reason and flags only when it has them. */
|
|
@@ -1658,8 +1695,12 @@ function programNeedsQuotes(command) {
|
|
|
1658
1695
|
const [program] = command.words;
|
|
1659
1696
|
return command.platform === "win32" && program !== void 0 && windowsShellWord(program) !== program;
|
|
1660
1697
|
}
|
|
1698
|
+
/**
|
|
1699
|
+
* One word for a POSIX shell: as it is when nothing in it is special, otherwise in single quotes. A word that starts
|
|
1700
|
+
* with `=` or `~` is quoted too: zsh reads `=ls` as the path of `ls`, and `~` as a home.
|
|
1701
|
+
*/
|
|
1661
1702
|
function posixShellWord(word) {
|
|
1662
|
-
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
1703
|
+
return /^(?![=~])[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
1663
1704
|
}
|
|
1664
1705
|
/**
|
|
1665
1706
|
* The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
|
|
@@ -2986,12 +3027,12 @@ function collectFromJson(parsed, client, path, topScope = "user") {
|
|
|
2986
3027
|
function collectFromToml(text, client, path) {
|
|
2987
3028
|
const servers = parseToml(text).mcp_servers;
|
|
2988
3029
|
if (servers === void 0) return [];
|
|
2989
|
-
if (!isRecord$
|
|
3030
|
+
if (!isRecord$2(servers)) throw new TomlError("mcp_servers is not a table");
|
|
2990
3031
|
const found = [];
|
|
2991
3032
|
for (const [name, entry] of Object.entries(servers)) {
|
|
2992
|
-
if (!isRecord$
|
|
3033
|
+
if (!isRecord$2(entry)) throw new TomlError(`mcp_servers.${name} is not a table`);
|
|
2993
3034
|
if (entry.args !== void 0 && !Array.isArray(entry.args)) throw new TomlError(`mcp_servers.${name}.args is not an array`);
|
|
2994
|
-
if (entry.env !== void 0 && !isRecord$
|
|
3035
|
+
if (entry.env !== void 0 && !isRecord$2(entry.env)) throw new TomlError(`mcp_servers.${name}.env is not a table`);
|
|
2995
3036
|
found.push({
|
|
2996
3037
|
client,
|
|
2997
3038
|
path,
|
|
@@ -2999,12 +3040,12 @@ function collectFromToml(text, client, path) {
|
|
|
2999
3040
|
command: typeof entry.command === "string" ? entry.command : "",
|
|
3000
3041
|
args: Array.isArray(entry.args) ? entry.args.map(String) : [],
|
|
3001
3042
|
...typeof entry.url === "string" ? { url: entry.url } : {},
|
|
3002
|
-
...isRecord$
|
|
3043
|
+
...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
|
|
3003
3044
|
});
|
|
3004
3045
|
}
|
|
3005
3046
|
return found;
|
|
3006
3047
|
}
|
|
3007
|
-
function isRecord$
|
|
3048
|
+
function isRecord$2(value) {
|
|
3008
3049
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3009
3050
|
}
|
|
3010
3051
|
/**
|
|
@@ -3017,8 +3058,8 @@ function isRecord$1(value) {
|
|
|
3017
3058
|
*/
|
|
3018
3059
|
function codexServerFromGet(name, stdout, path) {
|
|
3019
3060
|
const answer = JSON.parse(stdout);
|
|
3020
|
-
if (!isRecord$
|
|
3021
|
-
const entry = isRecord$
|
|
3061
|
+
if (!isRecord$2(answer)) throw new SyntaxError("not an object");
|
|
3062
|
+
const entry = isRecord$2(answer.transport) ? answer.transport : answer;
|
|
3022
3063
|
const command = typeof entry.command === "string" ? entry.command : "";
|
|
3023
3064
|
const url = typeof entry.url === "string" ? entry.url : void 0;
|
|
3024
3065
|
if (!command && !url) throw new SyntaxError("neither a command nor a URL");
|
|
@@ -3030,7 +3071,7 @@ function codexServerFromGet(name, stdout, path) {
|
|
|
3030
3071
|
command,
|
|
3031
3072
|
args: Array.isArray(entry.args) ? entry.args.map(String) : [],
|
|
3032
3073
|
...url ? { url } : {},
|
|
3033
|
-
...isRecord$
|
|
3074
|
+
...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
|
|
3034
3075
|
};
|
|
3035
3076
|
return {
|
|
3036
3077
|
...server,
|
|
@@ -3136,7 +3177,7 @@ async function scanRegisteredServers(env = process.env, platform = process.platf
|
|
|
3136
3177
|
continue;
|
|
3137
3178
|
}
|
|
3138
3179
|
add(collectFromJson(parsed, file.client, file.path));
|
|
3139
|
-
if (file.client !== "claude-code" || !isRecord$
|
|
3180
|
+
if (file.client !== "claude-code" || !isRecord$2(parsed) || !isRecord$2(parsed.projects)) continue;
|
|
3140
3181
|
for (const root of Object.keys(parsed.projects)) {
|
|
3141
3182
|
if (!isAbsolute(root)) continue;
|
|
3142
3183
|
const path = join(root, ".mcp.json");
|
|
@@ -6141,7 +6182,7 @@ const BEHAVIOUR_ACTS = Object.freeze([
|
|
|
6141
6182
|
"file",
|
|
6142
6183
|
"send"
|
|
6143
6184
|
]);
|
|
6144
|
-
function isRecord$
|
|
6185
|
+
function isRecord$1(value) {
|
|
6145
6186
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6146
6187
|
}
|
|
6147
6188
|
/**
|
|
@@ -6188,7 +6229,7 @@ function accountNamed(written, names) {
|
|
|
6188
6229
|
/** One stored rule, or why it cannot be read. Reasons are fixed words: nothing from the file is repeated in them. */
|
|
6189
6230
|
function readRule(raw, index, names) {
|
|
6190
6231
|
const at = `rule ${index + 1}`;
|
|
6191
|
-
if (!isRecord$
|
|
6232
|
+
if (!isRecord$1(raw)) return { unreadable: {
|
|
6192
6233
|
account: null,
|
|
6193
6234
|
reason: `${at} is not an object`
|
|
6194
6235
|
} };
|
|
@@ -6244,7 +6285,7 @@ function readBehaviourSettings(config) {
|
|
|
6244
6285
|
rules: [],
|
|
6245
6286
|
unreadable: []
|
|
6246
6287
|
};
|
|
6247
|
-
if (!isRecord$
|
|
6288
|
+
if (!isRecord$1(stored)) return unreadableWhole("the behaviour setting is not an object");
|
|
6248
6289
|
const rules = stored.rules;
|
|
6249
6290
|
if (rules === void 0) return {
|
|
6250
6291
|
rules: [],
|
|
@@ -6551,7 +6592,7 @@ Object.freeze({
|
|
|
6551
6592
|
*/
|
|
6552
6593
|
function behaviourRenamed(config, id, from, to) {
|
|
6553
6594
|
const stored = config.behaviour;
|
|
6554
|
-
if (!isRecord$
|
|
6595
|
+
if (!isRecord$1(stored)) return stored;
|
|
6555
6596
|
const rules = stored.rules;
|
|
6556
6597
|
if (!Array.isArray(rules)) return stored;
|
|
6557
6598
|
const held = new Set([...Object.values(config.inboxes ?? {}), ...Object.values(config.accounts ?? {})].map((entry) => entry.id));
|
|
@@ -6562,7 +6603,7 @@ function behaviourRenamed(config, id, from, to) {
|
|
|
6562
6603
|
};
|
|
6563
6604
|
return {
|
|
6564
6605
|
...stored,
|
|
6565
|
-
rules: rules.map((rule) => isRecord$
|
|
6606
|
+
rules: rules.map((rule) => isRecord$1(rule) && holds(rule) ? {
|
|
6566
6607
|
...rule,
|
|
6567
6608
|
account: to
|
|
6568
6609
|
} : rule)
|
|
@@ -6949,6 +6990,7 @@ const configV2Schema = looseObject({
|
|
|
6949
6990
|
/** What version 3 adds, loose as the rest of the file is: a later release's keys inside them are kept. */
|
|
6950
6991
|
const v3Shape = {
|
|
6951
6992
|
sendEpochs: record(string().min(1), number().int().min(0)).optional(),
|
|
6993
|
+
standingEpoch: number().int().min(0).optional(),
|
|
6952
6994
|
legacyDrain: looseObject({
|
|
6953
6995
|
since: absoluteTimeSchema,
|
|
6954
6996
|
tracked: record(string().min(1), _enum(["open", ...[
|
|
@@ -7192,7 +7234,7 @@ var ConfigStore = class {
|
|
|
7192
7234
|
});
|
|
7193
7235
|
}
|
|
7194
7236
|
}
|
|
7195
|
-
const written = withSendEpochs(current, parsed.data);
|
|
7237
|
+
const written = withStandingEpoch(current, withSendEpochs(current, parsed.data));
|
|
7196
7238
|
const serialized = `${JSON.stringify(written, null, 2)}\n`;
|
|
7197
7239
|
await writeFileAtomic(this.path, serialized, 384, options.signal);
|
|
7198
7240
|
committed = written;
|
|
@@ -7375,6 +7417,46 @@ function withSendEpochs(before, after) {
|
|
|
7375
7417
|
sendEpochs: epochs
|
|
7376
7418
|
};
|
|
7377
7419
|
}
|
|
7420
|
+
/**
|
|
7421
|
+
* The standing epoch in `config` (design 2026-10-19-standing-approvals, S8): its stored value, or 0 when it has none —
|
|
7422
|
+
* or when it is not a whole number of 0 or more, which no write of this release leaves.
|
|
7423
|
+
*/
|
|
7424
|
+
function standingEpochOf(config) {
|
|
7425
|
+
const epoch = config.standingEpoch;
|
|
7426
|
+
return typeof epoch === "number" && Number.isInteger(epoch) && epoch >= 0 ? epoch : 0;
|
|
7427
|
+
}
|
|
7428
|
+
/** The change policy of every inbox and account by id, each its own or the default. */
|
|
7429
|
+
function changePoliciesById(config) {
|
|
7430
|
+
const policies = /* @__PURE__ */ new Map();
|
|
7431
|
+
for (const inbox of Object.values(config.inboxes)) policies.set(inbox.id, inbox.changePolicy ?? defaultChangePolicy(config));
|
|
7432
|
+
for (const account of Object.values(config.accounts)) policies.set(account.id, account.changePolicy ?? defaultChangePolicy(config));
|
|
7433
|
+
return policies;
|
|
7434
|
+
}
|
|
7435
|
+
/**
|
|
7436
|
+
* Whether this write turns a change policy from `chat` to `confirm`: the default's, or the effective one of an inbox or
|
|
7437
|
+
* account there before and after, by id.
|
|
7438
|
+
*/
|
|
7439
|
+
function tightensAChangePolicy(before, after) {
|
|
7440
|
+
if (defaultChangePolicy(before) === "chat" && defaultChangePolicy(after) === "confirm") return true;
|
|
7441
|
+
const was = changePoliciesById(before);
|
|
7442
|
+
for (const [id, policy] of changePoliciesById(after)) if (policy === "confirm" && was.get(id) === "chat") return true;
|
|
7443
|
+
return false;
|
|
7444
|
+
}
|
|
7445
|
+
/**
|
|
7446
|
+
* `after`, with the standing epoch this write owes (design 2026-10-19-standing-approvals, S8): on version 3, the higher
|
|
7447
|
+
* of the two it holds — never lowered, whatever the write's own copy says, nor dropped — and one more when it tightens a
|
|
7448
|
+
* change policy (`tightensAChangePolicy`). Before version 3 there is no epoch, and no standing approval: every one is
|
|
7449
|
+
* made on version 3.
|
|
7450
|
+
*/
|
|
7451
|
+
function withStandingEpoch(before, after) {
|
|
7452
|
+
if (before.version !== 3 || after.version !== 3) return after;
|
|
7453
|
+
const held = Math.max(standingEpochOf(before), standingEpochOf(after));
|
|
7454
|
+
const kept = tightensAChangePolicy(before, after) ? held + 1 : held;
|
|
7455
|
+
return kept === 0 && after.standingEpoch === void 0 ? after : {
|
|
7456
|
+
...after,
|
|
7457
|
+
standingEpoch: kept
|
|
7458
|
+
};
|
|
7459
|
+
}
|
|
7378
7460
|
async function readFileIfExists(path) {
|
|
7379
7461
|
try {
|
|
7380
7462
|
return await readFile(path, "utf8");
|
|
@@ -7859,23 +7941,53 @@ function ownerEntry(config, ownerId) {
|
|
|
7859
7941
|
*/
|
|
7860
7942
|
function liveGateOf(config, record) {
|
|
7861
7943
|
const governing = (fallback) => record.kind === "change" && record.change !== void 0 ? governingChangePolicy(config, record.change) : fallback;
|
|
7944
|
+
const standingEpoch = standingEpochOf(config);
|
|
7862
7945
|
if (record.ownerScope !== "owner") return {
|
|
7863
7946
|
owner: "none",
|
|
7864
|
-
changePolicy: governing(defaultChangePolicy(config))
|
|
7947
|
+
changePolicy: governing(defaultChangePolicy(config)),
|
|
7948
|
+
standingEpoch
|
|
7865
7949
|
};
|
|
7866
7950
|
const entry = ownerEntry(config, record.inboxId);
|
|
7867
|
-
if (entry === void 0) return {
|
|
7951
|
+
if (entry === void 0) return {
|
|
7952
|
+
owner: "removed",
|
|
7953
|
+
standingEpoch
|
|
7954
|
+
};
|
|
7868
7955
|
return {
|
|
7869
7956
|
owner: "present",
|
|
7870
7957
|
sendPolicy: entry.sendPolicy ?? config.defaults.sendPolicy,
|
|
7871
7958
|
changePolicy: governing(entry.changePolicy ?? defaultChangePolicy(config)),
|
|
7872
|
-
sendEpoch: sendEpochOf(config, record.inboxId)
|
|
7959
|
+
sendEpoch: sendEpochOf(config, record.inboxId),
|
|
7960
|
+
standingEpoch
|
|
7873
7961
|
};
|
|
7874
7962
|
}
|
|
7875
7963
|
/** The reason a record whose owner was removed is revoked with. */
|
|
7876
7964
|
const OWNER_REMOVED_REASON = "its mailbox or account was removed";
|
|
7877
7965
|
/** The reason a send prepared before a `never` is revoked with: its stored epoch is behind the live one. */
|
|
7878
7966
|
const SEND_EPOCH_REASON = "sending was turned off since this was prepared (policy: never)";
|
|
7967
|
+
/**
|
|
7968
|
+
* The reason a standing approval, or one of its unused children, is revoked with once standing approvals were ended
|
|
7969
|
+
* since it was made: its standing epoch is behind the live one (design 2026-10-19-standing-approvals, S8).
|
|
7970
|
+
*/
|
|
7971
|
+
const STANDING_EPOCH_REASON = "standing approvals were ended since this was approved";
|
|
7972
|
+
/**
|
|
7973
|
+
* The reason a standing approval is revoked with once its account's change policy is stricter than the one it was
|
|
7974
|
+
* approved under (`approvedChangePolicy`) — `changePolicy-tightened`, read from the configuration itself, whatever the
|
|
7975
|
+
* standing epoch says, so a release that knows no epoch tightening the policy still ends it (design
|
|
7976
|
+
* 2026-10-19-standing-approvals, S8, the lead's call after review).
|
|
7977
|
+
*/
|
|
7978
|
+
const CHANGE_POLICY_TIGHTENED_REASON = "the change policy was tightened since this was approved";
|
|
7979
|
+
/** Whether `live` is stricter than `approved`: `confirm` over `chat`. */
|
|
7980
|
+
function changePolicyTightened(approved, live) {
|
|
7981
|
+
return live === "confirm" && approved !== "confirm";
|
|
7982
|
+
}
|
|
7983
|
+
/**
|
|
7984
|
+
* Whether even a look writes this outcome's revocation down: a standing approval ended by a tightened change policy.
|
|
7985
|
+
* Every other revocation a look derives is from something that never goes back — an epoch, an owner's id — so the next
|
|
7986
|
+
* action can write it; a policy can be loosened again, and what it ended must stay ended (S8).
|
|
7987
|
+
*/
|
|
7988
|
+
function writtenAtALook(outcome) {
|
|
7989
|
+
return outcome.revokes && outcome.record?.kind === "standing" && outcome.reason === "the change policy was tightened since this was approved";
|
|
7990
|
+
}
|
|
7879
7991
|
/** What an outcome says of a pending or approved send under a live `never`, before anything revokes it. */
|
|
7880
7992
|
const NEVER_NOTICE = "sending is turned off (policy: never); any use revokes it";
|
|
7881
7993
|
/** The refusal of a claim, an approval or an answer by a store opened without a configuration. */
|
|
@@ -7884,7 +7996,11 @@ const NO_CONFIGURATION = "this approval store was opened without a configuration
|
|
|
7884
7996
|
const sentAtWords = (usedAt) => `sent at ${usedAt}`;
|
|
7885
7997
|
/** The refusal prefix and detail key of each kind: a send sends nothing, a change changes nothing, a question saves nothing. */
|
|
7886
7998
|
function refusalOf(kind) {
|
|
7887
|
-
const [prefix, idKey, hint] = kind === "
|
|
7999
|
+
const [prefix, idKey, hint] = kind === "standing" ? [
|
|
8000
|
+
"nothing was sent",
|
|
8001
|
+
"approvalId",
|
|
8002
|
+
"This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal."
|
|
8003
|
+
] : kind === "disclosure" ? [
|
|
7888
8004
|
"nothing was disclosed",
|
|
7889
8005
|
"approvalId",
|
|
7890
8006
|
"Prepare a new standing disclosure authorisation and show its exact activation preview to the person."
|
|
@@ -7915,7 +8031,7 @@ function refusalOf(kind) {
|
|
|
7915
8031
|
* or one a pinned surface may not see — byte for byte the same, with `approval: null`, and nothing of any record in it.
|
|
7916
8032
|
*/
|
|
7917
8033
|
function approvalNotFound(approvalId, kind) {
|
|
7918
|
-
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}`, {
|
|
8034
|
+
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}`, {
|
|
7919
8035
|
hint: "Check the id: an approval is only found by the surface, the kind and the owner it was prepared for.",
|
|
7920
8036
|
details: { approval: null }
|
|
7921
8037
|
});
|
|
@@ -8083,12 +8199,20 @@ function classifyV2(read, context) {
|
|
|
8083
8199
|
const ownerRemoved = read.ownerScope === "owner" && live?.owner === "removed";
|
|
8084
8200
|
const active = read.state === "pending" || read.state === "approved";
|
|
8085
8201
|
const acting = action === "approve" || action === "claim";
|
|
8086
|
-
const
|
|
8202
|
+
const sends = read.kind === "send" || read.kind === "standing";
|
|
8203
|
+
const standingBound = read.kind === "standing" || read.kind === "send" && read.route === "standing";
|
|
8204
|
+
const never = sends && live?.owner === "present" && stricterPolicy(live.sendPolicy ?? "never", read.requiredPolicy) === "never";
|
|
8087
8205
|
let derived = null;
|
|
8206
|
+
let revokedByNever = false;
|
|
8088
8207
|
if (active && ownerRemoved) derived = OWNER_REMOVED_REASON;
|
|
8089
8208
|
else if (active && never) {
|
|
8090
|
-
if (acting)
|
|
8091
|
-
|
|
8209
|
+
if (acting) {
|
|
8210
|
+
derived = live?.sendPolicy === "never" ? `sending is turned off for this ${accountNoun(read.channel)} (policy: never)` : "sending is turned off for this approval (policy: never)";
|
|
8211
|
+
revokedByNever = true;
|
|
8212
|
+
}
|
|
8213
|
+
} else if (active && sends && live?.owner === "present" && (read.sendEpoch ?? 0) !== live.sendEpoch) derived = SEND_EPOCH_REASON;
|
|
8214
|
+
else if (active && standingBound && live !== null && live.standingEpoch !== void 0 && read.standingEpoch !== live.standingEpoch) derived = STANDING_EPOCH_REASON;
|
|
8215
|
+
else if (read.kind === "standing" && read.state === "approved" && live?.owner === "present" && changePolicyTightened(read.approvedChangePolicy, live.changePolicy)) derived = CHANGE_POLICY_TIGHTENED_REASON;
|
|
8092
8216
|
const record = derived === null ? read : {
|
|
8093
8217
|
...read,
|
|
8094
8218
|
state: "revoked",
|
|
@@ -8096,7 +8220,6 @@ function classifyV2(read, context) {
|
|
|
8096
8220
|
revokedAt: at,
|
|
8097
8221
|
challengeHash: void 0
|
|
8098
8222
|
};
|
|
8099
|
-
const revokedByNever = derived !== null && derived !== "its mailbox or account was removed" && derived !== "sending was turned off since this was prepared (policy: never)";
|
|
8100
8223
|
let claimable = false;
|
|
8101
8224
|
let reason = record.reason;
|
|
8102
8225
|
if (record.state === "pending" || record.state === "approved") {
|
|
@@ -8162,6 +8285,7 @@ function isClaimable(record, live) {
|
|
|
8162
8285
|
case "change": return record.state === "approved" || record.route === "chat" && live.changePolicy === "chat";
|
|
8163
8286
|
case "download": return downloadClaimable(record, live.changePolicy);
|
|
8164
8287
|
case "disclosure": return record.state === "approved";
|
|
8288
|
+
case "standing": return false;
|
|
8165
8289
|
}
|
|
8166
8290
|
}
|
|
8167
8291
|
/**
|
|
@@ -8210,7 +8334,7 @@ function errorOf(record, approval, context) {
|
|
|
8210
8334
|
if (download) return refuse("APPROVAL_EXPIRED", downloadExpiredWords(record.approvedVia !== void 0), record, approval);
|
|
8211
8335
|
const nothing = `nothing was ${record.kind === "change" ? "changed" : "sent"} with it`;
|
|
8212
8336
|
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}`, {
|
|
8213
|
-
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.",
|
|
8337
|
+
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.",
|
|
8214
8338
|
details: {
|
|
8215
8339
|
approvalId: record.approvalId,
|
|
8216
8340
|
state: record.state,
|
|
@@ -8294,6 +8418,578 @@ var AuditLog = class {
|
|
|
8294
8418
|
}
|
|
8295
8419
|
};
|
|
8296
8420
|
/**
|
|
8421
|
+
* The emoji a standing approval may react with (design 2026-10-19-standing-approvals, S3 and §8.4): a curated list of
|
|
8422
|
+
* standard Unicode names, each with its code point, shipped in the code.
|
|
8423
|
+
*
|
|
8424
|
+
* Only standard names, because a custom workspace emoji's image can be replaced under the same name: the person would
|
|
8425
|
+
* have approved a picture that is no longer the one sent. And a short list rather than every Unicode name, because each
|
|
8426
|
+
* one here is a word the agent may say on the person's behalf without asking.
|
|
8427
|
+
*/
|
|
8428
|
+
const STANDARD_EMOJI = Object.freeze({
|
|
8429
|
+
eyes: "U+1F440",
|
|
8430
|
+
white_check_mark: "U+2705",
|
|
8431
|
+
heavy_check_mark: "U+2714",
|
|
8432
|
+
ballot_box_with_check: "U+2611",
|
|
8433
|
+
"+1": "U+1F44D",
|
|
8434
|
+
thumbsup: "U+1F44D",
|
|
8435
|
+
ok: "U+1F197",
|
|
8436
|
+
ok_hand: "U+1F44C",
|
|
8437
|
+
"100": "U+1F4AF",
|
|
8438
|
+
heavy_plus_sign: "U+2795",
|
|
8439
|
+
handshake: "U+1F91D",
|
|
8440
|
+
raised_hands: "U+1F64C",
|
|
8441
|
+
pray: "U+1F64F",
|
|
8442
|
+
clap: "U+1F44F",
|
|
8443
|
+
wave: "U+1F44B",
|
|
8444
|
+
memo: "U+1F4DD",
|
|
8445
|
+
pencil2: "U+270F",
|
|
8446
|
+
hourglass_flowing_sand: "U+23F3",
|
|
8447
|
+
stopwatch: "U+23F1",
|
|
8448
|
+
inbox_tray: "U+1F4E5",
|
|
8449
|
+
pushpin: "U+1F4CC",
|
|
8450
|
+
mag: "U+1F50D",
|
|
8451
|
+
bulb: "U+1F4A1",
|
|
8452
|
+
speech_balloon: "U+1F4AC",
|
|
8453
|
+
thinking_face: "U+1F914",
|
|
8454
|
+
slightly_smiling_face: "U+1F642",
|
|
8455
|
+
heart: "U+2764",
|
|
8456
|
+
tada: "U+1F389",
|
|
8457
|
+
rocket: "U+1F680",
|
|
8458
|
+
sparkles: "U+2728"
|
|
8459
|
+
});
|
|
8460
|
+
/**
|
|
8461
|
+
* The emoji among them that people read as a yes: ✅ on "can I deploy?" is taken for approval, and a Slack workflow
|
|
8462
|
+
* can start on a reaction (S3). Allowed, with a warning in the preview.
|
|
8463
|
+
*/
|
|
8464
|
+
const READS_AS_APPROVAL = Object.freeze([
|
|
8465
|
+
"white_check_mark",
|
|
8466
|
+
"heavy_check_mark",
|
|
8467
|
+
"ballot_box_with_check",
|
|
8468
|
+
"+1",
|
|
8469
|
+
"thumbsup",
|
|
8470
|
+
"ok",
|
|
8471
|
+
"ok_hand",
|
|
8472
|
+
"100",
|
|
8473
|
+
"heavy_plus_sign",
|
|
8474
|
+
"handshake"
|
|
8475
|
+
]);
|
|
8476
|
+
/** Whether `name` is one of the standard emoji a standing approval may hold, exactly as listed. */
|
|
8477
|
+
function isStandardEmoji(name) {
|
|
8478
|
+
return Object.hasOwn(STANDARD_EMOJI, name);
|
|
8479
|
+
}
|
|
8480
|
+
Object.freeze(["reaction", "reply"]);
|
|
8481
|
+
/** S3: what a standing approval holds when no content is given. */
|
|
8482
|
+
const STANDING_DEFAULT_REACTION = "eyes";
|
|
8483
|
+
/** One day, the unit a standing approval's expiry is counted in. */
|
|
8484
|
+
const STANDING_DAY_MS = 864e5;
|
|
8485
|
+
/** S6's table: each limit's default and the range a person may set it to. */
|
|
8486
|
+
const STANDING_LIMITS = Object.freeze({
|
|
8487
|
+
perThread: Object.freeze({
|
|
8488
|
+
default: 1,
|
|
8489
|
+
min: 1,
|
|
8490
|
+
max: 3
|
|
8491
|
+
}),
|
|
8492
|
+
perHour: Object.freeze({
|
|
8493
|
+
default: 10,
|
|
8494
|
+
min: 1,
|
|
8495
|
+
max: 30
|
|
8496
|
+
}),
|
|
8497
|
+
perDay: Object.freeze({
|
|
8498
|
+
default: 30,
|
|
8499
|
+
min: 1,
|
|
8500
|
+
max: 100
|
|
8501
|
+
}),
|
|
8502
|
+
perSender: Object.freeze({
|
|
8503
|
+
default: 3,
|
|
8504
|
+
min: 1,
|
|
8505
|
+
max: 10
|
|
8506
|
+
}),
|
|
8507
|
+
expiresDays: Object.freeze({
|
|
8508
|
+
default: 7,
|
|
8509
|
+
min: 1,
|
|
8510
|
+
max: 30
|
|
8511
|
+
})
|
|
8512
|
+
});
|
|
8513
|
+
const LIMIT_NAMES$1 = [
|
|
8514
|
+
"perThread",
|
|
8515
|
+
"perHour",
|
|
8516
|
+
"perDay",
|
|
8517
|
+
"perSender",
|
|
8518
|
+
"expiresDays"
|
|
8519
|
+
];
|
|
8520
|
+
const CONTENT_WORDS = Object.freeze({
|
|
8521
|
+
"too-many-reactions": `a standing approval holds at most 5 reactions`,
|
|
8522
|
+
"too-many-replies": `a standing approval holds at most 5 replies`,
|
|
8523
|
+
"custom-emoji": "only a standard emoji from the list this release ships may be a standing reaction",
|
|
8524
|
+
empty: "a standing reply has no words",
|
|
8525
|
+
"too-long": `a standing reply is at most 200 characters`,
|
|
8526
|
+
"multi-line": "a standing reply is one line",
|
|
8527
|
+
"control-character": "a standing reply holds a control or invisible character",
|
|
8528
|
+
mention: "a standing reply mentions nobody: no @, and no <@…> or <!…>",
|
|
8529
|
+
link: "a standing reply holds no link, address or domain",
|
|
8530
|
+
placeholder: "a standing reply is sent exactly as written: it holds no placeholder",
|
|
8531
|
+
markup: "a standing reply is plain text: no formatting characters",
|
|
8532
|
+
"emoji-code": "a standing reply holds no :emoji: code; a reaction is its own content",
|
|
8533
|
+
pictograph: "a standing reply holds no emoji or pictograph, such as ✅, ✔ or 👍; a reaction is its own content",
|
|
8534
|
+
duplicate: "a standing approval holds each content once"
|
|
8535
|
+
});
|
|
8536
|
+
/** The refusal of a content, as BAD_DATA, with its reason and which content it was. */
|
|
8537
|
+
function contentRefused(reason, where) {
|
|
8538
|
+
return new CommsError("BAD_DATA", `nothing was prepared: ${CONTENT_WORDS[reason]}`, {
|
|
8539
|
+
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.",
|
|
8540
|
+
details: {
|
|
8541
|
+
reason,
|
|
8542
|
+
...where === void 0 ? {} : {
|
|
8543
|
+
kind: where.kind,
|
|
8544
|
+
position: where.position
|
|
8545
|
+
}
|
|
8546
|
+
}
|
|
8547
|
+
});
|
|
8548
|
+
}
|
|
8549
|
+
/** The digest of one content: its kind and exact text. */
|
|
8550
|
+
function standingContentDigest(kind, text) {
|
|
8551
|
+
return sha256Hex(canonicalJson$1({
|
|
8552
|
+
kind,
|
|
8553
|
+
text
|
|
8554
|
+
}));
|
|
8555
|
+
}
|
|
8556
|
+
/** A reaction's name, exactly as listed — `:name:` read as `name` — or the refusal of a custom one. */
|
|
8557
|
+
function reactionName(written, position) {
|
|
8558
|
+
const trimmed = written.trim();
|
|
8559
|
+
const name = /^:[^:]+:$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed;
|
|
8560
|
+
if (!isStandardEmoji(name)) throw contentRefused("custom-emoji", {
|
|
8561
|
+
kind: "reaction",
|
|
8562
|
+
position
|
|
8563
|
+
});
|
|
8564
|
+
return name;
|
|
8565
|
+
}
|
|
8566
|
+
/** Each reason a reply's text is refused for, in the order checked: the first that applies is the one given. */
|
|
8567
|
+
const REPLY_RULES = Object.freeze([
|
|
8568
|
+
["multi-line", (text) => /[\r\n\u2028\u2029]/.test(text)],
|
|
8569
|
+
["pictograph", (text) => /[\p{Extended_Pictographic}\u2713]/u.test(text)],
|
|
8570
|
+
["control-character", (text) => [...text].some((char) => char === " " || isDangerous(char.codePointAt(0) ?? 0))],
|
|
8571
|
+
["mention", (text) => /@|<[@!#]/.test(text)],
|
|
8572
|
+
["link", (text) => /:\/\/|www\.|mailto:|[A-Za-z0-9-]+\.[A-Za-z]{2,}/i.test(text)],
|
|
8573
|
+
["placeholder", (text) => /[{}[\]]/.test(text)],
|
|
8574
|
+
["markup", (text) => /[<>*_~`|]/.test(text)],
|
|
8575
|
+
["emoji-code", (text) => /:[A-Za-z0-9_+-]+:/.test(text)]
|
|
8576
|
+
]);
|
|
8577
|
+
/**
|
|
8578
|
+
* 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
|
|
8579
|
+
* approval as surely as ✅ is. Matched whole, in any case. Allowed, with a warning in the preview.
|
|
8580
|
+
*/
|
|
8581
|
+
const APPROVAL_WORDS = Object.freeze([
|
|
8582
|
+
["yes", /\byes\b/i],
|
|
8583
|
+
["approved", /\bapproved\b/i],
|
|
8584
|
+
["approve", /\bapprove\b/i],
|
|
8585
|
+
["go ahead", /\bgo\s+ahead\b/i],
|
|
8586
|
+
["lgtm", /\blgtm\b/i],
|
|
8587
|
+
["ok", /\bok\b/i],
|
|
8588
|
+
["done", /\bdone\b/i],
|
|
8589
|
+
["confirmed", /\bconfirmed\b/i]
|
|
8590
|
+
]);
|
|
8591
|
+
/** What a reply's reads-as-approval warning begins with: the preview shows it under that reply. */
|
|
8592
|
+
function replyWarningPrefix(text) {
|
|
8593
|
+
return `The reply "${text}" can read as approval:`;
|
|
8594
|
+
}
|
|
8595
|
+
/** S3's warning for a reply holding words that read as a yes, or null. */
|
|
8596
|
+
function replyReadsAsApproval(text) {
|
|
8597
|
+
const said = APPROVAL_WORDS.filter(([, pattern]) => pattern.test(text)).map(([word]) => `"${word}"`);
|
|
8598
|
+
if (said.length === 0) return null;
|
|
8599
|
+
return `${replyWarningPrefix(text)} it says ${said.join(", ")}. People read "OK" or "Done" on "can I deploy?" as a yes.`;
|
|
8600
|
+
}
|
|
8601
|
+
/** A reply's text, trimmed and checked (S3), or the refusal of the first rule it breaks. */
|
|
8602
|
+
function replyText(written, position) {
|
|
8603
|
+
const where = {
|
|
8604
|
+
kind: "reply",
|
|
8605
|
+
position
|
|
8606
|
+
};
|
|
8607
|
+
const text = written.trim();
|
|
8608
|
+
if (text === "") throw contentRefused("empty", where);
|
|
8609
|
+
for (const [reason, breaks] of REPLY_RULES) if (breaks(text)) throw contentRefused(reason, where);
|
|
8610
|
+
if ([...text].length > 200) throw contentRefused("too-long", where);
|
|
8611
|
+
return text;
|
|
8612
|
+
}
|
|
8613
|
+
/** S3's warning for an emoji that reads as approval, in the spec's words. */
|
|
8614
|
+
function readsAsApprovalWarning(name) {
|
|
8615
|
+
return `:${name}: can read as approval: people read ✅ on "can I deploy?" as a yes, and a Slack workflow can start on a reaction.`;
|
|
8616
|
+
}
|
|
8617
|
+
/**
|
|
8618
|
+
* The contents a person wrote, checked (S3): reactions first, then replies, each as written, each with its digest; and
|
|
8619
|
+
* the warnings the preview shows. With no content given at all, `eyes` alone. Refuses BAD_DATA, with `details.reason`,
|
|
8620
|
+
* for anything S3 does not allow.
|
|
8621
|
+
*/
|
|
8622
|
+
function checkedContents(input) {
|
|
8623
|
+
const reactions = input.reactions ?? [];
|
|
8624
|
+
const replies = input.replies ?? [];
|
|
8625
|
+
if (reactions.length > 5) throw contentRefused("too-many-reactions");
|
|
8626
|
+
if (replies.length > 5) throw contentRefused("too-many-replies");
|
|
8627
|
+
const names = reactions.length === 0 && replies.length === 0 ? [STANDING_DEFAULT_REACTION] : reactions.map((written, index) => reactionName(written, index + 1));
|
|
8628
|
+
const texts = replies.map((written, index) => replyText(written, index + 1));
|
|
8629
|
+
const contents = [...names.map((text) => ({
|
|
8630
|
+
kind: "reaction",
|
|
8631
|
+
text,
|
|
8632
|
+
digest: standingContentDigest("reaction", text)
|
|
8633
|
+
})), ...texts.map((text) => ({
|
|
8634
|
+
kind: "reply",
|
|
8635
|
+
text,
|
|
8636
|
+
digest: standingContentDigest("reply", text)
|
|
8637
|
+
}))];
|
|
8638
|
+
if (new Set(contents.map((content) => content.digest)).size !== contents.length) throw contentRefused("duplicate");
|
|
8639
|
+
return {
|
|
8640
|
+
contents,
|
|
8641
|
+
warnings: [...names.filter((name) => READS_AS_APPROVAL.includes(name)).map(readsAsApprovalWarning), ...texts.map(replyReadsAsApproval).filter((warning) => warning !== null)]
|
|
8642
|
+
};
|
|
8643
|
+
}
|
|
8644
|
+
/** S6's limits, each from the table: its default when left out, and BAD_DATA outside its range. */
|
|
8645
|
+
function checkedLimits(input) {
|
|
8646
|
+
const limits = {};
|
|
8647
|
+
for (const name of LIMIT_NAMES$1) {
|
|
8648
|
+
const { default: fallback, min, max } = STANDING_LIMITS[name];
|
|
8649
|
+
const given = input[name];
|
|
8650
|
+
const value = given ?? fallback;
|
|
8651
|
+
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}`, {
|
|
8652
|
+
hint: `Leave it out for ${fallback}, or give a whole number from ${min} to ${max}.`,
|
|
8653
|
+
details: {
|
|
8654
|
+
reason: "limit-out-of-range",
|
|
8655
|
+
limit: name,
|
|
8656
|
+
min,
|
|
8657
|
+
max,
|
|
8658
|
+
given: given ?? null
|
|
8659
|
+
}
|
|
8660
|
+
});
|
|
8661
|
+
limits[name] = value;
|
|
8662
|
+
}
|
|
8663
|
+
return limits;
|
|
8664
|
+
}
|
|
8665
|
+
/** The canonical form of a place, as the digest binds it. */
|
|
8666
|
+
function canonicalPlaces(places) {
|
|
8667
|
+
if (places.kind === "threads-i-am-in") return { kind: "threads-i-am-in" };
|
|
8668
|
+
return {
|
|
8669
|
+
kind: "exact",
|
|
8670
|
+
places: places.places.map((place) => ({
|
|
8671
|
+
id: place.id,
|
|
8672
|
+
name: place.name,
|
|
8673
|
+
members: place.members,
|
|
8674
|
+
shared: {
|
|
8675
|
+
extShared: place.shared.extShared,
|
|
8676
|
+
pendingExtShared: place.shared.pendingExtShared,
|
|
8677
|
+
externalMembers: place.shared.externalMembers
|
|
8678
|
+
}
|
|
8679
|
+
}))
|
|
8680
|
+
};
|
|
8681
|
+
}
|
|
8682
|
+
/**
|
|
8683
|
+
* The digest a standing record is bound to: everything its binding holds but the summary — the account, every content
|
|
8684
|
+
* with its digest in order, every place as recorded, the limits, who proposed it, what it replaces, and the warnings
|
|
8685
|
+
* the person was shown.
|
|
8686
|
+
*/
|
|
8687
|
+
function standingDigest(binding) {
|
|
8688
|
+
return sha256Hex(canonicalJson$1({
|
|
8689
|
+
kind: "standing",
|
|
8690
|
+
target: {
|
|
8691
|
+
kind: binding.target.kind,
|
|
8692
|
+
name: binding.target.name,
|
|
8693
|
+
id: binding.target.id
|
|
8694
|
+
},
|
|
8695
|
+
contents: binding.contents.map((content) => ({
|
|
8696
|
+
kind: content.kind,
|
|
8697
|
+
text: content.text,
|
|
8698
|
+
digest: content.digest
|
|
8699
|
+
})),
|
|
8700
|
+
places: canonicalPlaces(binding.places),
|
|
8701
|
+
limits: {
|
|
8702
|
+
perThread: binding.limits.perThread,
|
|
8703
|
+
perHour: binding.limits.perHour,
|
|
8704
|
+
perDay: binding.limits.perDay,
|
|
8705
|
+
perSender: binding.limits.perSender,
|
|
8706
|
+
expiresDays: binding.limits.expiresDays
|
|
8707
|
+
},
|
|
8708
|
+
proposedBy: binding.proposedBy,
|
|
8709
|
+
replaces: binding.replaces ?? null,
|
|
8710
|
+
warnings: [...binding.warnings]
|
|
8711
|
+
}));
|
|
8712
|
+
}
|
|
8713
|
+
/** A narrowing that is not one: USAGE, saying what to do instead. */
|
|
8714
|
+
function notANarrowing(message) {
|
|
8715
|
+
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." });
|
|
8716
|
+
}
|
|
8717
|
+
/**
|
|
8718
|
+
* The standing approval as it applies after `narrowed`: only the contents whose digests it keeps, only the exact
|
|
8719
|
+
* places it keeps, and the lower of each limit — so a narrowing never raises one, whatever it says. USAGE for a digest
|
|
8720
|
+
* or a place the approval does not hold, for keeping none, and for places kept from `threads-i-am-in`, which names none.
|
|
8721
|
+
*/
|
|
8722
|
+
function effectiveStanding(binding, narrowed) {
|
|
8723
|
+
if (narrowed === void 0) return binding;
|
|
8724
|
+
let contents = binding.contents;
|
|
8725
|
+
if (narrowed.keep !== void 0) {
|
|
8726
|
+
const keep = new Set(narrowed.keep);
|
|
8727
|
+
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");
|
|
8728
|
+
contents = binding.contents.filter((content) => keep.has(content.digest));
|
|
8729
|
+
if (contents.length === 0) throw notANarrowing("it would keep no content at all");
|
|
8730
|
+
}
|
|
8731
|
+
let places = binding.places;
|
|
8732
|
+
if (narrowed.places !== void 0) {
|
|
8733
|
+
if (binding.places.kind !== "exact") throw notANarrowing("threads-i-am-in names no places to keep; narrow its contents or limits, or revoke it");
|
|
8734
|
+
const keep = new Set(narrowed.places);
|
|
8735
|
+
const held = binding.places.places;
|
|
8736
|
+
for (const id of keep) if (!held.some((place) => place.id === id)) throw notANarrowing("it keeps a place this standing approval does not cover");
|
|
8737
|
+
const kept = held.filter((place) => keep.has(place.id));
|
|
8738
|
+
if (kept.length === 0) throw notANarrowing("it would keep no place at all");
|
|
8739
|
+
places = {
|
|
8740
|
+
kind: "exact",
|
|
8741
|
+
places: kept
|
|
8742
|
+
};
|
|
8743
|
+
}
|
|
8744
|
+
const limits = { ...binding.limits };
|
|
8745
|
+
for (const name of LIMIT_NAMES$1) {
|
|
8746
|
+
const lower = narrowed.limits?.[name];
|
|
8747
|
+
if (lower !== void 0 && Number.isFinite(lower)) limits[name] = Math.min(limits[name], lower);
|
|
8748
|
+
}
|
|
8749
|
+
return {
|
|
8750
|
+
...binding,
|
|
8751
|
+
contents,
|
|
8752
|
+
places,
|
|
8753
|
+
limits
|
|
8754
|
+
};
|
|
8755
|
+
}
|
|
8756
|
+
/**
|
|
8757
|
+
* The narrowing `narrowing` makes of what is in force — `binding` as `narrowed` already leaves it — as the `narrowed` to
|
|
8758
|
+
* store beside `binding`: the contents and exact places kept, and every limit, as asked. Applies at once, so it may
|
|
8759
|
+
* only narrow (S8): USAGE for anything that would widen what is in force — a higher limit, a later expiry, a content
|
|
8760
|
+
* or a place not held — and BAD_DATA for a limit outside the table.
|
|
8761
|
+
*/
|
|
8762
|
+
function narrowedFurther(binding, narrowed, narrowing) {
|
|
8763
|
+
const before = effectiveStanding(binding, narrowed);
|
|
8764
|
+
const kept = effectiveStanding(before, {
|
|
8765
|
+
...narrowing.keep === void 0 ? {} : { keep: narrowing.keep },
|
|
8766
|
+
...narrowing.places === void 0 ? {} : { places: narrowing.places }
|
|
8767
|
+
});
|
|
8768
|
+
const asked = { ...kept.limits };
|
|
8769
|
+
for (const name of LIMIT_NAMES$1) {
|
|
8770
|
+
const value = narrowing.limits?.[name];
|
|
8771
|
+
if (value !== void 0) asked[name] = value;
|
|
8772
|
+
}
|
|
8773
|
+
const after = {
|
|
8774
|
+
...kept,
|
|
8775
|
+
limits: checkedLimits(asked)
|
|
8776
|
+
};
|
|
8777
|
+
if (classifyStandingChange(before, after) === "widens") throw new CommsError("USAGE", "nothing was narrowed: this would widen the standing approval, not narrow it", {
|
|
8778
|
+
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.",
|
|
8779
|
+
details: { reason: "widens" }
|
|
8780
|
+
});
|
|
8781
|
+
return {
|
|
8782
|
+
keep: after.contents.map((content) => content.digest),
|
|
8783
|
+
...after.places.kind === "exact" ? { places: after.places.places.map((place) => place.id) } : {},
|
|
8784
|
+
limits: { ...after.limits }
|
|
8785
|
+
};
|
|
8786
|
+
}
|
|
8787
|
+
/** How many days a standing approval lasts once approved, as narrowed: the lower of its own and the narrowing's. */
|
|
8788
|
+
function standingDays(binding, narrowed) {
|
|
8789
|
+
const lower = narrowed?.limits?.expiresDays;
|
|
8790
|
+
return typeof lower === "number" && Number.isFinite(lower) ? Math.min(binding.limits.expiresDays, lower) : binding.limits.expiresDays;
|
|
8791
|
+
}
|
|
8792
|
+
/** A Slack conversation a standing approval may name exactly: a channel, a private channel or group DM, or a DM. */
|
|
8793
|
+
const STANDING_PLACE_PATTERN = /^[CGD][A-Z0-9]{2,}$/;
|
|
8794
|
+
const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8795
|
+
const isStrings$1 = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
8796
|
+
/** Why `shared` is not a recorded sharing state, or null. */
|
|
8797
|
+
function sharedProblem(shared) {
|
|
8798
|
+
if (!isRecord$3(shared)) return "a place has no sharing state";
|
|
8799
|
+
if (typeof shared.extShared !== "boolean" || typeof shared.pendingExtShared !== "boolean") return "a place has no sharing state";
|
|
8800
|
+
if (shared.externalMembers !== null && typeof shared.externalMembers !== "boolean") return "a place has no sharing state";
|
|
8801
|
+
return null;
|
|
8802
|
+
}
|
|
8803
|
+
/** Why `places` is not a standing approval's places, or null. */
|
|
8804
|
+
function placesProblem(places) {
|
|
8805
|
+
if (!isRecord$3(places)) return "it has no places";
|
|
8806
|
+
if (places.kind === "threads-i-am-in") return Object.keys(places).length === 1 ? null : "its places are not one kind";
|
|
8807
|
+
if (places.kind !== "exact" || !Array.isArray(places.places)) return "it has no places";
|
|
8808
|
+
if (places.places.length === 0) return "it names no place";
|
|
8809
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8810
|
+
for (const place of places.places) {
|
|
8811
|
+
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";
|
|
8812
|
+
if (seen.has(place.id)) return "a place is named twice";
|
|
8813
|
+
seen.add(place.id);
|
|
8814
|
+
if (place.name !== null && typeof place.name !== "string") return "a place has no name or null";
|
|
8815
|
+
if (!Number.isSafeInteger(place.members) || place.members < 0) return "a place has no member count";
|
|
8816
|
+
const shared = sharedProblem(place.shared);
|
|
8817
|
+
if (shared !== null) return shared;
|
|
8818
|
+
}
|
|
8819
|
+
return null;
|
|
8820
|
+
}
|
|
8821
|
+
/**
|
|
8822
|
+
* Why `binding` is not one a standing record may hold, or null: every content one `checkedContents` gives for its
|
|
8823
|
+
* texts, in order, with its digest; exactly the warnings those contents give (S3); every limit inside the table; places
|
|
8824
|
+
* 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
|
|
8825
|
+
* the validator a record that holds one, so nothing a caller assembles by hand — a custom emoji, a 31-day expiry, a
|
|
8826
|
+
* reply with a link — can become a standing approval.
|
|
8827
|
+
*/
|
|
8828
|
+
function standingBindingProblem(binding) {
|
|
8829
|
+
if (!isRecord$3(binding)) return "it is not a standing approval";
|
|
8830
|
+
if (typeof binding.summary !== "string") return "it has no summary";
|
|
8831
|
+
const target = binding.target;
|
|
8832
|
+
if (!isRecord$3(target) || target.kind !== "account" || typeof target.name !== "string") return "it names no account";
|
|
8833
|
+
if (typeof target.id !== "string" || !ACCOUNT_ID_PATTERN.test(target.id)) return "it names no account by its id";
|
|
8834
|
+
if (!Array.isArray(binding.contents)) return "it has no contents";
|
|
8835
|
+
const written = binding.contents;
|
|
8836
|
+
if (!written.every((content) => isRecord$3(content) && typeof content.text === "string")) return "a content is unreadable";
|
|
8837
|
+
const contents = written;
|
|
8838
|
+
let checked;
|
|
8839
|
+
let warnings;
|
|
8840
|
+
try {
|
|
8841
|
+
({contents: checked, warnings} = checkedContents({
|
|
8842
|
+
reactions: contents.filter((content) => content.kind === "reaction").map((content) => content.text),
|
|
8843
|
+
replies: contents.filter((content) => content.kind === "reply").map((content) => content.text)
|
|
8844
|
+
}));
|
|
8845
|
+
} catch {
|
|
8846
|
+
return "a content is not one a standing approval may hold";
|
|
8847
|
+
}
|
|
8848
|
+
if (contents.length === 0) return "it holds no content";
|
|
8849
|
+
const canonical = (list) => canonicalJson$1(list.map((content) => ({
|
|
8850
|
+
kind: content.kind,
|
|
8851
|
+
text: content.text,
|
|
8852
|
+
digest: content.digest
|
|
8853
|
+
})));
|
|
8854
|
+
if (canonical(checked) !== canonical(contents)) return "its contents are not as checked, in order, with their digests";
|
|
8855
|
+
const places = placesProblem(binding.places);
|
|
8856
|
+
if (places !== null) return places;
|
|
8857
|
+
if (!isRecord$3(binding.limits)) return "it has no limits";
|
|
8858
|
+
try {
|
|
8859
|
+
if (canonicalJson$1(checkedLimits(binding.limits)) !== canonicalJson$1(binding.limits)) return "its limits are not whole and complete";
|
|
8860
|
+
} catch {
|
|
8861
|
+
return "a limit is outside the table";
|
|
8862
|
+
}
|
|
8863
|
+
if (binding.proposedBy !== "agent" && binding.proposedBy !== "person") return "it does not say who proposed it";
|
|
8864
|
+
if (binding.replaces !== void 0 && (typeof binding.replaces !== "string" || !APPROVAL_ID_PATTERN.test(binding.replaces))) return "what it replaces is not an approval id";
|
|
8865
|
+
if (!isStrings$1(binding.warnings)) return "its warnings are unreadable";
|
|
8866
|
+
if (canonicalJson$1(binding.warnings) !== canonicalJson$1(warnings)) return "its warnings are not the ones its contents give";
|
|
8867
|
+
return null;
|
|
8868
|
+
}
|
|
8869
|
+
/** A copy of `binding` holding exactly its own fields, or BAD_DATA naming why it is not one (`standingBindingProblem`). */
|
|
8870
|
+
function canonicalStandingBinding(binding) {
|
|
8871
|
+
const problem = standingBindingProblem(binding);
|
|
8872
|
+
if (problem !== null) throw new CommsError("BAD_DATA", `nothing was prepared: ${problem}`, {
|
|
8873
|
+
hint: "This is a bug — please report it.",
|
|
8874
|
+
details: { reason: "binding-invalid" }
|
|
8875
|
+
});
|
|
8876
|
+
return {
|
|
8877
|
+
summary: binding.summary,
|
|
8878
|
+
target: {
|
|
8879
|
+
kind: "account",
|
|
8880
|
+
name: binding.target.name,
|
|
8881
|
+
id: binding.target.id
|
|
8882
|
+
},
|
|
8883
|
+
contents: binding.contents.map((content) => ({
|
|
8884
|
+
kind: content.kind,
|
|
8885
|
+
text: content.text,
|
|
8886
|
+
digest: content.digest
|
|
8887
|
+
})),
|
|
8888
|
+
places: binding.places.kind === "threads-i-am-in" ? { kind: "threads-i-am-in" } : {
|
|
8889
|
+
kind: "exact",
|
|
8890
|
+
places: binding.places.places.map((place) => ({
|
|
8891
|
+
id: place.id,
|
|
8892
|
+
name: place.name,
|
|
8893
|
+
members: place.members,
|
|
8894
|
+
shared: {
|
|
8895
|
+
extShared: place.shared.extShared,
|
|
8896
|
+
pendingExtShared: place.shared.pendingExtShared,
|
|
8897
|
+
externalMembers: place.shared.externalMembers
|
|
8898
|
+
}
|
|
8899
|
+
}))
|
|
8900
|
+
},
|
|
8901
|
+
limits: { ...binding.limits },
|
|
8902
|
+
proposedBy: binding.proposedBy,
|
|
8903
|
+
...binding.replaces === void 0 ? {} : { replaces: binding.replaces },
|
|
8904
|
+
warnings: [...binding.warnings]
|
|
8905
|
+
};
|
|
8906
|
+
}
|
|
8907
|
+
/**
|
|
8908
|
+
* Why `narrowed`, stored beside `binding`, is not a narrowing of it, or null: each key what a narrowing holds, each
|
|
8909
|
+
* content and place one the binding has, and no limit above the binding's or outside the table. Not bound into the
|
|
8910
|
+
* record's digest, since it changes after approval; only ever able to narrow, so a hand that edits it can only take away.
|
|
8911
|
+
*/
|
|
8912
|
+
function standingNarrowingProblem(binding, narrowed) {
|
|
8913
|
+
if (narrowed === void 0) return null;
|
|
8914
|
+
if (!isRecord$3(narrowed)) return "it is not a narrowing";
|
|
8915
|
+
for (const key of Object.keys(narrowed)) if (![
|
|
8916
|
+
"keep",
|
|
8917
|
+
"places",
|
|
8918
|
+
"limits"
|
|
8919
|
+
].includes(key)) return "it is not a narrowing";
|
|
8920
|
+
if (narrowed.keep !== void 0 && !isStrings$1(narrowed.keep)) return "it keeps something that is not a digest";
|
|
8921
|
+
if (narrowed.places !== void 0 && !isStrings$1(narrowed.places)) return "it keeps something that is not a place";
|
|
8922
|
+
if (narrowed.limits !== void 0) {
|
|
8923
|
+
if (!isRecord$3(narrowed.limits)) return "its limits are unreadable";
|
|
8924
|
+
for (const [name, value] of Object.entries(narrowed.limits)) {
|
|
8925
|
+
if (!LIMIT_NAMES$1.includes(name)) return "it narrows a limit there is none of";
|
|
8926
|
+
const limit = name;
|
|
8927
|
+
if (!Number.isSafeInteger(value) || value < STANDING_LIMITS[limit].min) return "a limit is outside the table";
|
|
8928
|
+
if (value > binding.limits[limit]) return "it raises a limit";
|
|
8929
|
+
}
|
|
8930
|
+
}
|
|
8931
|
+
try {
|
|
8932
|
+
effectiveStanding(binding, narrowed);
|
|
8933
|
+
} catch {
|
|
8934
|
+
return "it keeps what the standing approval does not hold";
|
|
8935
|
+
}
|
|
8936
|
+
return null;
|
|
8937
|
+
}
|
|
8938
|
+
/** Whether a place recorded as `after` is one `before` approved: the same id, shared the same, and no bigger a room. */
|
|
8939
|
+
function placeCovered(before, after) {
|
|
8940
|
+
const held = before.find((place) => place.id === after.id);
|
|
8941
|
+
if (held === void 0) return false;
|
|
8942
|
+
if (canonicalJson$1(held.shared) !== canonicalJson$1(after.shared)) return false;
|
|
8943
|
+
return after.members < 50 || held.members >= 50;
|
|
8944
|
+
}
|
|
8945
|
+
/**
|
|
8946
|
+
* What `after` does to `before` (S8): `widens` with any content digest or exact place `before` does not hold — a place
|
|
8947
|
+
* shared differently, or grown to 50 or more members, is not the place it approved — a swap between exact places and
|
|
8948
|
+
* `threads-i-am-in` either way, another account, any higher limit, or a later expiry; `same` when it permits exactly
|
|
8949
|
+
* what `before` does; anything else that differs `narrows`. Unclear is a widening: it costs a person a code, never a
|
|
8950
|
+
* yes they did not give.
|
|
8951
|
+
*/
|
|
8952
|
+
function classifyStandingChange(before, after) {
|
|
8953
|
+
if (before.target.id !== after.target.id) return "widens";
|
|
8954
|
+
const held = new Set(before.contents.map((content) => content.digest));
|
|
8955
|
+
if (after.contents.some((content) => !held.has(content.digest))) return "widens";
|
|
8956
|
+
if (before.places.kind !== after.places.kind) return "widens";
|
|
8957
|
+
if (before.places.kind === "exact" && after.places.kind === "exact") {
|
|
8958
|
+
const approved = before.places.places;
|
|
8959
|
+
if (after.places.places.some((place) => !placeCovered(approved, place))) return "widens";
|
|
8960
|
+
}
|
|
8961
|
+
for (const name of LIMIT_NAMES$1) if (after.limits[name] > before.limits[name]) return "widens";
|
|
8962
|
+
const kept = new Set(after.contents.map((content) => content.digest));
|
|
8963
|
+
const fewerContents = before.contents.some((content) => !kept.has(content.digest));
|
|
8964
|
+
const afterPlaces = after.places.kind === "exact" ? after.places.places : [];
|
|
8965
|
+
const fewerPlaces = before.places.kind === "exact" && before.places.places.some((place) => !afterPlaces.some((now) => now.id === place.id));
|
|
8966
|
+
const lower = LIMIT_NAMES$1.some((name) => after.limits[name] < before.limits[name]);
|
|
8967
|
+
return fewerContents || fewerPlaces || lower ? "narrows" : "same";
|
|
8968
|
+
}
|
|
8969
|
+
Object.freeze({
|
|
8970
|
+
"not-fresh": "the message is older than 15 minutes, or than this standing approval",
|
|
8971
|
+
"not-newest": "the message is not the newest from someone else in its thread or channel",
|
|
8972
|
+
"already-acknowledged": "this message or thread was already acknowledged under a standing approval",
|
|
8973
|
+
"not-human": "the message, or its thread's first message, is not from a person",
|
|
8974
|
+
interactive: "the message has buttons or menus, or came from a workflow",
|
|
8975
|
+
"place-not-covered": "this standing approval does not cover this place",
|
|
8976
|
+
"big-room": "this place has 50 or more members, and was not approved with that count",
|
|
8977
|
+
"count-unreadable": "how many people are in this place could not be read",
|
|
8978
|
+
external: "someone from another organisation is in this place",
|
|
8979
|
+
"shared-state-changed": "who shares this place changed since it was approved",
|
|
8980
|
+
"reaction-as-person": "a standing reaction goes only as the agent app, and here it would go as you",
|
|
8981
|
+
"app-not-member": "the agent app is not a member of this place, or whether it is could not be read",
|
|
8982
|
+
"content-not-held": "this standing approval holds no such content",
|
|
8983
|
+
"clock-behind": "the clock is earlier than this account's last standing act",
|
|
8984
|
+
unreadable: "the message or its thread could not be read"
|
|
8985
|
+
});
|
|
8986
|
+
Object.freeze({
|
|
8987
|
+
perThread: "per thread",
|
|
8988
|
+
perHour: "an hour",
|
|
8989
|
+
perDay: "a day",
|
|
8990
|
+
perSender: "a day on messages from one person"
|
|
8991
|
+
});
|
|
8992
|
+
/**
|
|
8297
8993
|
* The digest a download's question is bound to: the account, the download, the request, the files it listed and the
|
|
8298
8994
|
* names it showed them under.
|
|
8299
8995
|
*
|
|
@@ -8440,6 +9136,19 @@ function refuseDisclosure(code, reason, record, hint) {
|
|
|
8440
9136
|
} : {}
|
|
8441
9137
|
});
|
|
8442
9138
|
}
|
|
9139
|
+
/**
|
|
9140
|
+
* The same refusal for a standing approval, which sends only through its children: nothing was sent under it. Its hint
|
|
9141
|
+
* never names a command that makes or widens one — only the person does that, at their terminal.
|
|
9142
|
+
*/
|
|
9143
|
+
function refuseStanding(code, reason, record, hint) {
|
|
9144
|
+
return new CommsError(code, `nothing was sent: ${reason}`, {
|
|
9145
|
+
hint: hint ?? "This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal.",
|
|
9146
|
+
details: record ? {
|
|
9147
|
+
approvalId: record.approvalId,
|
|
9148
|
+
state: record.state
|
|
9149
|
+
} : {}
|
|
9150
|
+
});
|
|
9151
|
+
}
|
|
8443
9152
|
/** The refusal in the words of the record's own kind. */
|
|
8444
9153
|
function refusalFor(record) {
|
|
8445
9154
|
switch (approvalKind(record)) {
|
|
@@ -8447,6 +9156,7 @@ function refusalFor(record) {
|
|
|
8447
9156
|
case "change": return refuseChange;
|
|
8448
9157
|
case "download": return refuseDownload;
|
|
8449
9158
|
case "disclosure": return refuseDisclosure;
|
|
9159
|
+
case "standing": return refuseStanding;
|
|
8450
9160
|
}
|
|
8451
9161
|
}
|
|
8452
9162
|
/**
|
|
@@ -8497,6 +9207,14 @@ function cancelledClaim(record) {
|
|
|
8497
9207
|
reason: "cancelled"
|
|
8498
9208
|
}
|
|
8499
9209
|
});
|
|
9210
|
+
case "standing": return new CommsError("USAGE", "cancelled: nothing was sent", {
|
|
9211
|
+
hint: "The standing approval was not used: it is as it was.",
|
|
9212
|
+
details: {
|
|
9213
|
+
approvalId: record.approvalId,
|
|
9214
|
+
state: record.state,
|
|
9215
|
+
reason: "cancelled"
|
|
9216
|
+
}
|
|
9217
|
+
});
|
|
8500
9218
|
}
|
|
8501
9219
|
}
|
|
8502
9220
|
/** The refusal of a renewal or an outcome from a call that does not hold the send's claim. It names no token. */
|
|
@@ -8616,7 +9334,11 @@ var ApprovalStore = class {
|
|
|
8616
9334
|
reason: CLOCK_ANOMALY,
|
|
8617
9335
|
expiredAt: observed.toISOString()
|
|
8618
9336
|
};
|
|
8619
|
-
|
|
9337
|
+
let deadline = record.state === "approved" && record.kind !== "download" && record.usableUntil !== void 0 ? record.usableUntil : record.expiresAt;
|
|
9338
|
+
if (record.kind === "standing" && record.state === "approved" && record.approvedAt !== void 0 && record.standing) {
|
|
9339
|
+
const narrowed = Date.parse(record.approvedAt) + standingDays(record.standing, record.narrowed) * STANDING_DAY_MS;
|
|
9340
|
+
if (narrowed < Date.parse(deadline)) deadline = new Date(narrowed).toISOString();
|
|
9341
|
+
}
|
|
8620
9342
|
if (now >= Date.parse(deadline)) return {
|
|
8621
9343
|
...record,
|
|
8622
9344
|
state: "expired",
|
|
@@ -8784,6 +9506,7 @@ var ApprovalStore = class {
|
|
|
8784
9506
|
const objectOf = (record) => approvalObjectOf(record, live, this.#now());
|
|
8785
9507
|
let next;
|
|
8786
9508
|
try {
|
|
9509
|
+
await options.before?.(current, outcome);
|
|
8787
9510
|
next = decide(current, outcome, live);
|
|
8788
9511
|
} catch (error) {
|
|
8789
9512
|
throw withApproval(error, objectOf(current));
|
|
@@ -8824,7 +9547,7 @@ var ApprovalStore = class {
|
|
|
8824
9547
|
usedSaid: options.usedSaid
|
|
8825
9548
|
});
|
|
8826
9549
|
if (found.form === "v2" && derived.form === "v2") {
|
|
8827
|
-
const current = outcome.revokes && action !== "inspect" && action !== "wait"
|
|
9550
|
+
const current = outcome.revokes && outcome.record !== null && (action !== "inspect" && action !== "wait" || writtenAtALook(outcome)) && outcome.record !== null ? outcome.record : derived.record;
|
|
8828
9551
|
if (current.state !== found.record.state) await this.#write({
|
|
8829
9552
|
...current,
|
|
8830
9553
|
updatedAt: this.#now().toISOString()
|
|
@@ -8908,6 +9631,10 @@ var ApprovalStore = class {
|
|
|
8908
9631
|
hint: "Nothing more is needed from the person: complete the same activation with the same approval.",
|
|
8909
9632
|
details: detail
|
|
8910
9633
|
});
|
|
9634
|
+
case "standing": return new CommsError("USAGE", "nothing was sent: it is approved already, and a person approves it once", {
|
|
9635
|
+
hint: "Nothing more is needed from the person: the standing approval is in force until it ends.",
|
|
9636
|
+
details: detail
|
|
9637
|
+
});
|
|
8911
9638
|
}
|
|
8912
9639
|
}
|
|
8913
9640
|
/** Issues a new challenge to show a human; only its hash is kept. */
|
|
@@ -9068,7 +9795,7 @@ var ApprovalStore = class {
|
|
|
9068
9795
|
async approve(approvalId, via, live, answer, kind = "send", _platform = process.platform, options = {}) {
|
|
9069
9796
|
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");
|
|
9070
9797
|
let failure = null;
|
|
9071
|
-
const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome) => {
|
|
9798
|
+
const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome, gate) => {
|
|
9072
9799
|
if (outcome.error) throw outcome.error;
|
|
9073
9800
|
if (current.state !== "pending") throw this.#alreadyApproved(current, outcome);
|
|
9074
9801
|
if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
|
|
@@ -9085,10 +9812,22 @@ var ApprovalStore = class {
|
|
|
9085
9812
|
revokedAt: at.toISOString()
|
|
9086
9813
|
};
|
|
9087
9814
|
}
|
|
9815
|
+
if (kind === "standing" && via !== "terminal") {
|
|
9816
|
+
failure = {
|
|
9817
|
+
code: "APPROVAL_VOID",
|
|
9818
|
+
reason: "a standing approval is approved only with its code at a terminal, and this was not approved at a terminal"
|
|
9819
|
+
};
|
|
9820
|
+
return {
|
|
9821
|
+
...current,
|
|
9822
|
+
state: "revoked",
|
|
9823
|
+
reason: failure.reason,
|
|
9824
|
+
revokedAt: at.toISOString()
|
|
9825
|
+
};
|
|
9826
|
+
}
|
|
9088
9827
|
if (live.draftMessageId !== current.draftMessageId || live.contentDigest !== current.contentDigest) {
|
|
9089
9828
|
failure = {
|
|
9090
9829
|
code: "APPROVAL_VOID",
|
|
9091
|
-
reason: kind === "change" ? "the change shown is not the one the approval was prepared for" : "the draft changed after the preview was prepared"
|
|
9830
|
+
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"
|
|
9092
9831
|
};
|
|
9093
9832
|
return {
|
|
9094
9833
|
...current,
|
|
@@ -9128,7 +9867,8 @@ var ApprovalStore = class {
|
|
|
9128
9867
|
approvedAt: at.toISOString(),
|
|
9129
9868
|
usableUntil: new Date(at.getTime() + (current.approvedMs ?? APPROVAL_LIFETIMES.approved)).toISOString(),
|
|
9130
9869
|
approvedBindingDigest: current.bindingDigest,
|
|
9131
|
-
challengeHash: void 0
|
|
9870
|
+
challengeHash: void 0,
|
|
9871
|
+
...kind === "standing" ? { approvedChangePolicy: gate?.changePolicy ?? "chat" } : {}
|
|
9132
9872
|
};
|
|
9133
9873
|
}, { usedSaid: options.usedSaid });
|
|
9134
9874
|
const failed = failure;
|
|
@@ -9147,6 +9887,7 @@ var ApprovalStore = class {
|
|
|
9147
9887
|
*/
|
|
9148
9888
|
async claimForSend(approvalId, live, options = {}) {
|
|
9149
9889
|
let failure = null;
|
|
9890
|
+
let parentProblem = null;
|
|
9150
9891
|
const done = await this.#transition(approvalId, {
|
|
9151
9892
|
kind: "send",
|
|
9152
9893
|
owner: live.inboxId
|
|
@@ -9167,6 +9908,7 @@ var ApprovalStore = class {
|
|
|
9167
9908
|
challengeHash: void 0
|
|
9168
9909
|
};
|
|
9169
9910
|
};
|
|
9911
|
+
if (parentProblem !== null) return voidWith("APPROVAL_VOID", parentProblem);
|
|
9170
9912
|
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");
|
|
9171
9913
|
if (live.draftMessageId !== current.draftMessageId) return voidWith("APPROVAL_VOID", "the draft was edited after the preview");
|
|
9172
9914
|
if (live.contentDigest !== current.contentDigest) return voidWith("APPROVAL_VOID", "the draft content changed after the preview");
|
|
@@ -9178,7 +9920,12 @@ var ApprovalStore = class {
|
|
|
9178
9920
|
state: "sending",
|
|
9179
9921
|
sendingAt: at
|
|
9180
9922
|
};
|
|
9181
|
-
}, {
|
|
9923
|
+
}, {
|
|
9924
|
+
usedSaid: options.usedSaid,
|
|
9925
|
+
before: async (current, outcome) => {
|
|
9926
|
+
if (outcome.error === void 0 && current.route === "standing" && current.state === "approved") parentProblem = await this.#standingParentProblem(current);
|
|
9927
|
+
}
|
|
9928
|
+
});
|
|
9182
9929
|
const failed = failure;
|
|
9183
9930
|
if (failed) throw refusedAfter(refuse(failed.code, failed.reason, done.record), done);
|
|
9184
9931
|
const claimToken = randomBytes(16).toString("hex");
|
|
@@ -9191,6 +9938,31 @@ var ApprovalStore = class {
|
|
|
9191
9938
|
};
|
|
9192
9939
|
}
|
|
9193
9940
|
/**
|
|
9941
|
+
* Why a standing child may not be claimed now, or null (design 2026-10-19-standing-approvals, S9, the lead's call after
|
|
9942
|
+
* review): its standing approval read again, under that one's own lock — ended by any of the store's rules, an epoch
|
|
9943
|
+
* or a tightened change policy among them, or narrowed so that it no longer holds the child's content or covers its
|
|
9944
|
+
* place. Called with the child's lock held: no lock is ever taken the other way round.
|
|
9945
|
+
*/
|
|
9946
|
+
async #standingParentProblem(child) {
|
|
9947
|
+
const use = child.standingUse;
|
|
9948
|
+
if (child.standingId === void 0 || use === void 0) return "it names no standing approval it is a use of";
|
|
9949
|
+
let seen;
|
|
9950
|
+
try {
|
|
9951
|
+
seen = await this.inspect(child.standingId, {
|
|
9952
|
+
kind: "standing",
|
|
9953
|
+
owner: child.inboxId
|
|
9954
|
+
}, { action: "claim" });
|
|
9955
|
+
} catch {
|
|
9956
|
+
return "its standing approval could not be read";
|
|
9957
|
+
}
|
|
9958
|
+
const parent = seen.outcome.record;
|
|
9959
|
+
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})`}`;
|
|
9960
|
+
const inForce = effectiveStanding(parent.standing, parent.narrowed);
|
|
9961
|
+
if (!inForce.contents.some((content) => content.digest === use.content)) return "its standing approval no longer holds what it sends";
|
|
9962
|
+
if (inForce.places.kind === "exact" && !inForce.places.places.some((place) => place.id === use.channel)) return "its standing approval no longer covers where it goes";
|
|
9963
|
+
return null;
|
|
9964
|
+
}
|
|
9965
|
+
/**
|
|
9194
9966
|
* The file system's O_EXCL is the single-use guarantee, independent of the lock. A send's marker also holds its
|
|
9195
9967
|
* claim token, the one thing that lets the claimant — and nobody else — renew its lease and record its outcome.
|
|
9196
9968
|
*/
|
|
@@ -9352,6 +10124,208 @@ var ApprovalStore = class {
|
|
|
9352
10124
|
return done.record;
|
|
9353
10125
|
}
|
|
9354
10126
|
/**
|
|
10127
|
+
* A standing approval (design 2026-10-19-standing-approvals, S8), pending, bound to `standingDigest` of its binding —
|
|
10128
|
+
* computed here, never taken from the caller — and to the send epoch of its account and the standing epoch, both as
|
|
10129
|
+
* read when it is made.
|
|
10130
|
+
*
|
|
10131
|
+
* Its route is `confirm` and its `requiredPolicy` `confirm` whatever the change policy is, because what it permits
|
|
10132
|
+
* happens with nobody asked: it is approved only with the code typed at a terminal (`approve`, `via: 'terminal'`),
|
|
10133
|
+
* never by a yes in the chat, a client's form, or an answer carried in a call. It lasts its days once approved
|
|
10134
|
+
* (`approvedMs`). Nothing ever claims it: each use mints a child (`createStandingChild`).
|
|
10135
|
+
*
|
|
10136
|
+
* The binding is checked before anything is written (`canonicalStandingBinding`): a custom emoji, a reply with a
|
|
10137
|
+
* link, a limit outside the table or a place that is no Slack id is refused, however the caller assembled it.
|
|
10138
|
+
*/
|
|
10139
|
+
async createStanding(input) {
|
|
10140
|
+
const standing = canonicalStandingBinding(input.binding);
|
|
10141
|
+
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." });
|
|
10142
|
+
requireEpochs(input.sendEpoch, input.standingEpoch);
|
|
10143
|
+
requireKnownChannel("slack");
|
|
10144
|
+
await this.ensurePruned();
|
|
10145
|
+
const now = this.#now();
|
|
10146
|
+
const digest = standingDigest(standing);
|
|
10147
|
+
const pendingMs = APPROVAL_LIFETIMES.confirm;
|
|
10148
|
+
const record = this.#bound({
|
|
10149
|
+
approvalId: newApprovalId(),
|
|
10150
|
+
kind: "standing",
|
|
10151
|
+
digestVersion: 2,
|
|
10152
|
+
channel: "slack",
|
|
10153
|
+
ownerScope: "owner",
|
|
10154
|
+
route: "confirm",
|
|
10155
|
+
pendingMs,
|
|
10156
|
+
approvedMs: standing.limits.expiresDays * STANDING_DAY_MS,
|
|
10157
|
+
inboxId: standing.target.id,
|
|
10158
|
+
inboxSub: input.inboxSub,
|
|
10159
|
+
draftId: "standing",
|
|
10160
|
+
draftMessageId: digest,
|
|
10161
|
+
contentDigest: digest,
|
|
10162
|
+
sendEpoch: input.sendEpoch,
|
|
10163
|
+
standingEpoch: input.standingEpoch,
|
|
10164
|
+
policy: "confirm",
|
|
10165
|
+
requiredPolicy: "confirm",
|
|
10166
|
+
riskFlags: [],
|
|
10167
|
+
expect: {
|
|
10168
|
+
to: [],
|
|
10169
|
+
cc: [],
|
|
10170
|
+
bcc: [],
|
|
10171
|
+
subject: standing.summary
|
|
10172
|
+
},
|
|
10173
|
+
challengeAttempts: 0,
|
|
10174
|
+
state: "pending",
|
|
10175
|
+
createdAt: now.toISOString(),
|
|
10176
|
+
expiresAt: new Date(now.getTime() + pendingMs).toISOString(),
|
|
10177
|
+
updatedAt: now.toISOString(),
|
|
10178
|
+
standing
|
|
10179
|
+
});
|
|
10180
|
+
await this.#write(record);
|
|
10181
|
+
return record;
|
|
10182
|
+
}
|
|
10183
|
+
/**
|
|
10184
|
+
* Runs `use` under the standing record's own lock, with the record as it reads there and its classification for a
|
|
10185
|
+
* use (`claim`): its expiry, a revocation the configuration derives — its account removed, `never`, a send epoch or
|
|
10186
|
+
* a standing epoch behind the live one — written before `use` runs, and `outcome.error` set for every state that
|
|
10187
|
+
* refuses a use. `owner` is the account the caller acts for: any other's record is the one `NOT_FOUND`.
|
|
10188
|
+
*
|
|
10189
|
+
* What `use` decides is decided while no revocation, narrowing or approval of this record can land. `use` must not
|
|
10190
|
+
* take this record's lock again: it is held.
|
|
10191
|
+
*/
|
|
10192
|
+
async withStanding(standingId, owner, use) {
|
|
10193
|
+
const path = this.#path(standingId);
|
|
10194
|
+
const expect = {
|
|
10195
|
+
kind: "standing",
|
|
10196
|
+
owner
|
|
10197
|
+
};
|
|
10198
|
+
return withFileLock(`${path}.lock`, async () => {
|
|
10199
|
+
const config = await this.#config();
|
|
10200
|
+
const found = await this.#read(standingId, config);
|
|
10201
|
+
if (found === null || !matchesExpectation(found, expect)) throw approvalNotFound(standingId, "standing");
|
|
10202
|
+
const derived = this.#derived(found);
|
|
10203
|
+
const outcome = approvalOutcome(derived, {
|
|
10204
|
+
action: "claim",
|
|
10205
|
+
live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
|
|
10206
|
+
now: this.#now()
|
|
10207
|
+
});
|
|
10208
|
+
if (found.form !== "v2" || derived.form !== "v2" || outcome.record === null) throw outcome.error ?? integrityRefusal(found);
|
|
10209
|
+
const current = outcome.revokes ? outcome.record : derived.record;
|
|
10210
|
+
if (current.state !== found.record.state) await this.#write({
|
|
10211
|
+
...current,
|
|
10212
|
+
updatedAt: this.#now().toISOString()
|
|
10213
|
+
});
|
|
10214
|
+
return use(current, outcome);
|
|
10215
|
+
});
|
|
10216
|
+
}
|
|
10217
|
+
/**
|
|
10218
|
+
* One use of a standing approval (S9): a send record, approved the moment it is made — `route: 'standing'`,
|
|
10219
|
+
* `approvedVia: 'standing'`, `approvedAt` its creation — bound to the standing id, the standing epoch and the send
|
|
10220
|
+
* epoch, and usable once, within `APPROVAL_LIFETIMES.standingChild`. It then takes the one send path every send
|
|
10221
|
+
* takes: `claimForSend`, its O_EXCL marker, the fence and the outcome.
|
|
10222
|
+
*
|
|
10223
|
+
* Minted only by the use (`mintStandingUse`), under the account's acknowledgement ledger lock and then the standing
|
|
10224
|
+
* record's, after every check there. Checked again here, read without that lock — which the caller holds: the
|
|
10225
|
+
* standing record must be this account's, approved at a terminal, under the same epochs and the same account user,
|
|
10226
|
+
* and not ended, or nothing is written. A child never names anything but what its use was checked for.
|
|
10227
|
+
*/
|
|
10228
|
+
async createStandingChild(input) {
|
|
10229
|
+
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." });
|
|
10230
|
+
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." });
|
|
10231
|
+
requireEpochs(input.sendEpoch, input.standingEpoch);
|
|
10232
|
+
requireKnownChannel("slack");
|
|
10233
|
+
const config = await this.#config();
|
|
10234
|
+
if (input.approvalId !== void 0) {
|
|
10235
|
+
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." });
|
|
10236
|
+
}
|
|
10237
|
+
const found = await this.#read(input.standingId, config);
|
|
10238
|
+
if (found === null || !matchesExpectation(found, {
|
|
10239
|
+
kind: "standing",
|
|
10240
|
+
owner: input.inboxId
|
|
10241
|
+
})) throw approvalNotFound(input.standingId, "standing");
|
|
10242
|
+
if (found.form !== "v2") throw integrityRefusal(found);
|
|
10243
|
+
const parent = this.#derive(found.record);
|
|
10244
|
+
const live = config === null ? null : liveGateOf(config, parent);
|
|
10245
|
+
const outcome = approvalOutcome({
|
|
10246
|
+
form: "v2",
|
|
10247
|
+
record: parent
|
|
10248
|
+
}, {
|
|
10249
|
+
action: "claim",
|
|
10250
|
+
live,
|
|
10251
|
+
now: this.#now()
|
|
10252
|
+
});
|
|
10253
|
+
if (outcome.error) throw outcome.error;
|
|
10254
|
+
const standing = outcome.record ?? parent;
|
|
10255
|
+
if (standing.state === "pending") throw refuseStanding("APPROVAL_PENDING", "the standing approval waits for the person to approve it at a terminal", standing);
|
|
10256
|
+
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;
|
|
10257
|
+
if (unbound !== null) throw refuseStanding("APPROVAL_VOID", unbound, standing);
|
|
10258
|
+
const now = this.#now();
|
|
10259
|
+
const at = now.toISOString();
|
|
10260
|
+
const lifetime = APPROVAL_LIFETIMES.standingChild;
|
|
10261
|
+
const until = new Date(now.getTime() + lifetime).toISOString();
|
|
10262
|
+
const sealed = this.#bound({
|
|
10263
|
+
approvalId: input.approvalId ?? newApprovalId(),
|
|
10264
|
+
kind: "send",
|
|
10265
|
+
digestVersion: 2,
|
|
10266
|
+
channel: "slack",
|
|
10267
|
+
ownerScope: "owner",
|
|
10268
|
+
route: "standing",
|
|
10269
|
+
pendingMs: lifetime,
|
|
10270
|
+
approvedMs: lifetime,
|
|
10271
|
+
inboxId: input.inboxId,
|
|
10272
|
+
inboxSub: input.inboxSub,
|
|
10273
|
+
draftId: input.draftId,
|
|
10274
|
+
draftMessageId: input.draftMessageId,
|
|
10275
|
+
contentDigest: input.contentDigest,
|
|
10276
|
+
sendEpoch: input.sendEpoch,
|
|
10277
|
+
standingEpoch: input.standingEpoch,
|
|
10278
|
+
standingId: input.standingId,
|
|
10279
|
+
standingUse: {
|
|
10280
|
+
channel: input.use.channel,
|
|
10281
|
+
thread: input.use.thread,
|
|
10282
|
+
content: input.use.content
|
|
10283
|
+
},
|
|
10284
|
+
policy: live?.sendPolicy === "chat" ? "chat" : "confirm",
|
|
10285
|
+
requiredPolicy: "confirm",
|
|
10286
|
+
riskFlags: [...input.riskFlags],
|
|
10287
|
+
expect: {
|
|
10288
|
+
to: [...input.expect.to],
|
|
10289
|
+
cc: [...input.expect.cc],
|
|
10290
|
+
bcc: [...input.expect.bcc],
|
|
10291
|
+
subject: input.expect.subject
|
|
10292
|
+
},
|
|
10293
|
+
challengeAttempts: 0,
|
|
10294
|
+
state: "approved",
|
|
10295
|
+
createdAt: at,
|
|
10296
|
+
expiresAt: until,
|
|
10297
|
+
updatedAt: at,
|
|
10298
|
+
approvedVia: "standing",
|
|
10299
|
+
approvedAt: at,
|
|
10300
|
+
usableUntil: until
|
|
10301
|
+
});
|
|
10302
|
+
const record = {
|
|
10303
|
+
...sealed,
|
|
10304
|
+
approvedBindingDigest: sealed.bindingDigest
|
|
10305
|
+
};
|
|
10306
|
+
await this.#write(record);
|
|
10307
|
+
return record;
|
|
10308
|
+
}
|
|
10309
|
+
/**
|
|
10310
|
+
* Narrows a standing approval at once (S8): fewer contents or places, a lower limit, an earlier end. Nothing is asked
|
|
10311
|
+
* for, because nothing more is permitted: anything `classifyStandingChange` calls a widening of what is in force is
|
|
10312
|
+
* refused with USAGE, and the record is left as it was. The binding the person approved stays the record's own, and
|
|
10313
|
+
* what is narrowed is kept beside it, where it can only take away.
|
|
10314
|
+
*/
|
|
10315
|
+
async narrowStanding(standingId, owner, narrowing) {
|
|
10316
|
+
return (await this.#transition(standingId, {
|
|
10317
|
+
kind: "standing",
|
|
10318
|
+
owner
|
|
10319
|
+
}, "revoke", (current, outcome) => {
|
|
10320
|
+
if (outcome.error) throw outcome.error;
|
|
10321
|
+
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);
|
|
10322
|
+
return {
|
|
10323
|
+
...current,
|
|
10324
|
+
narrowed: narrowedFurther(current.standing, current.narrowed, narrowing)
|
|
10325
|
+
};
|
|
10326
|
+
})).record;
|
|
10327
|
+
}
|
|
10328
|
+
/**
|
|
9355
10329
|
* A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
|
|
9356
10330
|
* caller, as a change's digest is. It stands in for the draft revision too.
|
|
9357
10331
|
*
|
|
@@ -9636,13 +10610,28 @@ var ApprovalStore = class {
|
|
|
9636
10610
|
...derived.record,
|
|
9637
10611
|
updatedAt: this.#now().toISOString()
|
|
9638
10612
|
});
|
|
10613
|
+
const outcome = approvalOutcome(derived, {
|
|
10614
|
+
action: "inspect",
|
|
10615
|
+
live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
|
|
10616
|
+
now: this.#now()
|
|
10617
|
+
});
|
|
10618
|
+
if (writtenAtALook(outcome) && outcome.record !== null) {
|
|
10619
|
+
const ended = {
|
|
10620
|
+
...outcome.record,
|
|
10621
|
+
updatedAt: this.#now().toISOString()
|
|
10622
|
+
};
|
|
10623
|
+
await this.#write(ended);
|
|
10624
|
+
return {
|
|
10625
|
+
stored: {
|
|
10626
|
+
form: "v2",
|
|
10627
|
+
record: ended
|
|
10628
|
+
},
|
|
10629
|
+
outcome
|
|
10630
|
+
};
|
|
10631
|
+
}
|
|
9639
10632
|
return {
|
|
9640
10633
|
stored: derived,
|
|
9641
|
-
outcome
|
|
9642
|
-
action: "inspect",
|
|
9643
|
-
live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
|
|
9644
|
-
now: this.#now()
|
|
9645
|
-
})
|
|
10634
|
+
outcome
|
|
9646
10635
|
};
|
|
9647
10636
|
});
|
|
9648
10637
|
if (seen === null) continue;
|
|
@@ -9681,6 +10670,10 @@ var ApprovalStore = class {
|
|
|
9681
10670
|
return found.sort(byCreation);
|
|
9682
10671
|
}
|
|
9683
10672
|
};
|
|
10673
|
+
/** Both epochs a standing record or child binds: whole numbers of 0 or more, or a programming error. */
|
|
10674
|
+
function requireEpochs(sendEpoch, standingEpoch) {
|
|
10675
|
+
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." });
|
|
10676
|
+
}
|
|
9684
10677
|
/** Oldest first; a record that cannot be read has no time to be ordered by, and follows, by id. */
|
|
9685
10678
|
function byCreation(a, b) {
|
|
9686
10679
|
const createdOf = (stored) => stored.form === "v2" ? stored.record.createdAt : stored.form === "legacy" ? stored.view.createdAt : null;
|
|
@@ -9718,7 +10711,7 @@ function sameExpectation(a, b) {
|
|
|
9718
10711
|
const CLOCK_ANOMALY = "clock-anomaly";
|
|
9719
10712
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
9720
10713
|
/** Exactly what `Date.prototype.toISOString` writes, which is all the store ever writes. */
|
|
9721
|
-
const ISO$
|
|
10714
|
+
const ISO$2 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
9722
10715
|
const STATES$1 = [
|
|
9723
10716
|
"pending",
|
|
9724
10717
|
"approved",
|
|
@@ -9729,7 +10722,10 @@ const STATES$1 = [
|
|
|
9729
10722
|
"expired",
|
|
9730
10723
|
"revoked"
|
|
9731
10724
|
];
|
|
9732
|
-
/**
|
|
10725
|
+
/**
|
|
10726
|
+
* The states each kind can reach. A change or a download is claimed straight to `used`: it is never sent. A standing
|
|
10727
|
+
* approval is never claimed at all — its children are — so it ends expired or revoked.
|
|
10728
|
+
*/
|
|
9733
10729
|
const STATES_OF = {
|
|
9734
10730
|
send: STATES$1,
|
|
9735
10731
|
change: [
|
|
@@ -9745,8 +10741,24 @@ const STATES_OF = {
|
|
|
9745
10741
|
"used",
|
|
9746
10742
|
"expired",
|
|
9747
10743
|
"revoked"
|
|
10744
|
+
],
|
|
10745
|
+
standing: [
|
|
10746
|
+
"pending",
|
|
10747
|
+
"approved",
|
|
10748
|
+
"expired",
|
|
10749
|
+
"revoked"
|
|
9748
10750
|
]
|
|
9749
10751
|
};
|
|
10752
|
+
/** A standing approval's child, approved when it is made: never pending. */
|
|
10753
|
+
const CHILD_STATES = [
|
|
10754
|
+
"approved",
|
|
10755
|
+
"sending",
|
|
10756
|
+
"used",
|
|
10757
|
+
"failed",
|
|
10758
|
+
"unknown",
|
|
10759
|
+
"expired",
|
|
10760
|
+
"revoked"
|
|
10761
|
+
];
|
|
9750
10762
|
/** The send states that went through a claim, and so carry `sendingAt`. */
|
|
9751
10763
|
const CLAIMED_SEND = [
|
|
9752
10764
|
"sending",
|
|
@@ -9754,10 +10766,14 @@ const CLAIMED_SEND = [
|
|
|
9754
10766
|
"failed",
|
|
9755
10767
|
"unknown"
|
|
9756
10768
|
];
|
|
10769
|
+
/** Whether a record is one use of a standing approval: a send on the `standing` route. */
|
|
10770
|
+
function isChild(raw) {
|
|
10771
|
+
return raw.kind === "send" && raw.route === "standing";
|
|
10772
|
+
}
|
|
9757
10773
|
/** A record's timestamp field: absent, invalid, or the time it names. */
|
|
9758
10774
|
function timeOf(value) {
|
|
9759
10775
|
if (value === void 0) return null;
|
|
9760
|
-
if (typeof value !== "string" || !ISO$
|
|
10776
|
+
if (typeof value !== "string" || !ISO$2.test(value)) return "invalid";
|
|
9761
10777
|
const at = Date.parse(value);
|
|
9762
10778
|
return Number.isFinite(at) && new Date(at).toISOString() === value ? at : "invalid";
|
|
9763
10779
|
}
|
|
@@ -9804,7 +10820,7 @@ function checkVerified(record, raw) {
|
|
|
9804
10820
|
if (raw.digestVersion !== 2) return fail("digest-version");
|
|
9805
10821
|
const kind = raw.kind;
|
|
9806
10822
|
if (kind === "disclosure") return checkDisclosure(record, raw);
|
|
9807
|
-
if (kind !== "send" && kind !== "change" && kind !== "download") return fail("wrong-shape");
|
|
10823
|
+
if (kind !== "send" && kind !== "change" && kind !== "download" && kind !== "standing") return fail("wrong-shape");
|
|
9808
10824
|
if (!STATES$1.includes(raw.state)) return fail("state-impossible");
|
|
9809
10825
|
const state = raw.state;
|
|
9810
10826
|
if (typeof raw.contentDigest !== "string" || !HEX64.test(raw.contentDigest)) return fail("content-digest-malformed");
|
|
@@ -9814,10 +10830,16 @@ function checkVerified(record, raw) {
|
|
|
9814
10830
|
if (kind === "download") {
|
|
9815
10831
|
if (record.download === void 0 || downloadDigest(record.download) !== record.contentDigest) return fail("content-digest-mismatch");
|
|
9816
10832
|
}
|
|
10833
|
+
if (kind === "standing") {
|
|
10834
|
+
if (record.standing === void 0 || standingBindingProblem(record.standing) !== null) return fail("wrong-shape");
|
|
10835
|
+
if (standingDigest(record.standing) !== record.contentDigest) return fail("content-digest-mismatch");
|
|
10836
|
+
if (standingNarrowingProblem(record.standing, raw.narrowed) !== null) return fail("narrowing-invalid");
|
|
10837
|
+
} else if (raw.standing !== void 0 || raw.narrowed !== void 0) return fail("wrong-shape");
|
|
9817
10838
|
const identity = checkIdentity(record, raw, kind);
|
|
9818
10839
|
if (identity !== null) return identity;
|
|
9819
10840
|
if (!STATES_OF[kind].includes(state)) return fail("state-impossible");
|
|
9820
|
-
|
|
10841
|
+
if (isChild(raw) && !CHILD_STATES.includes(state)) return fail("state-impossible");
|
|
10842
|
+
const lifetime = checkLifetime(record, raw, kind);
|
|
9821
10843
|
if (lifetime !== null) return lifetime;
|
|
9822
10844
|
const times = {};
|
|
9823
10845
|
for (const field of TIMESTAMPS) {
|
|
@@ -9892,9 +10914,19 @@ function checkIdentity(record, raw, kind) {
|
|
|
9892
10914
|
if (scope !== expected) return fail("identity-contradiction");
|
|
9893
10915
|
if (raw.inboxId !== (expected === "owner" ? target?.id : "")) return fail("identity-contradiction");
|
|
9894
10916
|
}
|
|
9895
|
-
|
|
9896
|
-
|
|
10917
|
+
const wholeEpoch = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
10918
|
+
if (kind === "send" || kind === "standing") {
|
|
10919
|
+
if (!wholeEpoch(raw.sendEpoch)) return fail("identity-contradiction");
|
|
9897
10920
|
} else if (raw.sendEpoch !== void 0) return fail("identity-contradiction");
|
|
10921
|
+
if (kind === "standing" || isChild(raw)) {
|
|
10922
|
+
if (raw.channel !== "slack" || !wholeEpoch(raw.standingEpoch)) return fail("identity-contradiction");
|
|
10923
|
+
} else if (raw.standingEpoch !== void 0) return fail("identity-contradiction");
|
|
10924
|
+
if (isChild(raw)) {
|
|
10925
|
+
if (typeof raw.standingId !== "string" || !APPROVAL_ID_PATTERN.test(raw.standingId)) return fail("identity-contradiction");
|
|
10926
|
+
if (raw.standingId === raw.approvalId) return fail("identity-contradiction");
|
|
10927
|
+
if (standingUseProblem(raw.standingUse) !== null) return fail("identity-contradiction");
|
|
10928
|
+
} else if (raw.standingId !== void 0 || raw.standingUse !== void 0) return fail("identity-contradiction");
|
|
10929
|
+
if (kind === "standing" && record.standing.target.id !== raw.inboxId) return fail("identity-contradiction");
|
|
9898
10930
|
if (kind === "download") {
|
|
9899
10931
|
const download = record.download;
|
|
9900
10932
|
if (download === void 0 || download.target.id !== raw.inboxId) return fail("identity-contradiction");
|
|
@@ -9904,13 +10936,31 @@ function checkIdentity(record, raw, kind) {
|
|
|
9904
10936
|
}
|
|
9905
10937
|
return null;
|
|
9906
10938
|
}
|
|
9907
|
-
/**
|
|
9908
|
-
|
|
10939
|
+
/**
|
|
10940
|
+
* The lifetime profile: what the route says, and nothing the record could have chosen for itself. A standing approval
|
|
10941
|
+
* waits as a `confirm` change does and lasts the whole days its limits name, from 1 to 30; its child lives
|
|
10942
|
+
* `standingChild`, pending and approved alike.
|
|
10943
|
+
*/
|
|
10944
|
+
function checkLifetime(record, raw, kind) {
|
|
9909
10945
|
if (kind === "download") {
|
|
9910
10946
|
if (raw.pendingMs !== APPROVAL_LIFETIMES.download) return fail("lifetime-mismatch");
|
|
9911
10947
|
if (raw.route !== void 0 || raw.approvedMs !== void 0) return fail("lifetime-mismatch");
|
|
9912
10948
|
return null;
|
|
9913
10949
|
}
|
|
10950
|
+
if (kind === "standing") {
|
|
10951
|
+
const days = record.standing.limits.expiresDays;
|
|
10952
|
+
const { min, max } = STANDING_LIMITS.expiresDays;
|
|
10953
|
+
if (raw.route !== "confirm" || raw.pendingMs !== APPROVAL_LIFETIMES.confirm) return fail("lifetime-mismatch");
|
|
10954
|
+
if (!Number.isSafeInteger(days) || days < min || days > max) return fail("lifetime-mismatch");
|
|
10955
|
+
if (raw.approvedMs !== days * 864e5) return fail("lifetime-mismatch");
|
|
10956
|
+
if (raw.policy !== "confirm" || raw.requiredPolicy !== "confirm") return fail("lifetime-mismatch");
|
|
10957
|
+
return null;
|
|
10958
|
+
}
|
|
10959
|
+
if (isChild(raw)) {
|
|
10960
|
+
const lifetime = APPROVAL_LIFETIMES.standingChild;
|
|
10961
|
+
if (raw.pendingMs !== lifetime || raw.approvedMs !== lifetime) return fail("lifetime-mismatch");
|
|
10962
|
+
return null;
|
|
10963
|
+
}
|
|
9914
10964
|
if (raw.route !== "chat" && raw.route !== "confirm") return fail("lifetime-mismatch");
|
|
9915
10965
|
if (raw.pendingMs !== pendingMsOf(raw.route)) return fail("lifetime-mismatch");
|
|
9916
10966
|
if (raw.approvedMs !== APPROVAL_LIFETIMES.approved) return fail("lifetime-mismatch");
|
|
@@ -9960,6 +11010,10 @@ function checkTimestamps(raw, kind, state, times) {
|
|
|
9960
11010
|
if (!(createdAt <= approvedAt && approvedAt < expiresAt)) return fail("timestamp-misordered");
|
|
9961
11011
|
if (usableUntil - approvedAt !== raw.approvedMs) return fail("lifetime-mismatch");
|
|
9962
11012
|
}
|
|
11013
|
+
if (isChild(raw)) {
|
|
11014
|
+
if (approvedAt === void 0) return fail("timestamp-missing");
|
|
11015
|
+
if (approvedAt !== createdAt) return fail("timestamp-misordered");
|
|
11016
|
+
}
|
|
9963
11017
|
const deadline = usableUntil ?? expiresAt;
|
|
9964
11018
|
const since = approvedAt ?? createdAt;
|
|
9965
11019
|
const { sendingAt, sendingHeartbeatAt, usedAt, sentAt, failedAt, revokedAt, expiredAt } = times;
|
|
@@ -9987,7 +11041,9 @@ function checkTimestamps(raw, kind, state, times) {
|
|
|
9987
11041
|
*/
|
|
9988
11042
|
function checkEvidence(record, raw, kind, state) {
|
|
9989
11043
|
const via = raw.approvedVia;
|
|
9990
|
-
if (via !== void 0 && via !== "terminal" && via !== "elicitation") return fail("evidence-invalid");
|
|
11044
|
+
if (via !== void 0 && via !== "terminal" && via !== "elicitation" && via !== "standing") return fail("evidence-invalid");
|
|
11045
|
+
if (via === "standing" !== (isChild(raw) && via !== void 0)) return fail("evidence-invalid");
|
|
11046
|
+
if (raw.approvedChangePolicy !== void 0 && (kind !== "standing" || raw.approvedAt === void 0)) return fail("evidence-contradictory");
|
|
9991
11047
|
if (kind === "download") {
|
|
9992
11048
|
const answer = record.download?.answer;
|
|
9993
11049
|
const answered = via !== void 0 || raw.approvedDigest !== void 0 || answer !== void 0;
|
|
@@ -10005,7 +11061,9 @@ function checkEvidence(record, raw, kind, state) {
|
|
|
10005
11061
|
if (raw.approvedDigest !== void 0) return fail("evidence-contradictory");
|
|
10006
11062
|
if (!(raw.approvedAt !== void 0)) return via !== void 0 || raw.approvedBindingDigest !== void 0 ? fail("evidence-contradictory") : null;
|
|
10007
11063
|
if (via === void 0 || raw.approvedBindingDigest === void 0) return fail("evidence-missing");
|
|
10008
|
-
if (kind === "
|
|
11064
|
+
if (kind === "standing" && raw.approvedChangePolicy !== "chat" && raw.approvedChangePolicy !== "confirm") return fail("evidence-missing");
|
|
11065
|
+
if ((kind === "change" || kind === "standing") && via !== "terminal") return fail("evidence-invalid");
|
|
11066
|
+
if (isChild(raw) && via !== "standing") return fail("evidence-invalid");
|
|
10009
11067
|
if (raw.approvedBindingDigest !== record.bindingDigest) return fail("evidence-contradictory");
|
|
10010
11068
|
return null;
|
|
10011
11069
|
}
|
|
@@ -10025,9 +11083,11 @@ const KINDS$1 = [
|
|
|
10025
11083
|
"download",
|
|
10026
11084
|
"disclosure"
|
|
10027
11085
|
];
|
|
11086
|
+
/** Version 2 alone has standing approvals (design 2026-10-19-standing-approvals): no earlier release wrote one. */
|
|
11087
|
+
const V2_KINDS = [...KINDS$1, "standing"];
|
|
10028
11088
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10029
|
-
const isString = (value) => typeof value === "string";
|
|
10030
|
-
const isStrings = (value) => Array.isArray(value) && value.every(isString);
|
|
11089
|
+
const isString$1 = (value) => typeof value === "string";
|
|
11090
|
+
const isStrings = (value) => Array.isArray(value) && value.every(isString$1);
|
|
10031
11091
|
const optional = (value, check) => value === void 0 || check(value);
|
|
10032
11092
|
function unreadable(approvalId, reason) {
|
|
10033
11093
|
return {
|
|
@@ -10040,22 +11100,22 @@ function unreadable(approvalId, reason) {
|
|
|
10040
11100
|
};
|
|
10041
11101
|
}
|
|
10042
11102
|
function expectationShaped(value) {
|
|
10043
|
-
return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString(value.subject);
|
|
11103
|
+
return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString$1(value.subject);
|
|
10044
11104
|
}
|
|
10045
11105
|
/**
|
|
10046
11106
|
* The types every record shares, version 1 or 2: anything of the wrong type cannot reach the classifier. Values —
|
|
10047
11107
|
* a timestamp that is a string but not a time, a digest that is a string but not hex — are the validator's.
|
|
10048
11108
|
*/
|
|
10049
11109
|
function commonShape(raw) {
|
|
10050
|
-
if (!isString(raw.inboxId)) return "missing-ownership";
|
|
10051
|
-
if (!isString(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
|
|
11110
|
+
if (!isString$1(raw.inboxId)) return "missing-ownership";
|
|
11111
|
+
if (!isString$1(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
|
|
10052
11112
|
for (const field of [
|
|
10053
11113
|
"createdAt",
|
|
10054
11114
|
"expiresAt",
|
|
10055
11115
|
"updatedAt",
|
|
10056
11116
|
"draftId",
|
|
10057
11117
|
"draftMessageId"
|
|
10058
|
-
]) if (!isString(raw[field])) return "wrong-shape";
|
|
11118
|
+
]) if (!isString$1(raw[field])) return "wrong-shape";
|
|
10059
11119
|
if (!expectationShaped(raw.expect)) return "wrong-shape";
|
|
10060
11120
|
for (const field of [
|
|
10061
11121
|
"inboxSub",
|
|
@@ -10066,17 +11126,23 @@ function commonShape(raw) {
|
|
|
10066
11126
|
"reason",
|
|
10067
11127
|
"policy",
|
|
10068
11128
|
"requiredPolicy"
|
|
10069
|
-
]) if (!optional(raw[field], isString)) return "wrong-shape";
|
|
11129
|
+
]) if (!optional(raw[field], isString$1)) return "wrong-shape";
|
|
10070
11130
|
if (!optional(raw.riskFlags, isStrings)) return "wrong-shape";
|
|
10071
11131
|
if (!optional(raw.challengeAttempts, (value) => typeof value === "number")) return "wrong-shape";
|
|
10072
11132
|
const kind = raw.kind ?? "send";
|
|
10073
11133
|
if (kind === "change") {
|
|
10074
11134
|
const change = raw.change;
|
|
10075
|
-
if (!isObject(change) || !isString(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
|
|
11135
|
+
if (!isObject(change) || !isString$1(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
|
|
11136
|
+
}
|
|
11137
|
+
if (kind === "standing") {
|
|
11138
|
+
const standing = raw.standing;
|
|
11139
|
+
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";
|
|
11140
|
+
const narrowed = raw.narrowed;
|
|
11141
|
+
if (!optional(narrowed, (value) => isObject(value) && optional(value.keep, isStrings) && optional(value.places, isStrings) && optional(value.limits, isObject))) return "wrong-shape";
|
|
10076
11142
|
}
|
|
10077
11143
|
if (kind === "download") {
|
|
10078
11144
|
const download = raw.download;
|
|
10079
|
-
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";
|
|
11145
|
+
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";
|
|
10080
11146
|
}
|
|
10081
11147
|
return null;
|
|
10082
11148
|
}
|
|
@@ -10089,7 +11155,7 @@ function disclosureShape(raw) {
|
|
|
10089
11155
|
"expiresAt",
|
|
10090
11156
|
"updatedAt",
|
|
10091
11157
|
"bindingDigest"
|
|
10092
|
-
]) if (!isString(raw[field])) return "wrong-shape";
|
|
11158
|
+
]) if (!isString$1(raw[field])) return "wrong-shape";
|
|
10093
11159
|
for (const field of [
|
|
10094
11160
|
"pendingMs",
|
|
10095
11161
|
"approvedMs",
|
|
@@ -10105,9 +11171,9 @@ function disclosureShape(raw) {
|
|
|
10105
11171
|
"revokedAt",
|
|
10106
11172
|
"expiredAt",
|
|
10107
11173
|
"reason"
|
|
10108
|
-
]) if (!optional(raw[field], isString)) return "wrong-shape";
|
|
11174
|
+
]) if (!optional(raw[field], isString$1)) return "wrong-shape";
|
|
10109
11175
|
const disclosure = raw.disclosure;
|
|
10110
|
-
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";
|
|
11176
|
+
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";
|
|
10111
11177
|
for (const field of [
|
|
10112
11178
|
"channel",
|
|
10113
11179
|
"ownerScope",
|
|
@@ -10122,6 +11188,8 @@ function disclosureShape(raw) {
|
|
|
10122
11188
|
"requiredPolicy",
|
|
10123
11189
|
"riskFlags",
|
|
10124
11190
|
"approvedBindingDigest",
|
|
11191
|
+
"approvedChangePolicy",
|
|
11192
|
+
"standingUse",
|
|
10125
11193
|
"sendingAt",
|
|
10126
11194
|
"sendingHeartbeatAt",
|
|
10127
11195
|
"sentAt",
|
|
@@ -10139,16 +11207,20 @@ function v2Shape(raw) {
|
|
|
10139
11207
|
"channel",
|
|
10140
11208
|
"ownerScope",
|
|
10141
11209
|
"contentDigest"
|
|
10142
|
-
]) if (!optional(raw[field], isString)) return "wrong-shape";
|
|
11210
|
+
]) if (!optional(raw[field], isString$1)) return "wrong-shape";
|
|
10143
11211
|
for (const field of [
|
|
10144
11212
|
"pendingMs",
|
|
10145
11213
|
"approvedMs",
|
|
10146
|
-
"sendEpoch"
|
|
11214
|
+
"sendEpoch",
|
|
11215
|
+
"standingEpoch"
|
|
10147
11216
|
]) if (!optional(raw[field], (value) => typeof value === "number")) return "wrong-shape";
|
|
11217
|
+
if (!optional(raw.standingId, isString$1)) return "wrong-shape";
|
|
11218
|
+
if (!optional(raw.standingUse, isObject)) return "wrong-shape";
|
|
10148
11219
|
for (const field of [
|
|
10149
11220
|
"route",
|
|
10150
11221
|
"bindingDigest",
|
|
10151
11222
|
"approvedBindingDigest",
|
|
11223
|
+
"approvedChangePolicy",
|
|
10152
11224
|
"approvedAt",
|
|
10153
11225
|
"usableUntil",
|
|
10154
11226
|
"sendingAt",
|
|
@@ -10158,7 +11230,7 @@ function v2Shape(raw) {
|
|
|
10158
11230
|
"failedAt",
|
|
10159
11231
|
"revokedAt",
|
|
10160
11232
|
"expiredAt"
|
|
10161
|
-
]) if (!optional(raw[field], isString)) return "wrong-shape";
|
|
11233
|
+
]) if (!optional(raw[field], isString$1)) return "wrong-shape";
|
|
10162
11234
|
return null;
|
|
10163
11235
|
}
|
|
10164
11236
|
/**
|
|
@@ -10174,7 +11246,7 @@ function decodeStored(fileId, text, config, now) {
|
|
|
10174
11246
|
return unreadable(fileId, trimmed === "" || trimmed.startsWith("{") && !trimmed.endsWith("}") ? "truncated" : "invalid-json");
|
|
10175
11247
|
}
|
|
10176
11248
|
if (!isObject(raw)) return unreadable(fileId, "wrong-shape");
|
|
10177
|
-
if (!isString(raw.approvalId)) return unreadable(fileId, "wrong-shape");
|
|
11249
|
+
if (!isString$1(raw.approvalId)) return unreadable(fileId, "wrong-shape");
|
|
10178
11250
|
if (raw.kind === "disclosure") {
|
|
10179
11251
|
if (raw.digestVersion === 1) return unreadable(fileId, "wrong-shape");
|
|
10180
11252
|
const shape = disclosureShape(raw);
|
|
@@ -10196,8 +11268,8 @@ function decodeStored(fileId, text, config, now) {
|
|
|
10196
11268
|
const shape = commonShape(raw);
|
|
10197
11269
|
if (shape !== null) return unreadable(fileId, shape);
|
|
10198
11270
|
if (raw.digestVersion === 1) {
|
|
10199
|
-
if (!optional(raw.kind, (kind) => isString(kind) && KINDS$1.includes(kind))) return unreadable(fileId, "wrong-shape");
|
|
10200
|
-
if (!isString(raw.digest)) return unreadable(fileId, "wrong-shape");
|
|
11271
|
+
if (!optional(raw.kind, (kind) => isString$1(kind) && KINDS$1.includes(kind))) return unreadable(fileId, "wrong-shape");
|
|
11272
|
+
if (!isString$1(raw.digest)) return unreadable(fileId, "wrong-shape");
|
|
10201
11273
|
if (raw.approvalId !== fileId) return unreadable(fileId, "file-name-mismatch");
|
|
10202
11274
|
const record = raw;
|
|
10203
11275
|
return {
|
|
@@ -10206,7 +11278,7 @@ function decodeStored(fileId, text, config, now) {
|
|
|
10206
11278
|
record
|
|
10207
11279
|
};
|
|
10208
11280
|
}
|
|
10209
|
-
if (!isString(raw.kind) || !
|
|
11281
|
+
if (!isString$1(raw.kind) || !V2_KINDS.includes(raw.kind)) return unreadable(fileId, "missing-kind");
|
|
10210
11282
|
const v2 = v2Shape(raw);
|
|
10211
11283
|
if (v2 !== null) return unreadable(fileId, v2);
|
|
10212
11284
|
const record = raw;
|
|
@@ -10330,9 +11402,9 @@ const NEVER = Object.freeze({
|
|
|
10330
11402
|
cursor: null
|
|
10331
11403
|
});
|
|
10332
11404
|
/** Exactly what `Date.prototype.toISOString` writes. */
|
|
10333
|
-
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
11405
|
+
const ISO$1 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
10334
11406
|
function isoTime(value) {
|
|
10335
|
-
if (typeof value !== "string" || !ISO.test(value)) return null;
|
|
11407
|
+
if (typeof value !== "string" || !ISO$1.test(value)) return null;
|
|
10336
11408
|
const at = Date.parse(value);
|
|
10337
11409
|
return Number.isFinite(at) && new Date(at).toISOString() === value ? at : null;
|
|
10338
11410
|
}
|
|
@@ -10932,7 +12004,7 @@ function folding(platform) {
|
|
|
10932
12004
|
return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
|
|
10933
12005
|
}
|
|
10934
12006
|
/** The package version, read from package.json at build time. */
|
|
10935
|
-
const VERSION = "0.
|
|
12007
|
+
const VERSION = "0.24.0";
|
|
10936
12008
|
/**
|
|
10937
12009
|
* 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).
|
|
10938
12010
|
*
|
|
@@ -11107,8 +12179,8 @@ function locateCliCommand(request, runtime = {
|
|
|
11107
12179
|
const theirs = typeof manifest.version === "string" ? manifest.version : "of no version";
|
|
11108
12180
|
return fail("version", `${caller.packageName} ${version} finds ${target.packageName} ${theirs}, at ${installed}`);
|
|
11109
12181
|
}
|
|
11110
|
-
const callerWorkspace = workspaceOf(callerRoot);
|
|
11111
|
-
const targetWorkspace = workspaceOf(installed);
|
|
12182
|
+
const callerWorkspace = workspaceOf$1(callerRoot);
|
|
12183
|
+
const targetWorkspace = workspaceOf$1(installed);
|
|
11112
12184
|
const sameCheckout = callerWorkspace !== null && callerWorkspace === targetWorkspace;
|
|
11113
12185
|
const found = {
|
|
11114
12186
|
root: installed,
|
|
@@ -11518,7 +12590,7 @@ function installedPackage(start, name) {
|
|
|
11518
12590
|
* `pnpm-workspace.yaml` above it, and its `packages` globs. A package under the checkout but not one of them — an
|
|
11519
12591
|
* installed copy in its `node_modules` — is not a workspace package of it.
|
|
11520
12592
|
*/
|
|
11521
|
-
function workspaceOf(root) {
|
|
12593
|
+
function workspaceOf$1(root) {
|
|
11522
12594
|
let dir = dirname(root);
|
|
11523
12595
|
for (;;) {
|
|
11524
12596
|
const globs = workspaceGlobs(dir);
|
|
@@ -12396,7 +13468,7 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
12396
13468
|
if ((await approveChangeAtTerminal(core, prepared.approvalId, options.env, options.output, streams)).state !== "approved") throw cancelled();
|
|
12397
13469
|
} else if (options.answered !== true) {
|
|
12398
13470
|
streams.stdout.write(`${prepared.preview}\n\n`);
|
|
12399
|
-
if ((await askLine(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
|
|
13471
|
+
if ((await askLine$1(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
|
|
12400
13472
|
await revokeChange(core, prepared.approvalId, "cancelled at the terminal", {
|
|
12401
13473
|
surface: "cli",
|
|
12402
13474
|
disposition: "person"
|
|
@@ -12461,7 +13533,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
12461
13533
|
}
|
|
12462
13534
|
const prompt = await beginChangeApproval(core, approvalId, { surface: "cli" });
|
|
12463
13535
|
streams.stdout.write(`${prompt.preview}\n\n`);
|
|
12464
|
-
const answer = await askLine(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
|
|
13536
|
+
const answer = await askLine$1(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
|
|
12465
13537
|
if (!answer.trim()) {
|
|
12466
13538
|
await revokeChange(core, approvalId, "cancelled at the terminal", {
|
|
12467
13539
|
surface: "cli",
|
|
@@ -12481,7 +13553,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
12481
13553
|
function cancelled() {
|
|
12482
13554
|
return new CommsError("USAGE", "cancelled: nothing was changed");
|
|
12483
13555
|
}
|
|
12484
|
-
async function askLine(streams, question) {
|
|
13556
|
+
async function askLine$1(streams, question) {
|
|
12485
13557
|
const { createInterface } = await import("node:readline/promises");
|
|
12486
13558
|
const rl = createInterface({
|
|
12487
13559
|
input: streams.stdin,
|
|
@@ -12816,6 +13888,186 @@ async function openSecretStore(kind, options) {
|
|
|
12816
13888
|
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." });
|
|
12817
13889
|
return new KeychainSecretStore(keyring, options.namespace, KEYCHAIN_TIMEOUT_MS, told);
|
|
12818
13890
|
}
|
|
13891
|
+
/** Exactly what `Date.prototype.toISOString` writes. */
|
|
13892
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
13893
|
+
const OUTCOMES = [
|
|
13894
|
+
"sent",
|
|
13895
|
+
"failed",
|
|
13896
|
+
"unknown"
|
|
13897
|
+
];
|
|
13898
|
+
const isString = (value) => typeof value === "string";
|
|
13899
|
+
const isTime$2 = (value) => isString(value) && ISO.test(value) && !Number.isNaN(Date.parse(value));
|
|
13900
|
+
/** One line as an entry, or null when it is not one this release wrote. */
|
|
13901
|
+
function entryOf$2(line) {
|
|
13902
|
+
let raw;
|
|
13903
|
+
try {
|
|
13904
|
+
raw = JSON.parse(line);
|
|
13905
|
+
} catch {
|
|
13906
|
+
return null;
|
|
13907
|
+
}
|
|
13908
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
13909
|
+
const entry = raw;
|
|
13910
|
+
if (!isTime$2(entry.at)) return null;
|
|
13911
|
+
if (entry.kind === "use") {
|
|
13912
|
+
if (![
|
|
13913
|
+
"standingId",
|
|
13914
|
+
"childId",
|
|
13915
|
+
"contentDigest",
|
|
13916
|
+
"channel",
|
|
13917
|
+
"ts",
|
|
13918
|
+
"thread",
|
|
13919
|
+
"sender",
|
|
13920
|
+
"targetDigest"
|
|
13921
|
+
].every((field) => isString(entry[field]) && entry[field] !== "")) return null;
|
|
13922
|
+
if (entry.act !== "reaction" && entry.act !== "reply") return null;
|
|
13923
|
+
if (typeof entry.edited !== "boolean") return null;
|
|
13924
|
+
return {
|
|
13925
|
+
kind: "use",
|
|
13926
|
+
at: entry.at,
|
|
13927
|
+
standingId: entry.standingId,
|
|
13928
|
+
childId: entry.childId,
|
|
13929
|
+
act: entry.act,
|
|
13930
|
+
contentDigest: entry.contentDigest,
|
|
13931
|
+
channel: entry.channel,
|
|
13932
|
+
ts: entry.ts,
|
|
13933
|
+
thread: entry.thread,
|
|
13934
|
+
sender: entry.sender,
|
|
13935
|
+
targetDigest: entry.targetDigest,
|
|
13936
|
+
edited: entry.edited
|
|
13937
|
+
};
|
|
13938
|
+
}
|
|
13939
|
+
if (entry.kind === "outcome") {
|
|
13940
|
+
if (!isString(entry.childId) || !isString(entry.outcome) || !OUTCOMES.includes(entry.outcome)) return null;
|
|
13941
|
+
if (!isString(entry.notice) || entry.link !== void 0 && !isString(entry.link)) return null;
|
|
13942
|
+
if (entry.actAt !== void 0 && !isTime$2(entry.actAt)) return null;
|
|
13943
|
+
return {
|
|
13944
|
+
kind: "outcome",
|
|
13945
|
+
at: entry.at,
|
|
13946
|
+
...entry.actAt === void 0 ? {} : { actAt: entry.actAt },
|
|
13947
|
+
childId: entry.childId,
|
|
13948
|
+
outcome: entry.outcome,
|
|
13949
|
+
...entry.link === void 0 ? {} : { link: entry.link },
|
|
13950
|
+
notice: entry.notice
|
|
13951
|
+
};
|
|
13952
|
+
}
|
|
13953
|
+
return null;
|
|
13954
|
+
}
|
|
13955
|
+
var AckLedger = class {
|
|
13956
|
+
directory;
|
|
13957
|
+
#now;
|
|
13958
|
+
constructor(stateDir, now = () => /* @__PURE__ */ new Date()) {
|
|
13959
|
+
this.directory = join(stateDir, "standing");
|
|
13960
|
+
this.#now = now;
|
|
13961
|
+
}
|
|
13962
|
+
/** The clock this ledger was opened with: what a use is timed by, and its counts' windows measured from. */
|
|
13963
|
+
now() {
|
|
13964
|
+
return this.#now();
|
|
13965
|
+
}
|
|
13966
|
+
/** The account's ledger file. Refuses anything but an account id, so no name can reach another path. */
|
|
13967
|
+
#path(accountId) {
|
|
13968
|
+
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." });
|
|
13969
|
+
return join(this.directory, `${accountId}.jsonl`);
|
|
13970
|
+
}
|
|
13971
|
+
/** Every line, read: the entries, and the number — from 1 — of each line this release cannot read. */
|
|
13972
|
+
async #read(path) {
|
|
13973
|
+
let text = "";
|
|
13974
|
+
try {
|
|
13975
|
+
text = await readFile(path, "utf8");
|
|
13976
|
+
} catch (error) {
|
|
13977
|
+
if (error.code !== "ENOENT") throw error;
|
|
13978
|
+
}
|
|
13979
|
+
const entries = [];
|
|
13980
|
+
const unreadable = [];
|
|
13981
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
13982
|
+
if (line.trim() === "") continue;
|
|
13983
|
+
const entry = entryOf$2(line);
|
|
13984
|
+
if (entry === null) unreadable.push(index + 1);
|
|
13985
|
+
else entries.push(entry);
|
|
13986
|
+
}
|
|
13987
|
+
return {
|
|
13988
|
+
entries,
|
|
13989
|
+
unreadable
|
|
13990
|
+
};
|
|
13991
|
+
}
|
|
13992
|
+
/** Where an account's ledger is kept: `<state>/standing/<accountId>.jsonl`, for a person told to move it aside. */
|
|
13993
|
+
pathOf(accountId) {
|
|
13994
|
+
return this.#path(accountId);
|
|
13995
|
+
}
|
|
13996
|
+
/**
|
|
13997
|
+
* Every account's ledger on this machine that holds a line this release cannot read, with the number of each such
|
|
13998
|
+
* line, for the doctor (design 2026-10-19-standing-approvals, the lead's call after review): while one does, no
|
|
13999
|
+
* standing approval of that account can be used — what it counts cannot be known — and nothing else says so. Reads
|
|
14000
|
+
* without the lock: a look, and every line it reads was written whole.
|
|
14001
|
+
*/
|
|
14002
|
+
async unreadable() {
|
|
14003
|
+
let names;
|
|
14004
|
+
try {
|
|
14005
|
+
names = await readdir(this.directory);
|
|
14006
|
+
} catch (error) {
|
|
14007
|
+
if (error.code === "ENOENT") return [];
|
|
14008
|
+
throw error;
|
|
14009
|
+
}
|
|
14010
|
+
const found = [];
|
|
14011
|
+
for (const name of names.sort()) {
|
|
14012
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
14013
|
+
const accountId = name.slice(0, -6);
|
|
14014
|
+
if (!ACCOUNT_ID_PATTERN.test(accountId)) continue;
|
|
14015
|
+
const path = this.#path(accountId);
|
|
14016
|
+
const { unreadable } = await this.#read(path);
|
|
14017
|
+
if (unreadable.length > 0) found.push({
|
|
14018
|
+
accountId,
|
|
14019
|
+
path,
|
|
14020
|
+
lines: unreadable
|
|
14021
|
+
});
|
|
14022
|
+
}
|
|
14023
|
+
return found;
|
|
14024
|
+
}
|
|
14025
|
+
/**
|
|
14026
|
+
* Runs `fn` under the account's ledger lock, with every entry as it reads there and `append`, which writes a `use`
|
|
14027
|
+
* line under the lock already held. `fn` must never take this lock again.
|
|
14028
|
+
*
|
|
14029
|
+
* A line this release cannot read refuses the whole call, BAD_DATA, before `fn` runs: a count read without it could
|
|
14030
|
+
* be lower than the truth, and a count is never guessed downwards.
|
|
14031
|
+
*/
|
|
14032
|
+
withLock(accountId, fn) {
|
|
14033
|
+
let path;
|
|
14034
|
+
try {
|
|
14035
|
+
path = this.#path(accountId);
|
|
14036
|
+
} catch (error) {
|
|
14037
|
+
return Promise.reject(error);
|
|
14038
|
+
}
|
|
14039
|
+
return withFileLock(`${path}.lock`, async () => {
|
|
14040
|
+
const { entries, unreadable } = await this.#read(path);
|
|
14041
|
+
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", {
|
|
14042
|
+
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.",
|
|
14043
|
+
details: {
|
|
14044
|
+
unreadable: unreadable.length,
|
|
14045
|
+
lines: unreadable
|
|
14046
|
+
}
|
|
14047
|
+
});
|
|
14048
|
+
const append = async (entry) => {
|
|
14049
|
+
if (entry.kind !== "use" || entryOf$2(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." });
|
|
14050
|
+
await appendPrivateLine(path, JSON.stringify(entry), { durable: true });
|
|
14051
|
+
};
|
|
14052
|
+
return fn(entries, append);
|
|
14053
|
+
});
|
|
14054
|
+
}
|
|
14055
|
+
/** Records what became of a use's act, taking the lock itself. It never frees a count. */
|
|
14056
|
+
async recordOutcome(accountId, entry) {
|
|
14057
|
+
const path = this.#path(accountId);
|
|
14058
|
+
const written = {
|
|
14059
|
+
...entry,
|
|
14060
|
+
kind: "outcome",
|
|
14061
|
+
at: entry.at ?? this.#now().toISOString()
|
|
14062
|
+
};
|
|
14063
|
+
if (entryOf$2(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." });
|
|
14064
|
+
await withFileLock(`${path}.lock`, () => appendPrivateLine(path, JSON.stringify(written), { durable: true }));
|
|
14065
|
+
}
|
|
14066
|
+
/** Every entry this release can read, oldest first, for a report: a line it cannot read is left out of it. */
|
|
14067
|
+
async entries(accountId) {
|
|
14068
|
+
return (await this.#read(this.#path(accountId))).entries;
|
|
14069
|
+
}
|
|
14070
|
+
};
|
|
12819
14071
|
var InboxStateStore = class {
|
|
12820
14072
|
directory;
|
|
12821
14073
|
constructor(stateDir) {
|
|
@@ -13629,6 +14881,7 @@ function openCore(options = {}) {
|
|
|
13629
14881
|
answered: (approvalId) => securityAnswered(paths.stateDir, approvalId)
|
|
13630
14882
|
}),
|
|
13631
14883
|
ledger: new SendLedger(paths.stateDir, now),
|
|
14884
|
+
acks: new AckLedger(paths.stateDir, now),
|
|
13632
14885
|
plans: new PlanStore(paths.stateDir, now),
|
|
13633
14886
|
taint: new TaintStore(paths.stateDir, now),
|
|
13634
14887
|
audit,
|
|
@@ -14414,7 +15667,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
14414
15667
|
};
|
|
14415
15668
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
14416
15669
|
packageName: serverFacts(request.channel).packageName,
|
|
14417
|
-
version: "0.
|
|
15670
|
+
version: "0.24.0"
|
|
14418
15671
|
};
|
|
14419
15672
|
const product = {
|
|
14420
15673
|
packageName,
|
|
@@ -14562,6 +15815,12 @@ function everyServerPruneChange(core, env, request) {
|
|
|
14562
15815
|
}
|
|
14563
15816
|
};
|
|
14564
15817
|
}
|
|
15818
|
+
Object.freeze([
|
|
15819
|
+
"narrow",
|
|
15820
|
+
"revoke",
|
|
15821
|
+
"off"
|
|
15822
|
+
]);
|
|
15823
|
+
Object.keys(STANDING_LIMITS);
|
|
14565
15824
|
/**
|
|
14566
15825
|
* Bringing a machine to the latest release, from a terminal (`agentcomms update`) or a chat (`comms_update`).
|
|
14567
15826
|
*
|
|
@@ -14615,8 +15874,9 @@ function updateToolNotice(options) {
|
|
|
14615
15874
|
let running = null;
|
|
14616
15875
|
let given = false;
|
|
14617
15876
|
let turn = Promise.resolve();
|
|
14618
|
-
return (tool) => {
|
|
15877
|
+
return (tool, args) => {
|
|
14619
15878
|
if (updateCheckSwitchedOff(options.env) !== null) return Promise.resolve(null);
|
|
15879
|
+
if (options.quiet?.(tool, args)) return Promise.resolve(null);
|
|
14620
15880
|
if (options.refresh && running === null) running = options.refresh().catch(() => void 0).finally(() => {
|
|
14621
15881
|
running = null;
|
|
14622
15882
|
});
|
|
@@ -14946,7 +16206,7 @@ function commandDecisionLine(core, decision, channel, platform) {
|
|
|
14946
16206
|
}
|
|
14947
16207
|
channelServer("core").packageName;
|
|
14948
16208
|
//#endregion
|
|
14949
|
-
//#region ../core/dist/tool-arguments-
|
|
16209
|
+
//#region ../core/dist/tool-arguments-5t8Xd2eK.mjs
|
|
14950
16210
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
14951
16211
|
const TARGET = "draft-2020-12";
|
|
14952
16212
|
/**
|
|
@@ -14989,7 +16249,7 @@ function strictToolArguments(server, refuse, notice, retired = {}) {
|
|
|
14989
16249
|
const parsed = await schema.safeParseAsync(input ?? {});
|
|
14990
16250
|
if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues, retired[name] ?? {}));
|
|
14991
16251
|
const result = await (declared ? handler(parsed.data, context) : handler(context));
|
|
14992
|
-
const line = notice ? await notice(name).catch(() => null) : null;
|
|
16252
|
+
const line = notice ? await notice(name, parsed.data).catch(() => null) : null;
|
|
14993
16253
|
return line === null ? result : withUpdateNotice(result, line, room);
|
|
14994
16254
|
});
|
|
14995
16255
|
};
|
|
@@ -16130,6 +17390,27 @@ strictObject({
|
|
|
16130
17390
|
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`);
|
|
16131
17391
|
}
|
|
16132
17392
|
});
|
|
17393
|
+
/** The environment values the Windows script reads the title and the body from. */
|
|
17394
|
+
const TITLE_VARIABLE = "AGENTCOMMS_NOTICE_TITLE";
|
|
17395
|
+
const BODY_VARIABLE = "AGENTCOMMS_NOTICE_BODY";
|
|
17396
|
+
Object.freeze([
|
|
17397
|
+
"-e",
|
|
17398
|
+
"on run argv",
|
|
17399
|
+
"-e",
|
|
17400
|
+
"display notification (item 1 of argv) with title (item 2 of argv)",
|
|
17401
|
+
"-e",
|
|
17402
|
+
"end run"
|
|
17403
|
+
]);
|
|
17404
|
+
[
|
|
17405
|
+
"$ErrorActionPreference = 'Stop'",
|
|
17406
|
+
"$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
|
|
17407
|
+
"$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
|
|
17408
|
+
"$text = $xml.GetElementsByTagName('text')",
|
|
17409
|
+
`$null = $text.Item(0).AppendChild($xml.CreateTextNode([string]$env:${TITLE_VARIABLE}))`,
|
|
17410
|
+
`$null = $text.Item(1).AppendChild($xml.CreateTextNode([string]$env:${BODY_VARIABLE}))`,
|
|
17411
|
+
"$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
|
|
17412
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)"
|
|
17413
|
+
].join("; ");
|
|
16133
17414
|
const URL_SHORTENERS = /* @__PURE__ */ new Set([
|
|
16134
17415
|
"bit.ly",
|
|
16135
17416
|
"tinyurl.com",
|
|
@@ -16260,6 +17541,10 @@ function analyseLink(text, href) {
|
|
|
16260
17541
|
flags
|
|
16261
17542
|
};
|
|
16262
17543
|
}
|
|
17544
|
+
Object.freeze({
|
|
17545
|
+
min: 5e3,
|
|
17546
|
+
max: 3e4
|
|
17547
|
+
});
|
|
16263
17548
|
Object.freeze({
|
|
16264
17549
|
complete: "not sent with any approval in the last 90 days",
|
|
16265
17550
|
window: "not sent with any of the 500 most recently changed approval records",
|