patchwork-os 1.2.0-beta.2.canary.650 → 1.2.0-beta.2.canary.652

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.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Per-member local credentials — ADR-0020 Phase A.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `canApproveAction` is referenced by nothing in production, and not because
7
+ * anyone forgot. The bridge authenticates ONE shared bearer token, and the
8
+ * dashboard session cookie's signed payload is literally `v1.${expiresAt}` —
9
+ * not "no subject field" but no field except expiry. Every approver is
10
+ * indistinguishable at the auth layer, so segregation of duties is
11
+ * unenforceable rather than merely unimplemented, and no decision record can
12
+ * honestly name a person.
13
+ *
14
+ * ## `crypto.scrypt`, from the standard library
15
+ *
16
+ * The dependency audit is unambiguous: no bcrypt, argon2, passport, next-auth
17
+ * or jose in this tree, direct or dev. Adding a native-compilation
18
+ * password-hashing dependency to a project that installs globally on macOS,
19
+ * Linux and Windows — and which already documents a macOS TCC symlink footgun
20
+ * around global installs — buys a marginally better KDF for a real
21
+ * cross-platform install risk. scrypt is memory-hard, in the standard library,
22
+ * and present in every runtime the bridge already targets.
23
+ *
24
+ * ## What this module does NOT do
25
+ *
26
+ * It does not read the roster, does not decide authorisation, and does not
27
+ * touch the dashboard cookie. Verifying a secret and deciding what somebody
28
+ * may do are different questions, and the cookie change touches ten consumers
29
+ * and wants its own review. This is the half with no blast radius.
30
+ */
31
+ import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
32
+ import { promisify } from "node:util";
33
+ const scrypt = promisify(scryptCb);
34
+ /**
35
+ * scrypt parameters, stored per-hash rather than assumed.
36
+ *
37
+ * Encoding them in the record is what makes them changeable later: a stored
38
+ * hash that does not say how it was derived can only be verified by a
39
+ * constant, so raising the cost would silently invalidate every existing
40
+ * credential instead of upgrading it.
41
+ *
42
+ * N=2^15 with r=8, p=1 is ~32 MB of memory per verification. `maxmem` must be
43
+ * raised above Node's 32 MB default or scrypt throws at these parameters —
44
+ * a footgun that presents as "the correct password is rejected".
45
+ */
46
+ export const SCRYPT_PARAMS = { N: 32768, r: 8, p: 1 };
47
+ const KEYLEN = 64;
48
+ const MAXMEM = 192 * 1024 * 1024;
49
+ const PREFIX = "scrypt";
50
+ /** Hash a password for storage. A fresh 16-byte salt per call. */
51
+ export async function hashPassword(password, params = SCRYPT_PARAMS) {
52
+ if (typeof password !== "string" || password.length === 0) {
53
+ throw new Error("password must be a non-empty string");
54
+ }
55
+ const salt = randomBytes(16);
56
+ const derived = await scrypt(password, salt, KEYLEN, {
57
+ ...params,
58
+ maxmem: MAXMEM,
59
+ });
60
+ return [
61
+ PREFIX,
62
+ params.N,
63
+ params.r,
64
+ params.p,
65
+ salt.toString("base64"),
66
+ derived.toString("base64"),
67
+ ].join("$");
68
+ }
69
+ /** Parsed form of a stored record, or null when it is not one. */
70
+ function parseRecord(record) {
71
+ if (typeof record !== "string")
72
+ return null;
73
+ const parts = record.split("$");
74
+ if (parts.length !== 6 || parts[0] !== PREFIX)
75
+ return null;
76
+ const [, nRaw, rRaw, pRaw, saltRaw, hashRaw] = parts;
77
+ const N = Number(nRaw);
78
+ const r = Number(rRaw);
79
+ const p = Number(pRaw);
80
+ if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) {
81
+ return null;
82
+ }
83
+ // Bound the work a stored record can demand. Without this, anyone who can
84
+ // write members.json can make one verification consume unbounded CPU and
85
+ // memory — a denial of service authored in a config file.
86
+ if (N < 16384 || N > 1 << 20 || r < 1 || r > 32 || p < 1 || p > 16) {
87
+ return null;
88
+ }
89
+ let salt;
90
+ let hash;
91
+ try {
92
+ salt = Buffer.from(saltRaw ?? "", "base64");
93
+ hash = Buffer.from(hashRaw ?? "", "base64");
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ if (salt.length === 0 || hash.length !== KEYLEN)
99
+ return null;
100
+ return { N, r, p, salt, hash };
101
+ }
102
+ /**
103
+ * Verify a password against a stored record.
104
+ *
105
+ * Returns false for a malformed record rather than throwing: a corrupt
106
+ * credential must read as "this password is wrong", not as an exception a
107
+ * caller might catch and treat as a pass. It is the same fail-closed reasoning
108
+ * as ADR-0016 — deciding whether an action happens defaults to no.
109
+ *
110
+ * The comparison is `timingSafeEqual` on the derived key. Lengths are checked
111
+ * first because it throws on a length mismatch, and a thrown comparison is a
112
+ * timing signal of its own.
113
+ */
114
+ export async function verifyPassword(password, record) {
115
+ const parsed = parseRecord(record);
116
+ if (!parsed)
117
+ return false;
118
+ if (typeof password !== "string" || password.length === 0)
119
+ return false;
120
+ let derived;
121
+ try {
122
+ derived = await scrypt(password, parsed.salt, KEYLEN, {
123
+ N: parsed.N,
124
+ r: parsed.r,
125
+ p: parsed.p,
126
+ maxmem: MAXMEM,
127
+ });
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ if (derived.length !== parsed.hash.length)
133
+ return false;
134
+ return timingSafeEqual(derived, parsed.hash);
135
+ }
136
+ /** True when a string is a well-formed credential record. */
137
+ export function isCredentialRecord(value) {
138
+ return typeof value === "string" && parseRecord(value) !== null;
139
+ }
140
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.js","sourceRoot":"","sources":["../../src/identity/credentials.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -4253,6 +4253,56 @@ if (process.argv[2] === "butler") {
4253
4253
  }
4254
4254
  process.exit(0);
4255
4255
  }
