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