tapline-host 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/accounts.d.ts +129 -12
  2. package/dist/accounts.js +844 -75
  3. package/dist/accounts.js.map +1 -1
  4. package/dist/actionlog.d.ts +11 -4
  5. package/dist/actionlog.js +22 -1
  6. package/dist/actionlog.js.map +1 -1
  7. package/dist/animations.d.ts +4 -2
  8. package/dist/animations.js +7 -7
  9. package/dist/animations.js.map +1 -1
  10. package/dist/api.d.ts +7 -1
  11. package/dist/api.js +12 -1
  12. package/dist/api.js.map +1 -1
  13. package/dist/audit.d.ts +1 -0
  14. package/dist/auditlog.d.ts +40 -0
  15. package/dist/auditlog.js +150 -0
  16. package/dist/auditlog.js.map +1 -0
  17. package/dist/cli.d.ts +8 -0
  18. package/dist/cli.js +143 -7
  19. package/dist/cli.js.map +1 -1
  20. package/dist/control.d.ts +51 -0
  21. package/dist/control.js +110 -0
  22. package/dist/control.js.map +1 -1
  23. package/dist/doctor.d.ts +6 -0
  24. package/dist/doctor.js +61 -4
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/drive.d.ts +5 -2
  27. package/dist/drive.js +50 -13
  28. package/dist/drive.js.map +1 -1
  29. package/dist/elicit.d.ts +47 -0
  30. package/dist/elicit.js +87 -0
  31. package/dist/elicit.js.map +1 -1
  32. package/dist/format.d.ts +6 -0
  33. package/dist/format.js +25 -4
  34. package/dist/format.js.map +1 -1
  35. package/dist/grants.d.ts +297 -0
  36. package/dist/grants.js +938 -0
  37. package/dist/grants.js.map +1 -0
  38. package/dist/grantscmd.d.ts +34 -0
  39. package/dist/grantscmd.js +100 -0
  40. package/dist/grantscmd.js.map +1 -0
  41. package/dist/hostfacts.d.ts +23 -0
  42. package/dist/hostfacts.js +65 -0
  43. package/dist/hostfacts.js.map +1 -0
  44. package/dist/inspect.d.ts +3 -2
  45. package/dist/inspect.js +20 -8
  46. package/dist/inspect.js.map +1 -1
  47. package/dist/mapcmd.d.ts +2 -2
  48. package/dist/mcp.js +1 -1
  49. package/dist/nochange.d.ts +1 -0
  50. package/dist/otpclient.d.ts +79 -0
  51. package/dist/otpclient.js +362 -0
  52. package/dist/otpclient.js.map +1 -0
  53. package/dist/pidlock.d.ts +99 -0
  54. package/dist/pidlock.js +382 -0
  55. package/dist/pidlock.js.map +1 -0
  56. package/dist/places.d.ts +11 -0
  57. package/dist/places.js +28 -2
  58. package/dist/places.js.map +1 -1
  59. package/dist/policy.d.ts +2 -1
  60. package/dist/policy.js +5 -2
  61. package/dist/policy.js.map +1 -1
  62. package/dist/protocol.d.ts +4 -0
  63. package/dist/protocol.js +2 -0
  64. package/dist/protocol.js.map +1 -1
  65. package/dist/report.d.ts +8 -0
  66. package/dist/report.js +34 -1
  67. package/dist/report.js.map +1 -1
  68. package/dist/routes.d.ts +1 -1
  69. package/dist/routes.js +27 -11
  70. package/dist/routes.js.map +1 -1
  71. package/dist/runschedule.d.ts +119 -0
  72. package/dist/runschedule.js +281 -0
  73. package/dist/runschedule.js.map +1 -0
  74. package/dist/runtime.d.ts +48 -1
  75. package/dist/runtime.js +214 -18
  76. package/dist/runtime.js.map +1 -1
  77. package/dist/schedule.d.ts +145 -0
  78. package/dist/schedule.js +401 -0
  79. package/dist/schedule.js.map +1 -0
  80. package/dist/schedulehost.d.ts +73 -0
  81. package/dist/schedulehost.js +391 -0
  82. package/dist/schedulehost.js.map +1 -0
  83. package/dist/script.d.ts +2 -0
  84. package/dist/script.js +2 -0
  85. package/dist/script.js.map +1 -1
  86. package/dist/server.d.ts +6 -0
  87. package/dist/server.js +3 -1
  88. package/dist/server.js.map +1 -1
  89. package/dist/thincall.d.ts +13 -0
  90. package/dist/thincall.js +139 -59
  91. package/dist/thincall.js.map +1 -1
  92. package/dist/tool-text-data.d.ts +1 -1
  93. package/dist/tool-text-data.js +1 -1
  94. package/dist/tool-text-data.js.map +1 -1
  95. package/dist/tool-text.d.ts +1 -1
  96. package/dist/tools.d.ts +116 -6
  97. package/dist/tools.js +758 -134
  98. package/dist/tools.js.map +1 -1
  99. package/dist/unlockcmd.d.ts +44 -0
  100. package/dist/unlockcmd.js +80 -0
  101. package/dist/unlockcmd.js.map +1 -0
  102. package/dist/uxcheck.d.ts +1 -0
  103. package/dist/vault.d.ts +75 -0
  104. package/dist/vault.js +395 -0
  105. package/dist/vault.js.map +1 -0
  106. package/package.json +1 -1
  107. package/skills/tapline/SKILL.md +34 -6
  108. package/skills/tapline/references/setup.md +11 -1
