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.
- package/dist/butler/todoistObservation.d.ts +72 -0
- package/dist/butler/todoistObservation.js +85 -0
- package/dist/butler/todoistObservation.js.map +1 -0
- package/dist/connectors/todoist.d.ts +33 -0
- package/dist/connectors/todoist.js +49 -0
- package/dist/connectors/todoist.js.map +1 -1
- package/dist/identity/authSeam.d.ts +97 -0
- package/dist/identity/authSeam.js +107 -0
- package/dist/identity/authSeam.js.map +1 -0
- package/dist/identity/credentials.d.ts +70 -0
- package/dist/identity/credentials.js +140 -0
- package/dist/identity/credentials.js.map +1 -0
- package/dist/index.js +52 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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);
|