tapline-host 1.6.1 → 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 (110) 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 +55 -16
  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/mapcmd.js +5 -4
  49. package/dist/mapcmd.js.map +1 -1
  50. package/dist/mcp.js +1 -1
  51. package/dist/nochange.d.ts +1 -0
  52. package/dist/otpclient.d.ts +79 -0
  53. package/dist/otpclient.js +362 -0
  54. package/dist/otpclient.js.map +1 -0
  55. package/dist/pidlock.d.ts +99 -0
  56. package/dist/pidlock.js +382 -0
  57. package/dist/pidlock.js.map +1 -0
  58. package/dist/places.d.ts +17 -0
  59. package/dist/places.js +44 -2
  60. package/dist/places.js.map +1 -1
  61. package/dist/policy.d.ts +2 -1
  62. package/dist/policy.js +5 -2
  63. package/dist/policy.js.map +1 -1
  64. package/dist/protocol.d.ts +4 -0
  65. package/dist/protocol.js +2 -0
  66. package/dist/protocol.js.map +1 -1
  67. package/dist/report.d.ts +8 -0
  68. package/dist/report.js +34 -1
  69. package/dist/report.js.map +1 -1
  70. package/dist/routes.d.ts +1 -1
  71. package/dist/routes.js +27 -11
  72. package/dist/routes.js.map +1 -1
  73. package/dist/runschedule.d.ts +119 -0
  74. package/dist/runschedule.js +281 -0
  75. package/dist/runschedule.js.map +1 -0
  76. package/dist/runtime.d.ts +48 -1
  77. package/dist/runtime.js +214 -18
  78. package/dist/runtime.js.map +1 -1
  79. package/dist/schedule.d.ts +145 -0
  80. package/dist/schedule.js +401 -0
  81. package/dist/schedule.js.map +1 -0
  82. package/dist/schedulehost.d.ts +73 -0
  83. package/dist/schedulehost.js +391 -0
  84. package/dist/schedulehost.js.map +1 -0
  85. package/dist/script.d.ts +2 -0
  86. package/dist/script.js +2 -0
  87. package/dist/script.js.map +1 -1
  88. package/dist/server.d.ts +6 -0
  89. package/dist/server.js +3 -1
  90. package/dist/server.js.map +1 -1
  91. package/dist/thincall.d.ts +13 -0
  92. package/dist/thincall.js +139 -59
  93. package/dist/thincall.js.map +1 -1
  94. package/dist/tool-text-data.d.ts +1 -1
  95. package/dist/tool-text-data.js +1 -1
  96. package/dist/tool-text-data.js.map +1 -1
  97. package/dist/tool-text.d.ts +1 -1
  98. package/dist/tools.d.ts +116 -6
  99. package/dist/tools.js +758 -134
  100. package/dist/tools.js.map +1 -1
  101. package/dist/unlockcmd.d.ts +44 -0
  102. package/dist/unlockcmd.js +80 -0
  103. package/dist/unlockcmd.js.map +1 -0
  104. package/dist/uxcheck.d.ts +1 -0
  105. package/dist/vault.d.ts +75 -0
  106. package/dist/vault.js +395 -0
  107. package/dist/vault.js.map +1 -0
  108. package/package.json +1 -1
  109. package/skills/tapline/SKILL.md +34 -6
  110. package/skills/tapline/references/setup.md +11 -1
package/dist/accounts.js CHANGED
@@ -1,26 +1,71 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { createHmac } from "node:crypto";
2
3
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
4
  import { dirname, join, parse, resolve } from "node:path";
4
5
  import { z } from "zod";
6
+ import { stable } from "./mapfile.js";
7
+ import { openProtected, peekFile, plainWarning, realVaultDeps, VaultOpenError } from "./vault.js";
5
8
  /**
6
- * Test accounts (AGENT-TOOLS 1.13 §18.6). The file holds real test credentials, so this module is built to keep them
7
- * from the agent: the file is refused inside a git work tree that doesn't ignore it, a value is typed only as the
8
- * whole text of a step, and `redact` scrubs every value from what the app sends back.
9
+ * The vault (AGENT-TOOLS 1.14 §20, which grew out of the test accounts of 1.13 §18.6). The file holds real test credentials
10
+ * and personal data, so this module is built to keep them from the agent: a plain file is refused inside a git work tree that
11
+ * doesn't ignore it, a value is typed only as the whole text of a step, and `redact` scrubs every value from what the app sends
12
+ * back. A version 1 accounts file still loads; each of its entries is an `account`.
9
13
  */
14
+ /** The name a version 1 account may have (kept as it was); version 2 names are ASCII letters, digits and `_`. */
10
15
  const ACCOUNT_NAME = /^[\w.-]{1,60}$/;
11
- // A shorter value would be scrubbed out of unrelated text. The message quotes no value.
12
- const SHORT = "must be at least 3 characters, so scrubbing it can't garble other text";
16
+ const ENTRY_NAME = /^[A-Za-z0-9_]{1,60}$/;
17
+ // Fail closed: a secret field (password, username, card number, expiry, cvc, a note's text, an identity's phone, email and
18
+ // birthday) shorter than 3 characters is refused at load, because a value that short is not scrubbed. The message quotes no value.
19
+ const SHORT = "must be at least 3 characters, because a shorter secret can't be scrubbed from answers without garbling other text";
20
+ /**
21
+ * A value shorter than this is held and typed, but not scrubbed: a country code such as `QA` would garble every answer. Only
22
+ * non-secret fields (an address's street, city, postcode and country, an identity's name) may be that short; every secret
23
+ * field is refused below this length at load.
24
+ */
25
+ const MIN_SCRUB = 3;
26
+ // -- kinds and fields (§20.1)
27
+ export const VAULT_KINDS = ["account", "card", "address", "identity", "note", "phone"];
28
+ /** The fields each kind has. `phone` is Tapwright's: Tapline holds it and never types it. */
29
+ export const KIND_FIELDS = {
30
+ account: ["username", "password"],
31
+ card: ["number", "expiry", "cvc"],
32
+ address: ["street", "city", "postcode", "country"],
33
+ identity: ["name", "phone", "email", "birthday"],
34
+ note: ["text"],
35
+ phone: ["passcode"],
36
+ };
37
+ const secret = z.string().min(3, SHORT);
38
+ const plain = z.string().min(1);
39
+ const apps = z.array(z.string().min(1)).optional();
40
+ const entrySchema = z.discriminatedUnion("kind", [
41
+ z.object({ kind: z.literal("account"), username: secret.optional(), password: secret.optional(), apps }),
42
+ z.object({ kind: z.literal("card"), number: secret.optional(), expiry: secret.optional(), cvc: secret.optional(), apps }),
43
+ z.object({ kind: z.literal("address"), street: plain.optional(), city: plain.optional(), postcode: plain.optional(), country: plain.optional(), apps }),
44
+ z.object({ kind: z.literal("identity"), name: plain.optional(), phone: secret.optional(), email: secret.optional(), birthday: secret.optional(), apps }),
45
+ z.object({ kind: z.literal("note"), text: secret.optional(), apps }),
46
+ z.object({ kind: z.literal("phone"), passcode: plain.optional(), apps }),
47
+ ]);
48
+ /** Version 2 (§20.1). A top-level `otp` key is Tapwright's and is ignored here. */
49
+ export const vaultFileSchema = z.object({
50
+ version: z.literal(2),
51
+ vault: z.record(z.string().regex(ENTRY_NAME, "entry names use ASCII letters, digits and '_'"), entrySchema),
52
+ });
13
53
  const accountSchema = z.object({
14
- username: z.string().min(3, SHORT).optional(),
15
- password: z.string().min(3, SHORT).optional(),
54
+ username: secret.optional(),
55
+ password: secret.optional(),
16
56
  /** Tapwright's: the phone's passcode. Tapline loads it without complaint and never types it. */
17
57
  passcode: z.string().min(1).optional(),
18
- apps: z.array(z.string().min(1)).optional(),
58
+ apps,
19
59
  });