4256
+ if (args[0] === "observe") {
4257
+ // The cron entry point: look up live task state, grade it, append
4258
+ // shadow rows. Operator path only — a recipe step runs AS the worker,
4259
+ // and a worker that could observe its own filings could report them
4260
+ // completed.
4261
+ //
4262
+ // Refs are supplied, not discovered. Deriving them from the run log is
4263
+ // a separate piece of work, and inventing a task id here would be the
4264
+ // same class of error as inventing an observation.
4265
+ const fileIdx = args.indexOf("--file");
4266
+ const src = fileIdx !== -1 && args[fileIdx + 1] ? args[fileIdx + 1] : "-";
4267
+ const { readFileSync } = await import("node:fs");
4268
+ const raw = src === "-"
4269
+ ? readFileSync(0, "utf-8")
4270
+ : readFileSync(src, "utf-8");
4271
+ let refs;
4272
+ try {
4273
+ refs = JSON.parse(raw);
4274
+ }
4275
+ catch (err) {
4276
+ process.stderr.write(`[butler] refs are not valid JSON: ${err.message}\n`);
4277
+ process.exit(2);
4278
+ }
4279
+ if (!Array.isArray(refs)) {
4280
+ process.stderr.write("[butler] expected a JSON array of {taskId, ref, recipe?}\n");
4281
+ process.exit(2);
4282
+ }
4283
+ const { getTodoistConnector } = await import("./connectors/todoist.js");
4284
+ const { observeTodoistErrands } = await import("./butler/todoistObservation.js");
4285
+ const run = await observeTodoistErrands(getTodoistConnector(), refs);
4286
+ const res = ingestErrandOutcomes(run.observations, {
4287
+ now: Date.now(),
4288
+ });
4289
+ if (args.includes("--json")) {
4290
+ process.stdout.write(`${JSON.stringify({ ...res, unavailable: run.unavailable }, null, 2)}\n`);
4291
+ }
4292
+ else {
4293
+ process.stdout.write(`[butler] observed ${run.observations.length}, ` +
4294
+ `could not read ${run.unavailable.length}.\n`);
4295
+ for (const u of run.unavailable) {
4296
+ // Printed, never silent: an unread errand is not a graded one, and
4297
+ // a run that quietly skipped half its refs must not look clean.
4298
+ process.stdout.write(`[butler] unavailable ${u.ref}: ${u.reason}\n`);
4299
+ }
4300
+ process.stdout.write(`[butler] graded ${res.graded}: ${res.batch.confirmed} confirmed, ` +
4301
+ `${res.batch.junk} junk, ${res.batch.unknown} unknown.\n`);
4302
+ process.stdout.write(`\n${formatShadowSummary(res.ledger)}\n`);
4303
+ }
4304
+ process.exit(0);
4305
+ }
4256
4306
  if (args[0] === "ingest") {
4257
4307
  // Observations come from a file or stdin as a JSON array. They are NOT
4258
4308
  // gathered here: reading the operator's trackers is a connector
@@ -4300,9 +4350,10 @@ if (process.argv[2] === "butler") {
4300
4350
  }
4301
4351
  process.exit(0);
4302
4352
  }
4303
- process.stderr.write("Usage: patchwork butler <shadow|ingest> [--json]\n\n" +
4353
+ process.stderr.write("Usage: patchwork butler <shadow|ingest|observe> [--json]\n\n" +
4304
4354
  " shadow summarise the graded shadow ledger\n" +
4305
4355
  " ingest [--file <path>|-] grade a JSON array of observations\n" +
4356
+ " observe [--file <path>|-] look up live Todoist state, then grade\n" +
4306
4357
  " [--stale-after-days N]\n\n" +
4307
4358
  " Shadow-only: nothing here writes the trust ledger.\n");
4308
4359
  process.exit(2);