package/dist/grants.js ADDED
@@ -0,0 +1,938 @@
1
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { z } from "zod";
5
+ import { plainText } from "./elicit.js";
6
+ import { dropPidLock, ownLockDeps, takePidLock } from "./pidlock.js";
7
+ import { listsFor, matchWord, policySchema } from "./policy.js";
8
+ /**
9
+ * AGENT-TOOLS 1.14 §19: grants. A person's standing OK for a kind of touch, with limits, given ahead of time. A grant lives
10
+ * in the policy file (version 2), is counted as slot files under `<policy file>.uses/` (no lock, no state file), and every use is a
11
+ * line of the audit log (§23). Only a person makes a grant; nothing here creates, edits or lifts one.
12
+ */
13
+ /** The most days away a grant's `until` may be, counted from the day the file is loaded (§19.1). */
14
+ export const GRANT_MAX_DAYS = 30;
15
+ /** The note a refused grant gets (§19.1). */
16
+ export const refusalLine = (refusal) => `Note: grant ${refusal.name.startsWith("#") ? refusal.name : JSON.stringify(refusal.name)} is refused: ${refusal.reason}`;
17
+ // -- never grantable (§19.2)
18
+ /** Words the tool treats as a payment or a purchase. */
19
+ export const PAYMENT_WORDS = ["Buy", "Purchase", "Pay", "Subscribe", "Checkout", "Place order", "Donate"];
20
+ /** Words that delete an account. (Privacy and security settings and Wallet are recognised by screen: Tapwright's to enforce.) */
21
+ export const ACCOUNT_WORDS = ["Delete account", "Delete my account", "Close account", "Deactivate account"];
22
+ /** Words that reach other people: a grant that names one is draftsOnly unless a person lifted it (§19.1, §21.3). */
23
+ export const REACH_WORDS = ["Send", "Post", "Publish", "Reply", "Comment"];
24
+ /** Why a word can never be granted, or null. `block` is the policy's block list for the grant's app. */
25
+ export function neverReason(word, block) {
26
+ const as = { label: word };
27
+ const pay = matchWord(PAYMENT_WORDS, as);
28
+ if (pay !== null)
29
+ return `payments and purchases are never grantable (${JSON.stringify(pay)}).`;
30
+ const account = matchWord(ACCOUNT_WORDS, as);
31
+ if (account !== null)
32
+ return `deleting an account is never grantable (${JSON.stringify(account)}).`;
33
+ const blocked = matchWord(block, as);
34
+ if (blocked !== null)
35
+ return `a blocked word is never grantable (${JSON.stringify(blocked)}).`;
36
+ return null;
37
+ }
38
+ // -- dates and times (local)
39
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
40
+ /** The local calendar day, `YYYY-MM-DD`. */
41
+ export function localDate(d) {
42
+ return `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
43
+ }
44
+ /** ISO 8601 in local time with its offset, as a slot file keeps it (§19.4). */
45
+ export function localIso(d) {
46
+ const off = -d.getTimezoneOffset();
47
+ const sign = off >= 0 ? "+" : "-";
48
+ const abs = Math.abs(off);
49
+ return `${localDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
50
+ }
51
+ /** Whole days from one `YYYY-MM-DD` to another; null when either isn't a real date. */
52
+ function daysBetween(from, to) {
53
+ const a = dateMs(from);
54
+ const b = dateMs(to);
55
+ return a === null || b === null ? null : Math.round((b - a) / 86_400_000);
56
+ }
57
+ function dateMs(date) {
58
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
59
+ if (m === null)
60
+ return null;
61
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
62
+ const ms = Date.UTC(y, mo - 1, d);
63
+ const back = new Date(ms);
64
+ return back.getUTCFullYear() === y && back.getUTCMonth() === mo - 1 && back.getUTCDate() === d ? ms : null;
65
+ }
66
+ function minutesOf(clock) {
67
+ const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(clock);
68
+ return m === null ? null : Number(m[1]) * 60 + Number(m[2]);
69
+ }
70
+ // -- loading (§19.1)
71
+ const rawGrant = z.looseObject({
72
+ name: z.unknown().optional(),
73
+ app: z.unknown().optional(),
74
+ words: z.unknown().optional(),
75
+ route: z.unknown().optional(),
76
+ perDay: z.unknown().optional(),
77
+ perHour: z.unknown().optional(),
78
+ until: z.unknown().optional(),
79
+ quiet: z.unknown().optional(),
80
+ draftsOnly: z.unknown().optional(),
81
+ revoked: z.unknown().optional(),
82
+ });
83
+ const wholeNumber = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100_000;
84
+ /**
85
+ * Reads a policy file's `grants`. A grant that is invalid, longer than 30 days or never grantable is refused alone: the
86
+ * others load, and the reason is kept (§19.1). `today` is the local day the file is loaded; `block` is the policy's block list for an app.
87
+ */
88
+ export function parseGrants(raw, today, block) {
89
+ const grants = [];
90
+ const refused = [];
91
+ if (raw === undefined)
92
+ return { grants, refused };
93
+ if (!Array.isArray(raw))
94
+ return { grants, refused: [{ name: "#1", reason: "grants must be a list." }] };
95
+ const seen = new Set();
96
+ const keys = new Set();
97
+ raw.forEach((item, index) => {
98
+ const parsed = rawGrant.safeParse(item);
99
+ const label = parsed.success && typeof parsed.data.name === "string" && parsed.data.name.trim() !== "" ? parsed.data.name : `#${index + 1}`;
100
+ const refuse = (reason) => {
101
+ refused.push({ name: label, reason });
102
+ };
103
+ if (!parsed.success)
104
+ return refuse("it isn't an object.");
105
+ const g = parsed.data;
106
+ if (label.startsWith("#"))
107
+ return refuse("name is missing.");
108
+ if (!safeGrantName(label))
109
+ return refuse("name may only use letters, digits, _, . and - (it names a folder), and not be . or .. or end in a dot.");
110
+ if (seen.has(label))
111
+ return refuse("name is already used by an earlier grant.");
112
+ // Two names that are equal ignoring ASCII case share a uses folder on a case-insensitive file system: the later is refused, active or revoked.
113
+ if (keys.has(grantKey(label)))
114
+ return refuse("name differs from an earlier grant's only in case (they would share one uses folder).");
115
+ seen.add(label);
116
+ keys.add(grantKey(label));
117
+ if (typeof g.app !== "string" || g.app.trim() === "")
118
+ return refuse("app is missing.");
119
+ if (!Array.isArray(g.words) || g.words.length === 0 || !g.words.every((w) => typeof w === "string" && w.trim() !== "")) {
120
+ return refuse("words must be a list of words, at least one.");
121
+ }
122
+ const words = g.words.map((w) => w.trim());
123
+ if (typeof g.until !== "string" || dateMs(g.until) === null)
124
+ return refuse("until must be a date, like 2026-11-21.");
125
+ const away = daysBetween(today, g.until);
126
+ if (away !== null && away > GRANT_MAX_DAYS)
127
+ return refuse(`until is ${away} days away; the most is ${GRANT_MAX_DAYS}.`);
128
+ if (g.route !== undefined && (typeof g.route !== "string" || g.route.trim() === ""))
129
+ return refuse("route must be the name of a saved route.");
130
+ if (g.perDay !== undefined && !wholeNumber(g.perDay))
131
+ return refuse("perDay must be a whole number, 1 or more.");
132
+ if (g.perHour !== undefined && !wholeNumber(g.perHour))
133
+ return refuse("perHour must be a whole number, 1 or more.");
134
+ if (g.draftsOnly !== undefined && typeof g.draftsOnly !== "boolean")
135
+ return refuse("draftsOnly must be true or false.");
136
+ let quiet;
137
+ if (g.quiet !== undefined) {
138
+ const q = g.quiet;
139
+ const from = typeof q === "object" && q !== null ? q.from : undefined;
140
+ const to = typeof q === "object" && q !== null ? q.to : undefined;
141
+ if (typeof from !== "string" || typeof to !== "string" || minutesOf(from) === null || minutesOf(to) === null)
142
+ return refuse("quiet needs from and to as HH:MM.");
143
+ if (from === to)
144
+ return refuse("quiet's from and to must differ.");
145
+ quiet = { from, to };
146
+ }
147
+ if (g.revoked !== undefined && (typeof g.revoked !== "string" || dateMs(g.revoked) === null))
148
+ return refuse("revoked must be a date, like 2026-11-14.");
149
+ for (const word of words) {
150
+ const never = neverReason(word, block(g.app));
151
+ if (never !== null)
152
+ return refuse(never);
153
+ }
154
+ const draftsOnly = typeof g.draftsOnly === "boolean" ? g.draftsOnly : words.some((w) => matchWord(REACH_WORDS, { label: w }) !== null);
155
+ grants.push({
156
+ name: label,
157
+ app: g.app,
158
+ words,
159
+ ...(typeof g.route === "string" ? { route: g.route.trim() } : {}),
160
+ perDay: typeof g.perDay === "number" ? g.perDay : 1,
161
+ ...(typeof g.perHour === "number" ? { perHour: g.perHour } : {}),
162
+ until: g.until,
163
+ ...(quiet !== undefined ? { quiet } : {}),
164
+ draftsOnly,
165
+ ...(typeof g.revoked === "string" ? { revoked: g.revoked } : {}),
166
+ });
167
+ });
168
+ return { grants, refused };
169
+ }
170
+ // -- the uses: slot files (§19.4, 1.14.1)
171
+ /**
172
+ * A grant's uses are files, with no state file, no lock, no takeover and no generation. A use is a file created exclusively
173
+ * (`wx`), so the file system decides who got it:
174
+ * a day slot: `<policy file>.uses/<grant name>/<YYYY-MM-DD>/<k>` for k = 1..perDay
175
+ * an hour slot: `<policy file>.uses/<grant name>/<YYYY-MM-DDTHH>/h<k>` for k = 1..perHour (the clock hour, not a rolling 60 minutes)
176
+ * all in local time. A count is the number of entries in the folder, whatever their names or content: an unreadable or garbage
177
+ * file counts, which fails closed. A stalled writer can only ever fill a slot nobody else has taken, so none can approve a use
178
+ * another one already made.
179
+ */
180
+ /** A name with its ASCII capitals lower-cased (nothing else changes). */
181
+ function asciiLower(name) {
182
+ return name.replace(/[A-Z]/g, (c) => c.toLowerCase());
183
+ }
184
+ /** What two grant names are compared by: equal ignoring ASCII case (names are limited to `[A-Za-z0-9._-]`). Names that agree here are one name. */
185
+ export function grantKey(name) {
186
+ return asciiLower(name);
187
+ }
188
+ /**
189
+ * The folder a grant's uses live in: its name lower-cased (a safe name is ASCII only, so this is exactly its case-folded form). A
190
+ * name's case therefore never picks another folder, and `g` and `G` are one folder on every file system.
191
+ */
192
+ export function usesFolder(name) {
193
+ return asciiLower(name);
194
+ }
195
+ /** The policy file's edit lock (§19.5). */
196
+ export function policyEditLockPath(policyPath) {
197
+ return `${policyPath}.edit.lock`;
198
+ }
199
+ /** Whether a grant's name is safe as a folder name: letters, digits, `_`, `.` and `-`; never `.`, `..` or one that ends in a dot (Windows drops it). */
200
+ export function safeGrantName(name) {
201
+ return /^[\w.-]+$/.test(name) && name !== "." && name !== ".." && !name.endsWith(".");
202
+ }
203
+ const hourFolder = (at) => `${localDate(at)}T${pad(at.getHours())}`;
204
+ /** The one key a grant scope is compared with: the app's id, or its name when it has none (the Grant.app contract). */
205
+ function appKey(app) {
206
+ return app.id ?? app.name;
207
+ }
208
+ function errorCode(error) {
209
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : undefined;
210
+ }
211
+ /** A folder's entries; `[]` when it doesn't exist yet; null when it can't be read (a file in the way, no permission): fail closed. */
212
+ async function listDir(path) {
213
+ try {
214
+ return await readdir(path);
215
+ }
216
+ catch (error) {
217
+ return errorCode(error) === "ENOENT" ? [] : null;
218
+ }
219
+ }
220
+ const RENAME_TRIES = 5;
221
+ /** How many times a claim that the clock overtook (midnight, the next hour) is made again. */
222
+ const CLAIM_TRIES = 4;
223
+ const RENAME_WAIT_MS = 20;
224
+ export class GrantBook {
225
+ options;
226
+ now;
227
+ usesRoot;
228
+ constructor(options) {
229
+ this.options = options;
230
+ this.now = options.now ?? (() => new Date());
231
+ this.usesRoot = `${options.policyPath}.uses`;
232
+ }
233
+ get policyPath() {
234
+ return this.options.policyPath;
235
+ }
236
+ /** The policy file read again, on every call: a revocation by hand or by the other tool is seen at the next touch (§19.5). */
237
+ async readPolicy() {
238
+ let json;
239
+ try {
240
+ json = JSON.parse(await readFile(this.options.policyPath, "utf8"));
241
+ }
242
+ catch {
243
+ return null;
244
+ }
245
+ const file = policySchema.safeParse(json);
246
+ if (!file.success)
247
+ return null;
248
+ // A version 1 file has no grants (§19.1).
249
+ if (file.data.version !== 2)
250
+ return { grants: [], refused: [] };
251
+ return parseGrants(file.data.grants, localDate(this.now()), (app) => listsFor(file.data, { id: app, name: app }).block);
252
+ }
253
+ /** The uses of a grant in the day and the hour of `at`, by listing the folders; null when one can't be read (fails closed). */
254
+ async countsAt(grant, at) {
255
+ if (!safeGrantName(grant.name))
256
+ return null;
257
+ if ((await listDir(this.usesRoot)) === null)
258
+ return null;
259
+ const base = join(this.usesRoot, usesFolder(grant.name));
260
+ const day = await listDir(join(base, localDate(at)));
261
+ const hour = await listDir(join(base, hourFolder(at)));
262
+ return day === null || hour === null ? null : { today: day.length, hour: hour.length };
263
+ }
264
+ /** Every grant, what was refused, and whether a uses folder can't be read: for `doctor` and the session's first answer. */
265
+ async inspect() {
266
+ const policy = await this.readPolicy();
267
+ let usesBroken = (await listDir(this.usesRoot)) === null;
268
+ const at = this.now();
269
+ for (const grant of policy?.grants ?? []) {
270
+ if (usesBroken)
271
+ break;
272
+ if (!safeGrantName(grant.name) || (await listDir(join(this.usesRoot, usesFolder(grant.name)))) === null || (await this.countsAt(grant, at)) === null)
273
+ usesBroken = true;
274
+ }
275
+ return { fileOk: policy !== null, grants: policy?.grants ?? [], refused: policy?.refused ?? [], usesBroken };
276
+ }
277
+ /** The date, `YYYY-MM-DD`, that is `days` from today (at most 30), for the dialog's question and a new grant's `until`. */
278
+ untilFor(days) {
279
+ const at = this.now();
280
+ return localDate(new Date(at.getFullYear(), at.getMonth(), at.getDate() + Math.min(GRANT_MAX_DAYS, Math.max(0, days))));
281
+ }
282
+ /** Every grant with today's and this hour's counts (null when its uses can't be read), for `tapline grants` (§19.7). */
283
+ async listing() {
284
+ const report = await this.inspect();
285
+ const at = this.now();
286
+ const usage = new Map();
287
+ for (const grant of report.grants)
288
+ usage.set(grant.name, await this.countsAt(grant, at));
289
+ return { report, usage };
290
+ }
291
+ /**
292
+ * Prune (at start, §19.4): the uses of a grant that has ended (today is after its `until`) or was revoked are deleted. Nothing else
293
+ * is ever counted from an earlier day or hour, so nothing else is needed. Returns the names it cleared.
294
+ */
295
+ async prune() {
296
+ const policy = await this.readPolicy();
297
+ if (policy === null)
298
+ return [];
299
+ const today = localDate(this.now());
300
+ const cleared = [];
301
+ // A folder whose grant is active is never touched, whatever another entry's name says.
302
+ const active = new Set(policy.grants.filter((g) => g.revoked === undefined && today <= g.until).map((g) => usesFolder(g.name)));
303
+ for (const grant of policy.grants) {
304
+ if (grant.revoked === undefined && today <= grant.until)
305
+ continue;
306
+ if (active.has(usesFolder(grant.name)))
307
+ continue;
308
+ if (await this.removeUses(grant.name))
309
+ cleared.push(grant.name);
310
+ }
311
+ return cleared;
312
+ }
313
+ /** Deletes a grant's whole uses folder; false for a name that isn't safe as a folder name or when it can't be deleted. */
314
+ async removeUses(name) {
315
+ if (!safeGrantName(name))
316
+ return false;
317
+ try {
318
+ await rm(join(this.usesRoot, usesFolder(name)), { recursive: true, force: true, maxRetries: 3, retryDelay: RENAME_WAIT_MS });
319
+ return true;
320
+ }
321
+ catch {
322
+ return false;
323
+ }
324
+ }
325
+ /**
326
+ * `grants --revoke-all` (§19.5): marks every grant in the policy file `"revoked": "<today>"` and writes one `revoke` audit
327
+ * line each, and deletes the revoked grants' uses. A grant that is already revoked keeps its date. The file is changed with a
328
+ * temporary file and a rename, and every running tool sees it at its very next touch, because each touch reads the file again.
329
+ */
330
+ async revokeAll(detail = "grants --revoke-all") {
331
+ const today = localDate(this.now());
332
+ const revoked = [];
333
+ const already = [];
334
+ const done = await this.editPolicy((file) => {
335
+ const list = file["grants"];
336
+ if (!Array.isArray(list))
337
+ return false;
338
+ let changed = false;
339
+ list.forEach((item, index) => {
340
+ if (typeof item !== "object" || item === null || Array.isArray(item))
341
+ return;
342
+ const grant = item;
343
+ const name = typeof grant["name"] === "string" && grant["name"].trim() !== "" ? grant["name"] : `#${index + 1}`;
344
+ if (typeof grant["revoked"] === "string" && dateMs(grant["revoked"]) !== null) {
345
+ already.push(name);
346
+ return;
347
+ }
348
+ grant["revoked"] = today;
349
+ revoked.push({ name, ...(typeof grant["app"] === "string" ? { app: grant["app"] } : {}) });
350
+ changed = true;
351
+ });
352
+ return changed;
353
+ });
354
+ if (typeof done === "string")
355
+ return { error: done };
356
+ // The kill switch never waits for its record: a line that can't be written doesn't undo the revocation.
357
+ for (const grant of revoked)
358
+ this.options.audit?.write({ kind: "revoke", ...(grant.app !== undefined ? { app: grant.app } : {}), name: grant.name, detail });
359
+ for (const grant of revoked)
360
+ await this.removeUses(grant.name);
361
+ return { revoked: revoked.map((g) => g.name), already };
362
+ }
363
+ /**
364
+ * Writes a grant a person made through the dialog (§19.6) into the policy file: a temporary file and a rename, a name not used
365
+ * yet, `until` at most 30 days away, a version 1 file becoming version 2. The creation is logged (§23, `approval`). Only the
366
+ * host's dialog calls this, with what the person answered; nothing a model sends reaches it.
367
+ */
368
+ async create(spec) {
369
+ const at = this.now();
370
+ const today = localDate(at);
371
+ let made;
372
+ let refusal;
373
+ const done = await this.editPolicy((file) => {
374
+ if (file["version"] !== 1 && file["version"] !== 2) {
375
+ refusal = "the policy file has no version 1 or 2.";
376
+ return false;
377
+ }
378
+ const list = Array.isArray(file["grants"]) ? file["grants"] : [];
379
+ const taken = new Set(list.map((item) => (typeof item === "object" && item !== null && typeof item.name === "string" ? grantKey(item.name) : undefined)));
380
+ let name = spec.nameBase;
381
+ for (let n = 2; taken.has(grantKey(name)); n++)
382
+ name = `${spec.nameBase}_${n}`;
383
+ const raw = {
384
+ name,
385
+ app: spec.app,
386
+ words: spec.words,
387
+ ...(spec.route !== undefined ? { route: spec.route } : {}),
388
+ perDay: spec.perDay,
389
+ ...(spec.perHour !== undefined ? { perHour: spec.perHour } : {}),
390
+ until: this.untilFor(spec.days),
391
+ ...(spec.quiet !== undefined ? { quiet: spec.quiet } : {}),
392
+ ...(spec.draftsOnly === false ? { draftsOnly: false } : {}),
393
+ };
394
+ const parsed = parseGrants([raw], today, (app) => listsFor(policySchema.parse({ ...file, version: 2 }), { id: app, name: app }).block);
395
+ const grant = parsed.grants[0];
396
+ if (grant === undefined) {
397
+ refusal = parsed.refused[0]?.reason ?? "it can't be a grant.";
398
+ return false;
399
+ }
400
+ file["version"] = 2;
401
+ file["grants"] = [...list, raw];
402
+ made = grant;
403
+ return true;
404
+ });
405
+ if (typeof done === "string")
406
+ return { error: done };
407
+ if (made === undefined)
408
+ return { error: refusal ?? "nothing was written." };
409
+ this.options.audit?.write({ kind: "approval", app: made.app, name: made.words[0] ?? made.name, detail: "grant created" });
410
+ return { grant: made };
411
+ }
412
+ /**
413
+ * Every change to the policy file (create, renew, lifting draftsOnly, revoke-all) runs under `<policy file>.edit.lock`: the shared
414
+ * pid-liveness lock (pidlock.ts), which no one can displace while its holder lives. Under it the file is read again and the change is
415
+ * applied to that fresh content, never to an earlier copy; the result is written with a temporary file and a rename. A change that
416
+ * can't get the lock within `editLockWaitMs` fails closed: nothing is written. A string is why it couldn't.
417
+ */
418
+ async editPolicy(change) {
419
+ const lockPath = policyEditLockPath(this.options.policyPath);
420
+ const deps = this.options.editLock ?? ownLockDeps();
421
+ const waitMs = this.options.editLockWaitMs ?? 5000;
422
+ const pollMs = this.options.editLockPollMs ?? 25;
423
+ const until = Date.now() + waitMs;
424
+ let token = null;
425
+ for (;;) {
426
+ token = takePidLock(deps, lockPath);
427
+ if (token !== null || Date.now() >= until)
428
+ break;
429
+ await new Promise((done) => setTimeout(done, pollMs));
430
+ }
431
+ if (token === null)
432
+ return "couldn't take the policy file's edit lock (another change is under way); nothing was changed, try again.";
433
+ try {
434
+ return await this.editPolicyLocked(change);
435
+ }
436
+ finally {
437
+ dropPidLock(lockPath, token);
438
+ }
439
+ }
440
+ /** The change itself, run under the edit lock: reads the file as it is now, lets `change` edit it, writes it back when it says it did. */
441
+ async editPolicyLocked(change) {
442
+ const path = this.options.policyPath;
443
+ let before;
444
+ let file;
445
+ try {
446
+ const info = await stat(path);
447
+ before = { mtimeMs: info.mtimeMs, size: info.size };
448
+ file = JSON.parse(await readFile(path, "utf8"));
449
+ }
450
+ catch (error) {
451
+ return `couldn't read the policy file: ${error instanceof Error ? error.message : String(error)}`;
452
+ }
453
+ if (typeof file !== "object" || file === null || Array.isArray(file))
454
+ return "the policy file isn't a policy.";
455
+ if (!change(file))
456
+ return true;
457
+ try {
458
+ const now = await stat(path);
459
+ if (now.mtimeMs !== before.mtimeMs || now.size !== before.size)
460
+ return "the policy file changed while it was being edited; try again.";
461
+ await this.options.beforePolicyWrite?.();
462
+ await this.writeAtomic(path, `${JSON.stringify(file, null, 2)}\n`);
463
+ }
464
+ catch (error) {
465
+ return `couldn't write the policy file: ${error instanceof Error ? error.message : String(error)}`;
466
+ }
467
+ return true;
468
+ }
469
+ /** The `Note:` lines of an inspection: each refused grant, and a uses folder that can't be read. */
470
+ static noticeLines(report) {
471
+ const lines = report.refused.map(refusalLine);
472
+ if (!report.fileOk)
473
+ lines.push("Note: the policy file can't be read now, so no grant covers anything; every touch asks a person.");
474
+ if (report.usesBroken)
475
+ lines.push("Note: the grants' uses folder can't be read, so a grant whose uses can't be counted counts as used up and its touch asks a person.");
476
+ return lines;
477
+ }
478
+ /**
479
+ * Whether a grant covers this touch (§19.3). Called where the host has decided the touch needs a person: a covered touch goes
480
+ * through, and anything else asks as before, with the notes. `use` claims the use's slot files and writes the audit line;
481
+ * `peek` claims and writes nothing (a dry run, and the check before a dialog). `detail` is what the audit line says.
482
+ */
483
+ async decide(touch, mode, detail) {
484
+ const policy = await this.readPolicy();
485
+ if (policy === null)
486
+ return { kind: "ask", notes: [], asked: [] };
487
+ // Never grantable, whatever the file says: a touch on a payment or an account deletion always asks (§19.2).
488
+ if (matchWord([...PAYMENT_WORDS, ...ACCOUNT_WORDS], touch.element) !== null)
489
+ return { kind: "ask", notes: [], asked: [] };
490
+ const at = this.now();
491
+ const today = localDate(at);
492
+ const minutes = at.getHours() * 60 + at.getMinutes();
493
+ const notes = [];
494
+ const asked = [];
495
+ for (const grant of policy.grants) {
496
+ if (grant.app !== appKey(touch.app))
497
+ continue;
498
+ if (matchWord(grant.words, touch.element) === null)
499
+ continue;
500
+ if (grant.route !== undefined && !touch.routes.includes(grant.route))
501
+ continue;
502
+ const name = JSON.stringify(grant.name);
503
+ const doesnt = (why) => `Note: grant ${name} doesn't cover this (${why}); asking a person.`;
504
+ if (grant.revoked !== undefined) {
505
+ notes.push(doesnt(`it was revoked on ${grant.revoked}`));
506
+ asked.push("revoked");
507
+ continue;
508
+ }
509
+ if (today > grant.until) {
510
+ notes.push(doesnt(`it ended on ${grant.until}`));
511
+ asked.push("ended");
512
+ continue;
513
+ }
514
+ if (grant.quiet !== undefined && inQuiet(grant.quiet, minutes)) {
515
+ notes.push(doesnt(`quiet hours ${grant.quiet.from} to ${grant.quiet.to}`));
516
+ asked.push("quiet hours");
517
+ continue;
518
+ }
519
+ // A draftsOnly grant only looks: its touch stops at a draft, and a draft a person sends counts no use (§19.3, §21.3).
520
+ const counted = mode === "use" && !grant.draftsOnly ? await this.use(grant, detail, touch) : await this.peek(grant, at);
521
+ if (counted === null)
522
+ continue;
523
+ if (typeof counted === "string") {
524
+ notes.push(doesnt(counted));
525
+ asked.push("limit reached");
526
+ continue;
527
+ }
528
+ if (grant.draftsOnly)
529
+ return { kind: "draft", name: grant.name };
530
+ return {
531
+ kind: "covered",
532
+ name: grant.name,
533
+ counts: `${counted.today} of ${counted.perDay}`,
534
+ counted: mode === "use",
535
+ owner: counted.owner,
536
+ slots: counted.slots,
537
+ line: `Note: allowed by grant ${name} (${counted.today} of ${counted.perDay} today).`,
538
+ };
539
+ }
540
+ return { kind: "ask", notes, asked };
541
+ }
542
+ /**
543
+ * Whether the grant a use was taken under still covers this touch, right now (§19.3): the policy file is read again, and the clock
544
+ * too, so a revocation, a `draftsOnly`, quiet hours, `until`, the word and the route condition are current. Takes no use and writes
545
+ * nothing; the use's slot files must all still be there and still be this use's. Any doubt (an unreadable file) answers false: the
546
+ * touch then asks a person.
547
+ */
548
+ async stillCovers(touch, use) {
549
+ // A use whose slots are in an earlier day or hour than now is released and claimed again in the current buckets (the reference is
550
+ // updated in place), at most a few times; where that fails the touch asks a person.
551
+ for (let round = 0; round < CLAIM_TRIES; round++) {
552
+ const policy = await this.readPolicy();
553
+ if (policy === null)
554
+ return false;
555
+ if (matchWord([...PAYMENT_WORDS, ...ACCOUNT_WORDS], touch.element) !== null)
556
+ return false;
557
+ const grant = policy.grants.find((g) => g.name === use.name);
558
+ if (grant === undefined)
559
+ return false;
560
+ if (!this.holds(grant, touch, this.now()))
561
+ return false;
562
+ if (use.owner === "" || use.slots.length === 0)
563
+ return false;
564
+ for (const slot of use.slots) {
565
+ const path = this.slotPath(slot, use.name);
566
+ if (path === null || (await slotOwner(path)) !== use.owner)
567
+ return false;
568
+ }
569
+ await this.options.afterSlotReads?.();
570
+ // The clock is read AFTER every await, as the last thing before answering true: time restrictions and the bucket are those of
571
+ // this very moment (no await between this read and the return).
572
+ const at = this.now();
573
+ if (!this.holds(grant, touch, at))
574
+ return false;
575
+ if (this.inCurrentBuckets(use.slots, at) && this.fits(grant, use.slots)) {
576
+ // The refreshed limits count the WHOLE bucket (higher slots and garbage too), not only this use's own slot number.
577
+ const over = await this.overLimit(grant, use.slots);
578
+ // The clock again after that await, as the last thing before answering true.
579
+ const later = this.now();
580
+ if (over === "")
581
+ return this.holds(grant, touch, later) && this.inCurrentBuckets(use.slots, later);
582
+ // Over the limit: this use is given back and the touch asks a person (the bucket is already full under the new limit).
583
+ if (over !== null)
584
+ await this.dropSlots(use.name, use.owner, use.slots);
585
+ return false;
586
+ }
587
+ await this.dropSlots(use.name, use.owner, use.slots);
588
+ const claimed = await this.claimBuckets(grant, grant.route ?? touch.routes[0]);
589
+ if (claimed === null || typeof claimed === "string")
590
+ return false;
591
+ use.owner = claimed.owner;
592
+ use.slots = claimed.slots;
593
+ }
594
+ return false;
595
+ }
596
+ /**
597
+ * Whether a claim still fits the grant as the policy file has it now: its day slot number is within perDay, and, when the grant has a
598
+ * perHour, it holds an hour slot whose number is within perHour. A policy that was changed between the claim and the touch (a quota
599
+ * cut, a perHour added) makes an old claim fall outside the new limits; it is then given back and claimed again, or the touch asks.
600
+ */
601
+ fits(grant, slots) {
602
+ let days = 0;
603
+ let hours = 0;
604
+ for (const slot of slots) {
605
+ const file = slot.split("/")[2] ?? "";
606
+ const hour = /^h([0-9]+)$/.exec(file);
607
+ if (hour !== null) {
608
+ hours += 1;
609
+ if (grant.perHour === undefined || Number(hour[1]) > grant.perHour)
610
+ return false;
611
+ continue;
612
+ }
613
+ const day = /^([0-9]+)$/.exec(file);
614
+ if (day === null || Number(day[1]) > grant.perDay)
615
+ return false;
616
+ days += 1;
617
+ }
618
+ return days === 1 && (grant.perHour === undefined ? hours === 0 : hours === 1);
619
+ }
620
+ /**
621
+ * Under the grant as it is now, the whole bucket (every entry of the day folder, and of the hour folder with a perHour; garbage counts
622
+ * as section 19.4 says) must hold at most the limit, this use's own slot included. "" means within the limits; otherwise the limit
623
+ * that is exceeded in words; null, a bucket that can't be read (any doubt asks a person).
624
+ */
625
+ async overLimit(grant, slots) {
626
+ for (const slot of slots) {
627
+ const parts = slot.split("/");
628
+ const names = await listDir(join(this.usesRoot, parts[0] ?? "", parts[1] ?? ""));
629
+ if (names === null)
630
+ return null;
631
+ const hourSlot = /^h\d+$/.test(parts[2] ?? "");
632
+ const limit = hourSlot ? grant.perHour : grant.perDay;
633
+ if (limit === undefined)
634
+ return null;
635
+ if (names.length > limit)
636
+ return names.length + " of " + limit + (hourSlot ? " this hour" : " today");
637
+ }
638
+ return "";
639
+ }
640
+ /** Whether every slot of a use is in the day (or hour) folder of `at`: a use from an earlier day or hour is not counted against the current one. */
641
+ inCurrentBuckets(slots, at) {
642
+ return slots.every((slot) => {
643
+ const folder = slot.split("/")[1];
644
+ return folder === localDate(at) || folder === hourFolder(at);
645
+ });
646
+ }
647
+ /** Whether this grant, as the policy file has it now, covers the touch at this time: app, word, route, revocation, end, quiet hours, drafts. */
648
+ holds(grant, touch, at) {
649
+ if (grant.app !== appKey(touch.app))
650
+ return false;
651
+ if (matchWord([...PAYMENT_WORDS, ...ACCOUNT_WORDS], touch.element) !== null)
652
+ return false;
653
+ if (matchWord(grant.words, touch.element) === null)
654
+ return false;
655
+ if (grant.route !== undefined && !touch.routes.includes(grant.route))
656
+ return false;
657
+ if (grant.revoked !== undefined || grant.draftsOnly || localDate(at) > grant.until)
658
+ return false;
659
+ return grant.quiet === undefined || !inQuiet(grant.quiet, at.getHours() * 60 + at.getMinutes());
660
+ }
661
+ /** The absolute path of a slot a use names, or null when it isn't a slot of that grant (never a path outside the uses folder). */
662
+ slotPath(slot, name) {
663
+ const parts = slot.split("/");
664
+ const [grant, folder, file] = parts;
665
+ if (parts.length !== 3 || grant !== usesFolder(name) || !safeGrantName(name) || folder === undefined || file === undefined)
666
+ return null;
667
+ if (!/^\d{4}-\d{2}-\d{2}(T\d{2})?$/.test(folder) || !/^h?\d+$/.test(file))
668
+ return null;
669
+ return join(this.usesRoot, grant, folder, file);
670
+ }
671
+ /** A use taken back: the touch wasn't made after all (§19.4: a use is counted when the touch is made). Deletes only this use's own slot files. */
672
+ async release(use) {
673
+ await this.dropSlots(use.name, use.owner, use.slots);
674
+ }
675
+ /** Deletes the slot files of a use, each only after its content says this owner made it. A file that can't be read or deleted stays: the use stays counted. */
676
+ async dropSlots(name, owner, slots) {
677
+ if (owner === "")
678
+ return;
679
+ for (const slot of slots) {
680
+ const path = this.slotPath(slot, name);
681
+ if (path === null)
682
+ continue;
683
+ try {
684
+ if ((await slotOwner(path)) === owner)
685
+ await rm(path, { force: true });
686
+ }
687
+ catch {
688
+ // stays counted: the safe side
689
+ }
690
+ }
691
+ }
692
+ /** The limit that stops a use as words for the note, or null when there is room. */
693
+ limit(grant, counts) {
694
+ if (counts.today >= grant.perDay)
695
+ return `${counts.today} of ${grant.perDay} today`;
696
+ if (grant.perHour !== undefined && counts.hour >= grant.perHour)
697
+ return `${counts.hour} of ${grant.perHour} this hour`;
698
+ return null;
699
+ }
700
+ /** A string: the limit that stopped it. null: it can't be counted (an unreadable uses folder), nothing to say. */
701
+ async peek(grant, at) {
702
+ const counts = await this.countsAt(grant, at);
703
+ if (counts === null)
704
+ return null;
705
+ return this.limit(grant, counts) ?? { today: counts.today + 1, perDay: grant.perDay, owner: "", slots: [] };
706
+ }
707
+ /**
708
+ * Claims a slot file in `dir` (k = 1..limit, exclusive create, the folder made as needed): the first k nobody has taken. A full
709
+ * folder answers how many entries it holds; a folder that can't be read or written answers null. Every entry counts, so a garbage
710
+ * file takes a place: a folder holding more entries than `limit` after the claim gives the claim back.
711
+ */
712
+ async claimSlot(dir, prefix, limit, content) {
713
+ try {
714
+ await mkdir(dir, { recursive: true });
715
+ }
716
+ catch {
717
+ return null;
718
+ }
719
+ const names = await listDir(dir);
720
+ if (names === null)
721
+ return null;
722
+ if (names.length >= limit)
723
+ return { full: names.length };
724
+ const taken = new Set(names);
725
+ for (let k = 1; k <= limit; k++) {
726
+ const file = join(dir, `${prefix}${k}`);
727
+ if (taken.has(`${prefix}${k}`))
728
+ continue;
729
+ await this.options.beforeCreate?.(file);
730
+ try {
731
+ await writeFile(file, content, { flag: "wx", mode: 0o600 });
732
+ }
733
+ catch (error) {
734
+ // Taken a moment ago (Windows says EPERM or EBUSY while a delete is under way): the next one.
735
+ const code = errorCode(error);
736
+ if (code === "EEXIST" || code === "EPERM" || code === "EBUSY")
737
+ continue;
738
+ return null;
739
+ }
740
+ const after = await listDir(dir);
741
+ if (after === null) {
742
+ await rm(file, { force: true }).catch(() => undefined);
743
+ return null;
744
+ }
745
+ if (after.length > limit) {
746
+ await rm(file, { force: true }).catch(() => undefined);
747
+ return { full: Math.max(limit, after.length - 1) };
748
+ }
749
+ return { file, count: after.length };
750
+ }
751
+ return { full: limit };
752
+ }
753
+ /**
754
+ * Claims the slot files of one use in the buckets of a fresh clock: the hour slot first (when there is a perHour), then the day slot;
755
+ * a day claim that fails gives the hour slot back. The clock is read again after the claim: when the day (or the hour, with a perHour)
756
+ * moved on meanwhile, the slots just made are in an earlier bucket, so they are deleted and the claim is made again in the current
757
+ * one (a claimer that paused across midnight never counts against yesterday). A string is the limit that stopped it; null, that it
758
+ * can't be counted.
759
+ */
760
+ async claimBuckets(grant, route) {
761
+ const folder = usesFolder(grant.name);
762
+ for (let attempt = 0; attempt < CLAIM_TRIES; attempt++) {
763
+ const at = this.now();
764
+ const counts = await this.countsAt(grant, at);
765
+ if (counts === null)
766
+ return null;
767
+ const stopped = this.limit(grant, counts);
768
+ if (stopped !== null)
769
+ return stopped;
770
+ const owner = randomBytes(8).toString("hex");
771
+ const content = `${JSON.stringify({ t: localIso(at), tool: "tapline", owner, ...(route !== undefined ? { route } : {}) })}\n`;
772
+ const slots = [];
773
+ const giveBack = () => this.dropSlots(grant.name, owner, slots);
774
+ try {
775
+ if (grant.perHour !== undefined) {
776
+ const hourDir = hourFolder(at);
777
+ const hour = await this.claimSlot(join(this.usesRoot, folder, hourDir), "h", grant.perHour, content);
778
+ if (hour === null)
779
+ return null;
780
+ if ("full" in hour)
781
+ return `${hour.full} of ${grant.perHour} this hour`;
782
+ slots.push(`${folder}/${hourDir}/${basename(hour.file)}`);
783
+ }
784
+ const dayDir = localDate(at);
785
+ const day = await this.claimSlot(join(this.usesRoot, folder, dayDir), "", grant.perDay, content);
786
+ if (day === null) {
787
+ await giveBack();
788
+ return null;
789
+ }
790
+ if ("full" in day) {
791
+ await giveBack();
792
+ return `${day.full} of ${grant.perDay} today`;
793
+ }
794
+ slots.push(`${folder}/${dayDir}/${basename(day.file)}`);
795
+ const later = this.now();
796
+ if (localDate(later) !== dayDir || (grant.perHour !== undefined && hourFolder(later) !== hourFolder(at))) {
797
+ await giveBack();
798
+ continue;
799
+ }
800
+ return { today: day.count, perDay: grant.perDay, owner, slots };
801
+ }
802
+ catch {
803
+ await giveBack();
804
+ return null;
805
+ }
806
+ }
807
+ return null;
808
+ }
809
+ /**
810
+ * Claims a use (see claimBuckets), then the policy and the clock are read again (a revocation, an end or quiet hours that came
811
+ * meanwhile win) and the audit line is written; a grant needs its line, so a use whose line can't be written is given back (§23).
812
+ * A string is the limit that stopped it; null, that it can't be counted.
813
+ */
814
+ async use(grant, detail, touch) {
815
+ const audit = this.options.audit;
816
+ if (audit === null || !safeGrantName(grant.name))
817
+ return null;
818
+ // After the awaits below the clock is read again, as the last thing before the audit line and the answer: a claim that midnight (or
819
+ // the next hour) overtook meanwhile is given back and made again in the current buckets, at most CLAIM_TRIES times.
820
+ let current = grant;
821
+ for (let attempt = 0; attempt < CLAIM_TRIES; attempt++) {
822
+ const claimed = await this.claimBuckets(current, current.route ?? touch.routes[0]);
823
+ if (claimed === null || typeof claimed === "string")
824
+ return claimed;
825
+ const giveBack = () => this.dropSlots(grant.name, claimed.owner, claimed.slots);
826
+ try {
827
+ await this.options.afterClaim?.();
828
+ const policy = await this.readPolicy();
829
+ const fresh = policy?.grants.find((g) => g.name === grant.name);
830
+ const at = this.now();
831
+ if (fresh === undefined || !this.holds(fresh, touch, at)) {
832
+ await giveBack();
833
+ return null;
834
+ }
835
+ // The claim is checked against the refreshed grant: after a quota cut or a changed perHour it is given back, and claimed once more
836
+ // under the new limits (the loop), which asks a person when they leave no room.
837
+ if (!this.inCurrentBuckets(claimed.slots, at) || !this.fits(fresh, claimed.slots)) {
838
+ await giveBack();
839
+ current = fresh;
840
+ continue;
841
+ }
842
+ // The refreshed limits count the whole bucket: a claim in a bucket that holds more than the new limit is given back, not reclaimed.
843
+ const over = await this.overLimit(fresh, claimed.slots);
844
+ if (over !== "") {
845
+ await giveBack();
846
+ return over;
847
+ }
848
+ // The clock again after that await.
849
+ const after = this.now();
850
+ if (!this.holds(fresh, touch, after) || !this.inCurrentBuckets(claimed.slots, after)) {
851
+ await giveBack();
852
+ return null;
853
+ }
854
+ if (!audit.write({ kind: "grant", app: appKey(touch.app), name: grant.name, detail: `${detail} (${claimed.today} of ${fresh.perDay} today)` })) {
855
+ await giveBack();
856
+ return null;
857
+ }
858
+ return claimed;
859
+ }
860
+ catch {
861
+ await giveBack();
862
+ return null;
863
+ }
864
+ }
865
+ return null;
866
+ }
867
+ /** A temporary file and a rename, with retries on Windows. */
868
+ async writeAtomic(path, content) {
869
+ const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
870
+ try {
871
+ await writeFile(temp, content, { encoding: "utf8", mode: 0o600 });
872
+ for (let attempt = 0;; attempt++) {
873
+ try {
874
+ await rename(temp, path);
875
+ return;
876
+ }
877
+ catch (error) {
878
+ const code = errorCode(error);
879
+ if ((code !== "EPERM" && code !== "EBUSY") || attempt >= RENAME_TRIES)
880
+ throw error;
881
+ await new Promise((done) => setTimeout(done, RENAME_WAIT_MS));
882
+ }
883
+ }
884
+ }
885
+ catch (error) {
886
+ await rm(temp, { force: true }).catch(() => undefined);
887
+ throw error;
888
+ }
889
+ }
890
+ }
891
+ /** The `owner` a slot file's content names; undefined when the file can't be read or isn't in the format (it is not provably anyone's own). */
892
+ async function slotOwner(path) {
893
+ try {
894
+ const parsed = JSON.parse(await readFile(path, "utf8"));
895
+ return typeof parsed === "object" && parsed !== null && typeof parsed.owner === "string" ? parsed.owner : undefined;
896
+ }
897
+ catch {
898
+ return undefined;
899
+ }
900
+ }
901
+ /** Whether the element touched is a payment or an account deletion: no grant covers it, whatever its word (§19.2). */
902
+ export const elementNeverGrantable = (element) => matchWord([...PAYMENT_WORDS, ...ACCOUNT_WORDS], element) !== null;
903
+ /** Whether a word reaches other people, so a grant for it shows drafts first unless a person lifts that (§19.1). */
904
+ export const reachesPeople = (word) => matchWord(REACH_WORDS, { label: word }) !== null;
905
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
906
+ /** `2026-11-21` as `21 Nov`. */
907
+ function shortDate(date) {
908
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
909
+ return m === null ? date : `${Number(m[3])} ${MONTHS[Number(m[2]) - 1] ?? m[2]}`;
910
+ }
911
+ /**
912
+ * The dialog's question, saying everything the grant allows (§19.6):
913
+ * `Allow tapping "Post" in Instagram up to 3 times a day, only in route post_update, until 21 Nov (drafts shown to you first)?`
914
+ * `app` is the app's name as the dialog may show it (already cut and cleaned); words are cleaned here.
915
+ */
916
+ export function grantCaption(grant, app) {
917
+ const words = grant.words.map((w) => JSON.stringify(plainText(w)));
918
+ const spoken = words.length <= 1 ? (words[0] ?? "") : `${words.slice(0, -1).join(", ")} or ${words[words.length - 1]}`;
919
+ const counts = `up to ${grant.perDay} ${grant.perDay === 1 ? "time" : "times"} a day${grant.perHour !== undefined ? ` and ${grant.perHour} an hour` : ""}`;
920
+ const quiet = grant.quiet !== undefined ? `, not between ${grant.quiet.from} and ${grant.quiet.to}` : "";
921
+ const route = grant.route !== undefined ? `, only in route ${plainText(grant.route)}` : "";
922
+ const drafts = grant.draftsOnly ? " (drafts shown to you first)" : "";
923
+ return `Allow tapping ${spoken} in ${app} ${counts}${quiet}${route}, until ${shortDate(grant.until)}${drafts}?`;
924
+ }
925
+ /** A grant's name from the app's name and the word, as `instagram_post`: ASCII letters, digits and `_`. */
926
+ export function grantNameBase(app, word) {
927
+ const slug = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
928
+ return [slug(app), slug(word)].filter((part) => part !== "").join("_") || "grant";
929
+ }
930
+ /** Whether a whole-word match for a word is a payment or an account deletion: never grantable, so never offered (§19.2). */
931
+ export const neverGrantable = (word, block) => neverReason(word, block) !== null;
932
+ /** Whether a local time of day (minutes since midnight) falls in the quiet window, which may cross midnight. */
933
+ function inQuiet(quiet, minutes) {
934
+ const from = minutesOf(quiet.from) ?? 0;
935
+ const to = minutesOf(quiet.to) ?? 0;
936
+ return from < to ? minutes >= from && minutes < to : minutes >= from || minutes < to;
937
+ }
938
+ //# sourceMappingURL=grants.js.map