60
+ /** Version 1 (§18.6). */
20
61
  export const accountsFileSchema = z.object({
21
62
  version: z.literal(1),
22
63
  accounts: z.record(z.string().regex(ACCOUNT_NAME, "account names use letters, digits, '.', '_' and '-'"), accountSchema),
23
64
  });
65
+ /** Where `value=` and the other answers name a value: `<home's street>`. */
66
+ export function placeholderOf(name, field) {
67
+ return `<${name}'s ${field}>`;
68
+ }
24
69
  function git(args, cwd) {
25
70
  return new Promise((done) => {
26
71
  execFile("git", args, { cwd, timeout: 10_000, windowsHide: true, encoding: "utf8" }, (error, stdout) => {
@@ -54,81 +99,732 @@ export const realGitProbe = {
54
99
  },
55
100
  };
56
101
  /** What the user is told wherever the accounts option is described (the parameter text, `--help`, `doctor`). */
57
- export const ACCOUNTS_WARNING = "A password stays out of pictures only while its field is secure; a \"show password\" toggle shows it. Keep the accounts file outside the folders the agent may read.";
102
+ export const ACCOUNTS_WARNING = "A password stays out of pictures only while its field is secure; a \"show password\" toggle shows it. Keep the vault file outside the folders the agent may read.";
58
103
  /** The refusal's words, for the first answer, `doctor` and every use. */
59
104
  export function blockedMessage(file) {
60
105
  return `accounts file is inside the git repo at ${file} and not ignored; move it or add it to .gitignore.`;
61
106
  }
62
- /** The invisible direction marks the app strips from text (react-native-tapline's `displayText`). */
63
- const DIRECTION_MARKS = /[‎‏‪-‮⁦-⁩]/g;
107
+ // -- redaction (§20.3)
108
+ /** Invisible direction marks (§3.3) and zero-width characters: the app strips the first, and text can carry the second. */
109
+ const INVISIBLE_CLASS = "\u200E\u200F\u202A-\u202E\u2066-\u2069\u200B-\u200D\u2060\uFEFF";
110
+ const INVISIBLE = new RegExp(`[${INVISIBLE_CLASS}]`, "g");
111
+ const INVISIBLE_ANY = new RegExp(`[${INVISIBLE_CLASS}]`);
112
+ function escapeRegExp(text) {
113
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
114
+ }
115
+ /** JSON escaping as a serializer that writes only ASCII produces it (`é`). */
116
+ function asciiJson(text) {
117
+ return JSON.stringify(text)
118
+ .slice(1, -1)
119
+ .replace(/[^\x00-\x7f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
120
+ }
121
+ const CUT = 200;
64
122
  /**
65
- * The shapes a value can take on its way back: as it is, inside a JSON string, in a URL (percent or form encoded), and
66
- * as the app's `normalizeText` leaves it (direction marks gone, whitespace collapsed and trimmed).
123
+ * The shapes a value can take on its way back: as it is, with direction marks and zero-width characters stripped, with
124
+ * whitespace collapsed (as the app's `normalizeText` leaves it), each of those inside a JSON string (also twice, and with
125
+ * non-ASCII escaped), and in a URL (percent or form encoded, either case of hex). A value the app cuts at 200 characters
126
+ * leaves its first 200.
67
127
  */
68
- function variantsOf(value) {
69
- const normalized = value.replace(DIRECTION_MARKS, "").replace(/\s+/g, " ").trim();
128
+ export function variantsOf(value) {
129
+ const collapse = (text) => text.replace(/\s+/g, " ").trim();
130
+ const stripped = value.replace(INVISIBLE, "");
131
+ const bases = new Set([value, stripped, collapse(value), collapse(stripped)]);
132
+ for (const base of [...bases])
133
+ if (base.length > CUT)
134
+ bases.add(base.slice(0, CUT));
70
135
  const forms = new Set();
71
- for (const base of [value, normalized]) {
72
- forms.add(base);
73
- forms.add(JSON.stringify(base).slice(1, -1));
74
- const percent = encodeURIComponent(base);
75
- forms.add(percent);
76
- forms.add(percent.replace(/%20/g, "+"));
77
- // URLSearchParams encodes more than encodeURIComponent does (`~` becomes %7E).
78
- forms.add(new URLSearchParams({ k: base }).toString().slice(2));
136
+ for (const base of bases) {
137
+ const jsons = [base, JSON.stringify(base).slice(1, -1), asciiJson(base), JSON.stringify(JSON.stringify(base)).slice(1, -1)];
138
+ for (const form of jsons) {
139
+ forms.add(form);
140
+ const percent = encodeURIComponent(form);
141
+ forms.add(percent);
142
+ forms.add(percent.replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase()));
143
+ forms.add(percent.replace(/%20/g, "+"));
144
+ // URLSearchParams encodes more than encodeURIComponent does (`~` becomes %7E).
145
+ forms.add(new URLSearchParams({ k: form }).toString().slice(2));
146
+ }
79
147
  }
80
148
  return [...forms].filter((form) => form !== "");
81
149
  }
82
- /** Longest variants first, so a value that holds another is scrubbed whole. */
83
- function scrubber(accounts) {
84
- const pairs = [];
85
- for (const [name, account] of Object.entries(accounts)) {
86
- if (account.username !== undefined)
87
- for (const form of variantsOf(account.username))
88
- pairs.push([form, `<${name}'s username>`]);
89
- if (account.password !== undefined)
90
- for (const form of variantsOf(account.password))
91
- pairs.push([form, `<${name}'s password>`]);
92
- }
93
- pairs.sort((a, b) => b[0].length - a[0].length);
150
+ /** The value with any whitespace run matching any whitespace and invisible characters allowed between its characters. */
151
+ function fuzzySource(value) {
152
+ const gap = `[${INVISIBLE_CLASS}]*`;
153
+ const parts = [];
154
+ for (const piece of value.split(/(\s+)/)) {
155
+ if (piece === "")
156
+ continue;
157
+ if (/^\s+$/.test(piece))
158
+ parts.push(`[\\s${INVISIBLE_CLASS}]+`);
159
+ else
160
+ parts.push([...piece].map((c) => escapeRegExp(c)).join(gap));
161
+ }
162
+ return parts.join(gap);
163
+ }
164
+ /** A hex number as a pattern that takes either case of its letters (`e9` matches `E9`). */
165
+ function hexSource(digits) {
166
+ return [...digits].map((d) => (/[a-f]/i.test(d) ? `[${d.toLowerCase()}${d.toUpperCase()}]` : d)).join("");
167
+ }
168
+ /** What a run of whitespace can come back as: itself, a percent or plus sign, a numeric entity, or a JSON escape. */
169
+ const SPACE_SOURCE = `(?:[\\s${INVISIBLE_CLASS}]|%(?:09|0[aA]|0[dD]|20)|\\+|&#0*(?:9|10|13|32);|&#[xX]0*(?:9|a|A|d|D|20);|\\\\{1,2}(?:[nrt]|u00(?:0[9aAdD]|20)))+`;
170
+ /** Every way one character can be written: raw, JSON `\uXXXX` (either case, once or twice escaped), percent-encoded, an HTML numeric entity. */
171
+ function charSource(ch) {
172
+ const cp = ch.codePointAt(0) ?? 0;
173
+ const alts = [escapeRegExp(ch)];
174
+ const units = [...ch].flatMap((c) => {
175
+ const out = [];
176
+ for (let i = 0; i < c.length; i++)
177
+ out.push(c.charCodeAt(i).toString(16).padStart(4, "0"));
178
+ return out;
179
+ });
180
+ alts.push(units.map((u) => `\\\\{1,2}u${hexSource(u)}`).join(""));
181
+ alts.push([...Buffer.from(ch, "utf8")].map((b) => `%${hexSource(b.toString(16).padStart(2, "0"))}`).join(""));
182
+ alts.push(`&#0*${cp};`, `&#[xX]0*${hexSource(cp.toString(16))};`);
183
+ return `(?:${alts.join("|")})`;
184
+ }
185
+ /** The value in any of its escape forms, character by character, with invisible characters allowed between (§20.3). */
186
+ function escapedSource(value) {
187
+ const gap = `[${INVISIBLE_CLASS}]*`;
188
+ const parts = [];
189
+ for (const piece of value.split(/(\s+)/)) {
190
+ if (piece === "")
191
+ continue;
192
+ parts.push(/^\s+$/.test(piece) ? SPACE_SOURCE : [...piece].map(charSource).join(gap));
193
+ }
194
+ return parts.join(gap);
195
+ }
196
+ /** The shortest prefix of a value that is scrubbed alone: what an app's cut leaves of it (§20.3, a fragment of 12 or more). */
197
+ const PREFIX_MIN = 12;
198
+ function plainView(text) {
199
+ const start = [];
200
+ const end = [];
201
+ for (let i = 0; i < text.length; i++) {
202
+ start.push(i);
203
+ end.push(i + 1);
204
+ }
205
+ return { text, start, end };
206
+ }
207
+ const HEX2 = /^[0-9a-fA-F]{2}$/;
208
+ function isHexCode(code) {
209
+ return (code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102);
210
+ }
211
+ /** The direction marks and zero-width characters of INVISIBLE_CLASS, by code. */
212
+ function isInvisibleCode(code) {
213
+ return code === 0x200e || code === 0x200f || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069) || (code >= 0x200b && code <= 0x200d) || code === 0x2060 || code === 0xfeff;
214
+ }
215
+ /** How many bytes the UTF-8 sequence opened by `lead` has (1 for a byte that opens none). */
216
+ function sequenceLength(lead) {
217
+ if (lead < 0x80)
218
+ return 1;
219
+ if (lead >= 0xf0 && lead < 0xf8)
220
+ return 4;
221
+ if (lead >= 0xe0 && lead < 0xf0)
222
+ return 3;
223
+ if (lead >= 0xc2 && lead < 0xe0)
224
+ return 2;
225
+ return 1;
226
+ }
227
+ const SIMPLE_ESCAPES = { n: "\n", r: "\r", t: "\t", b: "\b", f: "\f", "\\": "\\", '"': '"', "/": "/", "'": "'" };
228
+ const NAMED_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", sol: "/", bsol: "\\", num: "#", percnt: "%", plus: "+", equals: "=" };
229
+ const ENTITY = /^&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z]+));/;
230
+ /**
231
+ * One pass over a view that decodes every escape in it at once, each in a single left-to-right sweep (so the work is linear):
232
+ * a percent run (`%XX` bytes as UTF-8, a byte that is no sequence of its own stays escaped), JSON escapes (`\uXXXX` either hex
233
+ * case, `\n`, `\\`, `\"` ...), HTML entities (numeric of any padding, and the common named ones) and invisible characters (dropped).
234
+ * Every decoded character keeps the span of the bytes it was written with, and only those. `+` is not touched: it can be a form
235
+ * space or a plus, and the matcher accepts either. Null when nothing changed.
236
+ */
237
+ function decodePass(view) {
238
+ const src = view.text;
239
+ const n = src.length;
240
+ const parts = [];
241
+ const start = [];
242
+ const end = [];
243
+ let changed = false;
244
+ const put = (piece, from, to) => {
245
+ parts.push(piece);
246
+ for (let k = 0; k < piece.length; k++) {
247
+ start.push(from);
248
+ end.push(to);
249
+ }
250
+ };
251
+ let i = 0;
252
+ while (i < n) {
253
+ const code = src.charCodeAt(i);
254
+ if (isInvisibleCode(code)) {
255
+ changed = true;
256
+ i += 1;
257
+ }
258
+ else if (code === 37) {
259
+ // a run of %XX is read once, whole: a byte that decodes to nothing is kept and the sweep goes on past it
260
+ let j = i;
261
+ const bytes = [];
262
+ while (src.charCodeAt(j) === 37 && isHexCode(src.charCodeAt(j + 1)) && isHexCode(src.charCodeAt(j + 2))) {
263
+ bytes.push(parseInt(src.slice(j + 1, j + 3), 16));
264
+ j += 3;
265
+ }
266
+ if (bytes.length === 0) {
267
+ put("%", view.start[i], view.end[i]);
268
+ i += 1;
269
+ continue;
270
+ }
271
+ let b = 0;
272
+ while (b < bytes.length) {
273
+ const at = i + b * 3;
274
+ const lead = bytes[b];
275
+ const need = sequenceLength(lead);
276
+ let piece = null;
277
+ if (need === 1 && lead < 0x80)
278
+ piece = String.fromCharCode(lead);
279
+ else if (need > 1 && b + need <= bytes.length) {
280
+ const decoded = Buffer.from(bytes.slice(b, b + need)).toString("utf8");
281
+ if (!decoded.includes("�"))
282
+ piece = decoded;
283
+ }
284
+ if (piece === null) {
285
+ for (let k = 0; k < 3; k++)
286
+ put(src[at + k], view.start[at + k], view.end[at + k]);
287
+ b += 1;
288
+ }
289
+ else {
290
+ put(piece, view.start[at], view.end[at + need * 3 - 1]);
291
+ changed = true;
292
+ b += need;
293
+ }
294
+ }
295
+ i = j;
296
+ }
297
+ else if (code === 92) {
298
+ const next = src[i + 1];
299
+ let piece = null;
300
+ let used = 0;
301
+ if (next === "u" && i + 6 <= n && isHexCode(src.charCodeAt(i + 2)) && isHexCode(src.charCodeAt(i + 3)) && isHexCode(src.charCodeAt(i + 4)) && isHexCode(src.charCodeAt(i + 5))) {
302
+ piece = String.fromCharCode(parseInt(src.slice(i + 2, i + 6), 16));
303
+ used = 6;
304
+ }
305
+ else if (next !== undefined && Object.prototype.hasOwnProperty.call(SIMPLE_ESCAPES, next)) {
306
+ piece = SIMPLE_ESCAPES[next];
307
+ used = 2;
308
+ }
309
+ if (piece === null) {
310
+ put("\\", view.start[i], view.end[i]);
311
+ i += 1;
312
+ }
313
+ else {
314
+ put(piece, view.start[i], view.end[i + used - 1]);
315
+ changed = true;
316
+ i += used;
317
+ }
318
+ }
319
+ else if (code === 38) {
320
+ const m = ENTITY.exec(src.slice(i, i + 16));
321
+ let piece;
322
+ if (m !== null) {
323
+ if (m[1] !== undefined || m[2] !== undefined) {
324
+ const cp = m[1] !== undefined ? parseInt(m[1], 10) : parseInt(m[2], 16);
325
+ piece = cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : undefined;
326
+ }
327
+ else {
328
+ const name = m[3].toLowerCase();
329
+ piece = Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, name) ? NAMED_ENTITIES[name] : undefined;
330
+ }
331
+ }
332
+ if (m === null || piece === undefined) {
333
+ put("&", view.start[i], view.end[i]);
334
+ i += 1;
335
+ }
336
+ else {
337
+ put(piece, view.start[i], view.end[i + m[0].length - 1]);
338
+ changed = true;
339
+ i += m[0].length;
340
+ }
341
+ }
342
+ else {
343
+ parts.push(src[i]);
344
+ start.push(view.start[i]);
345
+ end.push(view.end[i]);
346
+ i += 1;
347
+ }
348
+ }
349
+ return changed ? { text: parts.join(""), start, end } : null;
350
+ }
351
+ /** How many decoding passes at most: each layer of encoding takes one, and a text with more is not one a person wrote. */
352
+ const MAX_LAYERS = 8;
353
+ const ESCAPE_HINT = new RegExp(`[%\\\\&${INVISIBLE_CLASS}]`);
354
+ /**
355
+ * The text and what it decodes to, pass after pass, until nothing more decodes (at most MAX_LAYERS passes, each linear in the
356
+ * text). Every layer in any nesting order (HTML, JSON, percent) is undone by the passes together; the stages are all kept, so
357
+ * a text that was only partly encoded is read as it stands at each stage too.
358
+ */
359
+ function viewsOf(text) {
360
+ const views = [plainView(text)];
361
+ if (!ESCAPE_HINT.test(text))
362
+ return views;
363
+ for (let layer = 0; layer < MAX_LAYERS; layer++) {
364
+ const next = decodePass(views[views.length - 1]);
365
+ if (next === null || next.text === views[views.length - 1].text)
366
+ break;
367
+ views.push(next);
368
+ }
369
+ return views;
370
+ }
371
+ /** The view without invisible characters, each whitespace run as one space. */
372
+ function normalizedView(view) {
373
+ const parts = [];
374
+ const start = [];
375
+ const end = [];
376
+ let inSpace = false;
377
+ for (let i = 0; i < view.text.length; i++) {
378
+ const c = view.text[i];
379
+ if (isInvisibleCode(view.text.charCodeAt(i)))
380
+ continue;
381
+ const space = /\s/.test(c);
382
+ if (space && inSpace) {
383
+ end[end.length - 1] = view.end[i];
384
+ continue;
385
+ }
386
+ inSpace = space;
387
+ parts.push(space ? " " : c);
388
+ start.push(view.start[i]);
389
+ end.push(view.end[i]);
390
+ }
391
+ return { text: parts.join(""), start, end };
392
+ }
393
+ /** How much of a half-written escape (a cut in the middle of `%C3%A`, `\u00`, `&#x`) follows `at`: it belongs to the fragment. */
394
+ function dangling(text, at) {
395
+ const rest = text.slice(at, at + 64);
396
+ const percent = /^%(?:[0-9a-fA-F](?![0-9a-fA-F]))?/.exec(rest);
397
+ if (percent !== null && !HEX2.test(rest.slice(1, 3)))
398
+ return percent[0].length;
399
+ const json = /^\\{1,2}u[0-9a-fA-F]{0,3}(?![0-9a-fA-F])/.exec(rest);
400
+ if (json !== null)
401
+ return json[0].length;
402
+ const entity = /^&#?[xX]?[0-9a-fA-F]*(?![0-9a-fA-F;])/.exec(rest);
403
+ if (entity !== null && entity[0].length > 1 && /^&#/.test(rest))
404
+ return entity[0].length;
405
+ const run = /^(?:%[0-9a-fA-F]{2})+/.exec(rest);
406
+ if (run !== null) {
407
+ const bytes = [];
408
+ for (let k = 0; k < run[0].length; k += 3)
409
+ bytes.push(parseInt(run[0].slice(k + 1, k + 3), 16));
410
+ let b = 0;
411
+ while (b < bytes.length) {
412
+ const need = sequenceLength(bytes[b]);
413
+ if (b + need > bytes.length)
414
+ return (bytes.length - b) * 3;
415
+ b += need;
416
+ }
417
+ }
418
+ return 0;
419
+ }
420
+ function probeOf(base, token) {
421
+ const head = base.slice(0, PREFIX_MIN);
422
+ return { base, token, head: new RegExp([...head].map((c) => (c === " " ? "[ +]" : escapeRegExp(c))).join(""), "g") };
423
+ }
424
+ const sameChar = (seen, wanted) => seen === wanted || (wanted === " " && seen === "+");
425
+ /**
426
+ * Every place a decoded view holds a value (all of it, when it is shorter than 12 characters) or a prefix of 12 or more of its
427
+ * characters: the first 12 and as many more as the text goes on to match. The span is the original text of those characters.
428
+ */
429
+ function probeSpans(views, probe, out) {
430
+ const { base, head, token } = probe;
431
+ const need = Math.min(PREFIX_MIN, base.length);
432
+ for (const view of views) {
433
+ if (view.text.length < need)
434
+ continue;
435
+ head.lastIndex = 0;
436
+ for (let m = head.exec(view.text); m !== null; m = head.exec(view.text)) {
437
+ const from = m.index;
438
+ head.lastIndex = from + 1;
439
+ let len = need;
440
+ while (len < base.length && sameChar(view.text[from + len], base[len]))
441
+ len++;
442
+ const last = from + len - 1;
443
+ const extra = dangling(view.text, last + 1);
444
+ out.push({ start: view.start[from], end: view.end[last + extra], token });
445
+ }
446
+ }
447
+ }
448
+ // -- encoded forms matched directly (the literal reading is never lost to decoding)
449
+ /** One layer of encoding a value can be wrapped in on its way back. */
450
+ const ENCODERS = [
451
+ (s) => JSON.stringify(s).slice(1, -1),
452
+ (s) => asciiJson(s),
453
+ (s) => asciiJson(s).replace(/\\u([0-9a-f]{4})/g, (_m, h) => `\\u${h.toUpperCase()}`),
454
+ (s) => encodeURIComponent(s),
455
+ (s) => encodeURIComponent(s).replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase()),
456
+ (s) => encodeURIComponent(s).replace(/%20/g, "+"),
457
+ (s) => encodeURIComponent(s).replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase()).replace(/%20/g, "+"),
458
+ (s) => new URLSearchParams({ k: s }).toString().slice(2),
459
+ (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"),
460
+ (s) => [...s].map((c) => (/[a-zA-Z0-9]/.test(c) ? c : `&#${c.codePointAt(0)};`)).join(""),
461
+ // a serializer that names the four markup characters and writes every other symbol as a number
462
+ (s) => [...s].map((c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : /[a-zA-Z0-9]/.test(c) ? c : `&#${c.codePointAt(0)};`)).join(""),
463
+ (s) => [...s].map((c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : /[a-zA-Z0-9]/.test(c) ? c : `&#x${(c.codePointAt(0) ?? 0).toString(16)};`)).join(""),
464
+ (s) => [...s].map((c) => (/[a-zA-Z0-9]/.test(c) ? c : `&#x${(c.codePointAt(0) ?? 0).toString(16)};`)).join(""),
465
+ (s) => [...s].map((c) => (/[a-zA-Z0-9]/.test(c) ? c : `&#x${(c.codePointAt(0) ?? 0).toString(16).toUpperCase()};`)).join(""),
466
+ ];
467
+ /** How many layers of encoding are combined, by how long the value is (the forms are values times encoders to that power). */
468
+ function layerLimit(length) {
469
+ return length <= 64 ? 3 : length <= 300 ? 2 : 1;
470
+ }
471
+ const FORM_CAP = 20000;
472
+ /**
473
+ * Every way the value reads once it is encoded: each encoding alone and the combinations in any order up to the layer limit
474
+ * (JSON inside a URL, a URL inside HTML ...). A literal `%41`, a quote or an ampersand in the value is written, not decoded, in
475
+ * each, so the form a text really holds can be found without decoding it first.
476
+ */
477
+ function encodedForms(base) {
478
+ const seen = new Set([base]);
479
+ let layer = [base];
480
+ for (let depth = 0; depth < layerLimit(base.length); depth++) {
481
+ const next = [];
482
+ for (const text of layer) {
483
+ for (const encode of ENCODERS) {
484
+ const form = encode(text);
485
+ if (form.length > FORM_CAP || seen.has(form))
486
+ continue;
487
+ seen.add(form);
488
+ next.push(form);
489
+ }
490
+ }
491
+ layer = next;
492
+ }
493
+ seen.delete(base);
494
+ return [...seen].filter((form) => form !== "");
495
+ }
496
+ function formIndexOf(bases) {
497
+ const heads = new Map();
498
+ const shorts = [];
499
+ const seen = new Set();
500
+ for (const [base, token] of bases) {
501
+ for (const form of [base, ...encodedForms(base)]) {
502
+ const key = `${token}\u0000${form}`;
503
+ if (seen.has(key))
504
+ continue;
505
+ seen.add(key);
506
+ if (form.length < PREFIX_MIN) {
507
+ if (form.length >= MIN_SCRUB)
508
+ shorts.push({ form, token });
509
+ continue;
510
+ }
511
+ const head = form.slice(0, PREFIX_MIN);
512
+ const list = heads.get(head);
513
+ if (list === undefined)
514
+ heads.set(head, [{ form, token }]);
515
+ else
516
+ list.push({ form, token });
517
+ }
518
+ }
519
+ return { heads, shorts };
520
+ }
521
+ /** How many characters a fragment of an encoded form reads as once decoded (`https%253A%252F%252Fp` is `https://p`: 9). */
522
+ function decodedLength(fragment) {
523
+ const views = viewsOf(fragment);
524
+ return [...views[views.length - 1].text].length;
525
+ }
526
+ /**
527
+ * Whether a prefix of an encoded form, which carries fewer than 12 decoded characters, is a text really cut there: it ends the text,
528
+ * a cut marker (`…`, `...`, `…(+N)`) follows, or what follows is not a character that could carry the value on (a delimiter such as
529
+ * `]`, a space or a quote that the form itself never holds). A letter, a digit or one of the form's own characters that differs from the
530
+ * form's next one says the text goes on as something else: not a truncation.
531
+ */
532
+ function reallyCut(text, end, form) {
533
+ if (end >= text.length)
534
+ return true;
535
+ const next = text[end];
536
+ if (next === "\u2026" || text.startsWith("...", end))
537
+ return true;
538
+ return !/[A-Za-z0-9]/.test(next) && !form.includes(next);
539
+ }
540
+ /**
541
+ * Every place the text holds an encoded form of a value, or a prefix of 12 or more characters of one. The text is searched as it
542
+ * stands and in each decoded view, so a form that decoding would damage is found at the stage where it still reads literally.
543
+ */
544
+ function formSpans(views, index, out) {
545
+ for (const view of views) {
546
+ const text = view.text;
547
+ for (const { form, token } of index.shorts) {
548
+ let at = text.indexOf(form);
549
+ while (at !== -1) {
550
+ out.push({ start: view.start[at], end: view.end[at + form.length - 1], token });
551
+ at = text.indexOf(form, at + 1);
552
+ }
553
+ }
554
+ for (let i = 0; i + PREFIX_MIN <= text.length; i++) {
555
+ const list = index.heads.get(text.slice(i, i + PREFIX_MIN));
556
+ if (list === undefined)
557
+ continue;
558
+ for (const { form, token } of list) {
559
+ let len = PREFIX_MIN;
560
+ while (len < form.length && i + len < text.length && text.charCodeAt(i + len) === form.charCodeAt(len))
561
+ len++;
562
+ // 12 encoded characters can hold far fewer decoded ones: such a prefix is a match only where the text is really cut.
563
+ if (len < form.length && decodedLength(text.slice(i, i + len)) < PREFIX_MIN && !reallyCut(text, i + len, form))
564
+ continue;
565
+ out.push({ start: view.start[i], end: view.end[i + len - 1], token });
566
+ }
567
+ }
568
+ }
569
+ }
570
+ /**
571
+ * Every value's every form, found in one pass and cut into spans. Where spans overlap the leftmost wins (the longest of those
572
+ * that start together), and one that runs past its end is scrubbed beyond it too, so no fragment of a longer value survives a
573
+ * shorter one. A placeholder is never scrubbed again.
574
+ */
575
+ function scrubber(entries, tokenOf = placeholderOf) {
576
+ const literals = new Map();
577
+ const patterns = new Map();
578
+ const bases = new Map();
579
+ const formBases = new Map();
580
+ for (const [name, entry] of Object.entries(entries)) {
581
+ for (const [field, value] of Object.entries(entry.values)) {
582
+ if (value.length < MIN_SCRUB)
583
+ continue;
584
+ const token = tokenOf(name, field);
585
+ for (const form of variantsOf(value))
586
+ if (!literals.has(form))
587
+ literals.set(form, token);
588
+ const collapsed = value.replace(INVISIBLE, "").replace(/\s+/g, " ").trim();
589
+ const addPattern = (source) => {
590
+ if (!patterns.has(source))
591
+ patterns.set(source, token);
592
+ };
593
+ if (value.length <= 500)
594
+ for (const base of new Set([value, collapsed]))
595
+ if (base.length >= MIN_SCRUB)
596
+ addPattern(fuzzySource(base));
597
+ if (value.length <= 2000)
598
+ for (const base of new Set([value, collapsed]))
599
+ if (base.length >= MIN_SCRUB)
600
+ addPattern(escapedSource(base));
601
+ // The value whole, and what an app's cut leaves of it (a prefix of 12 or more characters), in every layer of encoding.
602
+ if (collapsed.length >= MIN_SCRUB && !bases.has(collapsed))
603
+ bases.set(collapsed, token);
604
+ // The value's encoded forms are searched in the raw text itself, so decoding can never lose the literal reading.
605
+ for (const base of new Set([value, collapsed]))
606
+ if (base.length >= MIN_SCRUB && !formBases.has(base))
607
+ formBases.set(base, token);
608
+ }
609
+ }
610
+ const regexes = [...patterns].map(([source, token]) => ({ re: new RegExp(`(?=(${source}))`, "g"), token }));
611
+ const forms = [...literals];
612
+ const probes = [...bases].map(([base, token]) => probeOf(base, token));
613
+ const formIndex = formIndexOf(formBases);
614
+ if (forms.length === 0 && regexes.length === 0 && probes.length === 0)
615
+ return (text) => text;
94
616
  return (text) => {
95
- let out = text;
96
- for (const [value, token] of pairs)
97
- if (out.includes(value))
98
- out = out.split(value).join(token);
617
+ if (text.length < MIN_SCRUB)
618
+ return text;
619
+ const spans = [];
620
+ for (const [form, token] of forms) {
621
+ let at = text.indexOf(form);
622
+ while (at !== -1) {
623
+ spans.push({ start: at, end: at + form.length, token });
624
+ at = text.indexOf(form, at + 1);
625
+ }
626
+ }
627
+ for (const { re, token } of regexes) {
628
+ re.lastIndex = 0;
629
+ for (const m of text.matchAll(re)) {
630
+ const got = m[1].length;
631
+ if (got > 0)
632
+ spans.push({ start: m.index, end: m.index + got, token });
633
+ }
634
+ }
635
+ if (probes.length > 0) {
636
+ const raw = viewsOf(text);
637
+ const views = raw.map(normalizedView);
638
+ for (const probe of probes)
639
+ probeSpans(views, probe, spans);
640
+ formSpans(raw, formIndex, spans);
641
+ }
642
+ if (spans.length === 0)
643
+ return text;
644
+ spans.sort((a, b) => a.start - b.start || b.end - a.end);
645
+ let out = "";
646
+ let cursor = 0;
647
+ let last = "";
648
+ for (const span of spans) {
649
+ if (span.end <= cursor)
650
+ continue;
651
+ if (span.start >= cursor)
652
+ out += text.slice(cursor, span.start) + span.token;
653
+ else if (span.token !== last)
654
+ out += span.token;
655
+ last = span.token;
656
+ cursor = span.end;
657
+ }
658
+ return out + text.slice(cursor);
659
+ };
660
+ }
661
+ /**
662
+ * A scrubber for loose values that are not vault entries (a one-time code, AGENT-TOOLS 1.14 §20.4): each shows as `token`.
663
+ * The same forms and the same minimum length as a vault value.
664
+ */
665
+ export function looseScrubber(values, token) {
666
+ const entries = {};
667
+ values.forEach((value, i) => {
668
+ entries[`v${i}`] = { kind: "note", values: { text: value } };
669
+ });
670
+ return scrubber(entries, () => token);
671
+ }
672
+ function normalizedEntries(file) {
673
+ const out = {};
674
+ if (file.version === 1) {
675
+ for (const [name, account] of Object.entries(file.accounts)) {
676
+ const values = {};
677
+ for (const field of ["username", "password", "passcode"]) {
678
+ const value = account[field];
679
+ if (value !== undefined)
680
+ values[field] = value;
681
+ }
682
+ const onlyPasscode = values["passcode"] !== undefined && values["username"] === undefined && values["password"] === undefined;
683
+ out[name] = { kind: onlyPasscode ? "phone" : "account", values, ...(account.apps !== undefined ? { apps: account.apps } : {}) };
684
+ }
99
685
  return out;
686
+ }
687
+ for (const [name, entry] of Object.entries(file.vault)) {
688
+ const { kind, apps: appList, ...fields } = entry;
689
+ const values = {};
690
+ for (const [field, value] of Object.entries(fields))
691
+ if (typeof value === "string")
692
+ values[field] = value;
693
+ out[name] = { kind, values, ...(appList !== undefined ? { apps: appList } : {}) };
694
+ }
695
+ return out;
696
+ }
697
+ function has(entries, name) {
698
+ return Object.prototype.hasOwnProperty.call(entries, name) ? entries[name] : undefined;
699
+ }
700
+ const PASSCODE_NOTE = "an account's passcode is typed only by Tapwright on a locked phone's keypad; Tapline never types it.";
701
+ /** The entries each store was made from, so a scrubber over several stores can be built from their union (never chained). */
702
+ const ENTRIES_OF = new WeakMap();
703
+ /**
704
+ * One scrubber over the values of several stores (the current one and the ones a reload retired): every value is matched against the
705
+ * ORIGINAL text in one pass and the spans are merged leftmost-longest, so a value two stores share a prefix, a suffix or a middle
706
+ * of is never half-scrubbed by the other one. Each value keeps its own entry's placeholder.
707
+ */
708
+ export function combinedRedact(stores, codes) {
709
+ const union = {};
710
+ stores.forEach((store, i) => {
711
+ const entries = ENTRIES_OF.get(store);
712
+ if (entries === undefined)
713
+ return;
714
+ for (const [name, entry] of Object.entries(entries))
715
+ union[`${i}\u0000${name}`] = entry;
716
+ });
717
+ // The registered one-time codes are sources of the same matcher (section 20.4): a code that overlaps a vault value is matched
718
+ // against the same original text, so the longer complete match wins and no fragment of either is left.
719
+ codes?.values.forEach((value, i) => {
720
+ union[`${CODE_KEY}${i}`] = { kind: "note", values: { text: value } };
721
+ });
722
+ return scrubber(union, (key, field) => (codes !== undefined && key.startsWith(CODE_KEY) ? codes.token : placeholderOf(key.slice(key.indexOf("\u0000") + 1), field)));
723
+ }
724
+ const CODE_KEY = "otp\u0001";
725
+ /** The stores that make a store's values: itself, or (a store that keeps retired values) the current one and the retired ones. */
726
+ const BASES_OF = new WeakMap();
727
+ /** A store that also scrubs every retired store's values (redaction is cumulative for a session, section 20.3), in one matcher. */
728
+ export function historyStore(current, retired) {
729
+ let built = -1;
730
+ let combined = current.redact;
731
+ const store = {
732
+ ...current,
733
+ redact: (text) => {
734
+ if (built !== retired.length) {
735
+ combined = retired.length === 0 ? current.redact : combinedRedact([current, ...retired]);
736
+ built = retired.length;
737
+ }
738
+ return combined(text);
739
+ },
100
740
  };
741
+ BASES_OF.set(store, () => [current, ...retired]);
742
+ return store;
101
743
  }
102
- export function accountStore(file) {
103
- const accounts = file.accounts;
104
- const scrub = scrubber(accounts);
105
- const names = Object.keys(accounts);
106
- const list = () => (names.length === 0 ? "the accounts file has none" : `the accounts are ${names.join(", ")}`);
107
- return {
744
+ const SHARED = new WeakMap();
745
+ /**
746
+ * Scrubs a text of every vault value (current and retired) AND every registered one-time code with ONE matcher over the original
747
+ * text, never one scrubber after another: where a code and a vault value overlap, the longer complete match wins. The matcher is
748
+ * cached until a code is registered or the vault's stores change.
749
+ */
750
+ export function redactAll(store, codes, text) {
751
+ if (store === null || store === undefined)
752
+ return codes.redact(text);
753
+ const values = codes.values();
754
+ if (values.length === 0)
755
+ return store.redact(text);
756
+ const bases = BASES_OF.get(store)?.() ?? [store];
757
+ if (bases.some((base) => ENTRIES_OF.get(base) === undefined))
758
+ return codes.redact(store.redact(text));
759
+ const hit = SHARED.get(store);
760
+ if (hit !== undefined && hit.codes === values && hit.bases.length === bases.length && hit.bases.every((base, i) => base === bases[i]))
761
+ return hit.redact(text);
762
+ const redact = combinedRedact(bases, { values, token: codes.token });
763
+ SHARED.set(store, { bases: [...bases], codes: values, redact });
764
+ return redact(text);
765
+ }
766
+ function storeOf(entries) {
767
+ const scrub = scrubber(entries);
768
+ const names = Object.keys(entries);
769
+ const list = (word) => (names.length === 0 ? "the vault file has none" : `the ${word} are ${names.join(", ")}`);
770
+ const forApp = (entry, appId) => entry.apps !== undefined && !entry.apps.includes(appId) ? `isn't for this app (${appId}); it is for ${entry.apps.join(", ")}.` : null;
771
+ const store = {
108
772
  names: () => [...names],
773
+ kindOf: (name) => has(entries, name)?.kind,
109
774
  redact: scrub,
110
775
  resolve(name, field, appId) {
111
- const account = Object.prototype.hasOwnProperty.call(accounts, name) ? accounts[name] : undefined;
112
- if (account === undefined)
113
- return { ok: false, code: "not_found", message: `no account "${name}"; ${list()}.` };
114
- if (account.apps !== undefined && !account.apps.includes(appId)) {
115
- return { ok: false, code: "bad_params", message: `account "${name}" isn't for this app (${appId}); it is for ${account.apps.join(", ")}.` };
776
+ const entry = has(entries, name);
777
+ if (entry === undefined)
778
+ return { ok: false, code: "not_found", message: `no account "${name}"; ${list("accounts")}.` };
779
+ if (entry.kind !== "account" && entry.kind !== "phone") {
780
+ return { ok: false, code: "bad_params", message: `"account": "${name}" names ${entry.kind === "address" ? "an" : "a"} ${entry.kind} entry; "account" names account entries only (use \${vault.${name}.<field>}).` };
116
781
  }
117
- const value = account[field];
782
+ const wrong = forApp(entry, appId);
783
+ if (wrong !== null)
784
+ return { ok: false, code: "bad_params", message: `account "${name}" ${wrong}` };
785
+ const value = entry.values[field];
118
786
  if (value === undefined)
119
787
  return { ok: false, code: "bad_params", message: `account "${name}" has no ${field}.` };
120
788
  return { ok: true, value };
121
789
  },
790
+ resolveEntry(name, field, appId) {
791
+ const entry = has(entries, name);
792
+ if (entry === undefined)
793
+ return { ok: false, code: "not_found", message: `no vault entry "${name}"; ${list("entries")}.` };
794
+ if (entry.kind === "phone")
795
+ return { ok: false, code: "bad_params", message: PASSCODE_NOTE };
796
+ if (!KIND_FIELDS[entry.kind].includes(field)) {
797
+ return { ok: false, code: "bad_params", message: `vault entry "${name}" is ${entry.kind === "address" ? "an" : "a"} ${entry.kind}; its fields are ${KIND_FIELDS[entry.kind].join(", ")}.` };
798
+ }
799
+ const wrong = forApp(entry, appId);
800
+ if (wrong !== null)
801
+ return { ok: false, code: "bad_params", message: `vault entry "${name}" ${wrong}` };
802
+ const value = entry.values[field];
803
+ if (value === undefined)
804
+ return { ok: false, code: "bad_params", message: `vault entry "${name}" has no ${field}.` };
805
+ return { ok: true, value };
806
+ },
122
807
  };
808
+ ENTRIES_OF.set(store, entries);
809
+ return store;
810
+ }
811
+ /** The store of a version 1 file (the accounts of §18.6). */
812
+ export function accountStore(file) {
813
+ return storeOf(normalizedEntries(file));
814
+ }
815
+ /** The store of a version 2 file (§20.1). */
816
+ export function vaultStore(file) {
817
+ return storeOf(normalizedEntries(file));
123
818
  }
124
819
  /**
125
- * Reads the accounts file. Inside a git work tree that doesn't ignore it, nothing is read or loaded. A file that can't
126
- * be read or isn't the format throws a plain message that never quotes the file (it holds secrets).
820
+ * Reads the vault file. A plain file inside a git work tree that doesn't ignore it is not loaded (a protected file holds no
821
+ * value, so that rule doesn't apply to it). A protected file that can't be opened loads nothing and says why. A file that
822
+ * can't be read or isn't the format throws a plain message that never quotes the file (it holds secrets).
127
823
  */
128
- export async function loadAccounts(file, probe = realGitProbe) {
824
+ export async function loadAccounts(file, probe = realGitProbe, deps = realVaultDeps(), signal) {
129
825
  const path = resolve(file);
130
826
  if (!existsSync(path))
131
- throw new Error(`the accounts file ${path} doesn't exist`);
827
+ throw new Error(`the vault file ${path} doesn't exist`);
132
828
  // Through a symlink or a junction the file may really sit in a repo: ask about where it is.
133
829
  let real = path;
134
830
  try {
@@ -137,25 +833,49 @@ export async function loadAccounts(file, probe = realGitProbe) {
137
833
  catch {
138
834
  // the path stands
139
835
  }
140
- // A `.git` folder in the path is a repo's own files, which no .gitignore covers.
141
- const inGitDir = real.split(/[\\/]/).includes(".git");
142
- if (inGitDir || ((await probe.insideWorkTree(dirname(real))) && !(await probe.ignored(real)))) {
143
- return { store: null, blocked: blockedMessage(path) };
144
- }
145
- let json;
836
+ let text;
146
837
  try {
147
- json = JSON.parse(readFileSync(real, "utf8"));
838
+ text = readFileSync(real, "utf8");
148
839
  }
149
840
  catch {
150
- // Node's own message quotes part of the text, and this file holds passwords.
151
- throw new Error(`the accounts file ${path} isn't valid JSON`);
841
+ throw new Error(`the vault file ${path} can't be read`);
842
+ }
843
+ const peek = peekFile(text);
844
+ let json;
845
+ if (peek?.protected !== undefined) {
846
+ try {
847
+ json = JSON.parse(await openProtected(real, peek, deps, signal));
848
+ }
849
+ catch (error) {
850
+ if (error instanceof VaultOpenError)
851
+ return { store: null, blocked: `the vault file can't be opened: ${error.message.replace(/[.\s]+$/, "")}.` };
852
+ throw new Error(`the vault file ${path} was opened, but what it holds isn't valid JSON`);
853
+ }
854
+ }
855
+ else {
856
+ // A `.git` folder in the path is a repo's own files, which no .gitignore covers.
857
+ const inGitDir = real.split(/[\\/]/).includes(".git");
858
+ if (inGitDir || ((await probe.insideWorkTree(dirname(real))) && !(await probe.ignored(real)))) {
859
+ return { store: null, blocked: blockedMessage(path) };
860
+ }
861
+ try {
862
+ json = JSON.parse(text);
863
+ }
864
+ catch {
865
+ // Node's own message quotes part of the text, and this file holds passwords.
866
+ throw new Error(`the vault file ${path} isn't valid JSON`);
867
+ }
152
868
  }
153
- const parsed = accountsFileSchema.safeParse(json);
869
+ const version = typeof json === "object" && json !== null ? json.version : undefined;
870
+ const parsed = version === 2 ? vaultFileSchema.safeParse(json) : accountsFileSchema.safeParse(json);
154
871
  if (!parsed.success) {
155
872
  const why = parsed.error.issues.map((issue) => `${issue.path.join(".") || "file"}: ${issue.message}`).join("; ");
156
- throw new Error(`the accounts file ${path} isn't the right format: ${why}`);
873
+ throw new Error(`the vault file ${path} isn't the right format: ${why}`);
157
874
  }
158
- return { store: accountStore(parsed.data) };
875
+ const entries = normalizedEntries(parsed.data);
876
+ const store = storeOf(entries);
877
+ const fingerprint = (key) => createHmac("sha256", key).update(stable(entries)).digest("hex");
878
+ return peek?.protected === undefined ? { store, note: plainWarning(path), fingerprint } : { store, fingerprint };
159
879
  }
160
880
  /**
161
881
  * Scrubs every string in a JSON value (an app's answer), keeping its shape. Property names are scrubbed too; two names
@@ -180,27 +900,76 @@ export function redactValue(value, redact) {
180
900
  }
181
901
  return value;
182
902
  }
183
- const WHOLE = /^\$\{account\.(\w+)\}$/;
184
903
  /**
185
- * `${account.username}` or `${account.password}` becomes the value, but only when it is the whole text: joined with
186
- * other text it could get a password past the secure-field rule. Text without a placeholder passes unchanged (field null).
904
+ * The backstop at a writer of files (§20.3): scrubs the content values of a JSON value, never its keys, so a script keeps
905
+ * its tool names and its `${vault.home.street}` placeholders.
906
+ */
907
+ export function redactContent(value, redact) {
908
+ if (typeof value === "string") {
909
+ // `${…}` is syntax (a placeholder, a variable): only the text between is content.
910
+ return value
911
+ .split(/(\$\{[^}]*\})/)
912
+ .map((piece) => (piece.startsWith("${") && piece.endsWith("}") ? piece : redact(piece)))
913
+ .join("");
914
+ }
915
+ if (Array.isArray(value))
916
+ return value.map((item) => redactContent(item, redact));
917
+ if (typeof value === "object" && value !== null) {
918
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, CONTENT_FREE.has(key) ? item : redactContent(item, redact)]));
919
+ }
920
+ return value;
921
+ }
922
+ /** Keys whose values are the tool's own words (a tool or method name, a time, a format), never a typed or shown value. */
923
+ const CONTENT_FREE = new Set(["t", "tool", "source", "method", "format", "version", "kind", "app", "name", "start", "end", "route", "variables"]);
924
+ const WHOLE_ACCOUNT = /^\$\{account\.(\w+)\}$/;
925
+ const WHOLE_VAULT = /^\$\{vault\.(\w+)\.(\w+)\}$/;
926
+ const VAULT_ANY = /^\$\{vault\.[^}]*\}$/;
927
+ /** Whether a text refers to the vault (`${account.…}`, `${vault.…}`) or to a one-time code (`${otp.<app>}`, §20.4). */
928
+ export function mentionsVault(text) {
929
+ return text.includes("${account.") || text.includes("${vault.") || text.includes("${otp.");
930
+ }
931
+ /**
932
+ * `${account.username}`, `${account.password}` or `${vault.<name>.<field>}` becomes the value, but only when it is the whole
933
+ * text: joined with other text it could get a password past the secure-field rule. Text without a reference passes unchanged
934
+ * (field null).
187
935
  */
188
936
  export function substituteAccount(text, store, name, appId) {
189
- const whole = WHOLE.exec(text);
937
+ // A one-time code is fetched and typed by the tool itself (otpclient.ts); text that reaches here isn't one it can type.
938
+ if (text.includes("${otp.")) {
939
+ const alone = /^\$\{otp\.[^}]+\}$/.test(text);
940
+ return { ok: false, code: "bad_params", message: alone ? "a one-time code takes no account; write ${otp.<app>} alone." : "a one-time code is the whole text of a step, not part of one." };
941
+ }
942
+ const vault = WHOLE_VAULT.exec(text);
943
+ if (vault !== null) {
944
+ const entry = vault[1] ?? "";
945
+ const field = vault[2] ?? "";
946
+ const resolved = store.resolveEntry(entry, field, appId);
947
+ if (!resolved.ok)
948
+ return resolved;
949
+ const kind = store.kindOf(entry);
950
+ if (kind === undefined)
951
+ return { ok: false, code: "not_found", message: `no vault entry "${entry}".` };
952
+ return { ok: true, text: resolved.value, field, use: { name: entry, kind, field } };
953
+ }
954
+ if (VAULT_ANY.test(text)) {
955
+ return { ok: false, code: "bad_params", message: "a vault value is written \${vault.<name>.<field>}, for example \${vault.home.street}." };
956
+ }
957
+ const whole = WHOLE_ACCOUNT.exec(text);
190
958
  if (whole === null) {
959
+ if (text.includes("${vault."))
960
+ return { ok: false, code: "bad_params", message: "a vault value is the whole text of a step, not part of one." };
191
961
  if (text.includes("${account."))
192
962
  return { ok: false, code: "bad_params", message: "an account value is the whole text of a step, not part of one." };
193
963
  return { ok: true, text, field: null };
194
964
  }
195
965
  const field = whole[1];
196
- if (field === "passcode") {
197
- return { ok: false, code: "bad_params", message: "an account's passcode is typed only by Tapwright on a locked phone's keypad; Tapline never types it." };
198
- }
966
+ if (field === "passcode")
967
+ return { ok: false, code: "bad_params", message: PASSCODE_NOTE };
199
968
  if (field !== "username" && field !== "password") {
200
969
  return { ok: false, code: "bad_params", message: `\${account.${field ?? ""}} isn't a value an account has; use \${account.username} or \${account.password}.` };
201
970
  }
202
971
  if (name === undefined) {
203
- return { ok: false, code: "bad_params", message: `name the account: add "account": "<name>" (${store.names().length === 0 ? "the accounts file has none" : `the accounts are ${store.names().join(", ")}`}).` };
972
+ return { ok: false, code: "bad_params", message: `name the account: add "account": "<name>" (${store.names().length === 0 ? "the vault file has none" : `the entries are ${store.names().join(", ")}`}).` };
204
973
  }
205
974
  const resolved = store.resolve(name, field, appId);
206
975
  return resolved.ok ? { ok: true, text: resolved.value, field } : resolved;