patchwork-os 1.2.0-beta.2.canary.656 → 1.2.0-beta.2.canary.659

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,124 @@
1
+ /**
2
+ * Where per-member password hashes live — ADR-0020 Phase A.
3
+ *
4
+ * `~/.patchwork/credentials.json`, mode 0600, keyed by member id. A separate
5
+ * file from `members.json`, and that separation is the decision worth
6
+ * recording, because two other homes were considered and are worse.
7
+ *
8
+ * ## Not on the `Member` object
9
+ *
10
+ * `Member` is what `actorSnapshot` copies from into decision records. A hash
11
+ * hanging off it is one careless spread away from an audit log. That is why
12
+ * `LocalPasswordProvider` takes `credentialFor` injected rather than reading a
13
+ * field — this module supplies that function and nothing else.
14
+ *
15
+ * ## Not in `members.json`
16
+ *
17
+ * The roster is a describable, reviewable document: the sort of file somebody
18
+ * eventually pastes into an issue or commits to share a configuration. The
19
+ * moment it carries hashes, that is a credential leak by ordinary helpfulness.
20
+ * Separate files let the roster stay boring.
21
+ *
22
+ * A member deactivated rather than deleted (the roster's rule) leaves an
23
+ * orphaned credential here. That is correct and deliberate: the history stays,
24
+ * the login does not.
25
+ *
26
+ * ## NOT in the connector token store
27
+ *
28
+ * `PATCHWORK_TOKEN_DIR` already has 0600 handling and a keychain backend, and
29
+ * it is the wrong home twice over. Conceptually it holds credentials the
30
+ * bridge uses to call OUT; these are credentials people use to call IN.
31
+ * Practically, that path reaches `deleteSecretJsonSync` → an unconditional
32
+ * `unlink`, which is why `audit-connector-test-isolation` exists at all: in
33
+ * #1345 a disconnect test deleted a real credential off a developer's machine.
34
+ * Putting member logins behind that same unlink means a stray `clearTokens()`
35
+ * in a test can delete them.
36
+ *
37
+ * ## Fail CLOSED — the opposite of the roster
38
+ *
39
+ * A missing roster yields one implicit owner, so a single-user machine keeps
40
+ * working: "who may act on your own machine" defaults to the status quo. A
41
+ * missing credential file yields NOTHING, so nobody authenticates: "who are
42
+ * you" defaults to nobody. Those defaults are deliberately opposite, and the
43
+ * asymmetry is the same one ADR-0016 draws — a decision about whether an
44
+ * action happens defaults to no.
45
+ *
46
+ * Concretely: no file, unreadable file, malformed JSON, or an entry that is
47
+ * not a well-formed scrypt record all produce "no credential for this member",
48
+ * and `LocalPasswordProvider` then returns UNATTRIBUTED. None of them throw,
49
+ * because a thrown lookup is something a caller might catch and treat as a
50
+ * pass.
51
+ */
52
+ import { chmodSync, existsSync, readFileSync, statSync } from "node:fs";
53
+ import { join } from "node:path";
54
+ import { patchworkHome } from "../patchworkHome.js";
55
+ import { isCredentialRecord } from "./credentials.js";
56
+ export const CREDENTIALS_BASENAME = "credentials.json";
57
+ /** `~/.patchwork/credentials.json`, honouring `PATCHWORK_HOME`. */
58
+ export function defaultCredentialsPath() {
59
+ const home = process.env.PATCHWORK_HOME?.trim();
60
+ return join(home && home !== "" ? home : patchworkHome(), CREDENTIALS_BASENAME);
61
+ }
62
+ const EMPTY = {
63
+ credentialFor: () => undefined,
64
+ ids: () => [],
65
+ malformed: [],
66
+ overlyPermissive: false,
67
+ };
68
+ /**
69
+ * Load the credential file.
70
+ *
71
+ * Read ONCE at startup, like the roster, and deliberately not re-read per
72
+ * request: a file re-read on every authentication is a file whose permissions
73
+ * and contents can change under a running process, and the resulting behaviour
74
+ * is untestable. Rotation takes a restart, same as the roster.
75
+ */
76
+ export function loadCredentials(path = defaultCredentialsPath()) {
77
+ if (!existsSync(path))
78
+ return EMPTY;
79
+ let overlyPermissive = false;
80
+ try {
81
+ const mode = statSync(path).mode & 0o077;
82
+ if (mode !== 0) {
83
+ overlyPermissive = true;
84
+ // Tighten rather than merely complain. A world-readable password file is
85
+ // a live problem, and an operator who is told about it at startup has
86
+ // already been running that way for however long.
87
+ try {
88
+ chmodSync(path, 0o600);
89
+ }
90
+ catch {
91
+ /* reported via overlyPermissive; nothing else to do */
92
+ }
93
+ }
94
+ }
95
+ catch {
96
+ /* stat failed — fall through; the read below decides */
97
+ }
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
101
+ }
102
+ catch {
103
+ // Unreadable or malformed ⇒ no credentials ⇒ nobody authenticates.
104
+ return { ...EMPTY, overlyPermissive };
105
+ }
106
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
107
+ return { ...EMPTY, overlyPermissive };
108
+ }
109
+ const usable = new Map();
110
+ const malformed = [];
111
+ for (const [id, value] of Object.entries(parsed)) {
112
+ if (isCredentialRecord(value))
113
+ usable.set(id, value);
114
+ else
115
+ malformed.push(id);
116
+ }
117
+ return {
118
+ credentialFor: (memberId) => usable.get(memberId),
119
+ ids: () => [...usable.keys()],
120
+ malformed,
121
+ overlyPermissive,
122
+ };
123
+ }
124
+ //# sourceMappingURL=credentialStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentialStore.js","sourceRoot":"","sources":["../../src/identity/credentialStore.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The dashboard session cookie format — ONE implementation, two processes.
3
+ *
4
+ * ADR-0020 Phase A. The dashboard mints these at login (`v2.<memberId>.<exp>.<HMAC>`
5
+ * once a real member authenticates, `v1.<exp>.<HMAC>` when only the shared
6
+ * password was presented). The BRIDGE now needs to read them too, so that an
7
+ * approval arriving over the dashboard's proxy can name the human who gave it.
8
+ *
9
+ * ## Why this moved here rather than being reimplemented
10
+ *
11
+ * The obvious shape is a second verifier on the bridge side. It is also how
12
+ * this subsystem has failed before: two implementations of one wire format
13
+ * drift, and the drift here is not a crash. A bridge verifier that is slightly
14
+ * more permissive than the dashboard's signer accepts a cookie the dashboard
15
+ * would reject, and the consequence is an audit record naming a person on a
16
+ * credential nobody would have issued. A format whose two halves disagree is
17
+ * worse than a format with no second half.
18
+ *
19
+ * So the signing and verification live here, once, and `dashboard/src/lib/session.ts`
20
+ * re-exports them. The dashboard remains the only thing that MINTS a cookie;
21
+ * the bridge only ever verifies.
22
+ *
23
+ * ## Web Crypto only — no `node:` imports in this file
24
+ *
25
+ * Load-bearing. The dashboard's consumer runs in the Edge Runtime (middleware),
26
+ * where `node:crypto` is unavailable. `crypto.subtle` is present in both the
27
+ * Edge Runtime and every Node version the bridge targets, so one implementation
28
+ * covers all three. Adding a `node:` import here breaks the dashboard build,
29
+ * and it breaks it in middleware — the one place where a failure locks every
30
+ * user out of the dashboard.
31
+ */
32
+ export declare const SESSION_COOKIE_NAME = "patchwork_session";
33
+ export declare const SESSION_TTL_MS: number;
34
+ /**
35
+ * Member ids that are safe inside a dot-delimited cookie payload.
36
+ *
37
+ * Load-bearing, not cosmetic. The payload is split on ".", so an id
38
+ * containing one makes `v2.a.b.123.<sig>` ambiguous — is the subject `a` with
39
+ * expiry `b.123`, or `a.b` with expiry `123`? Two members could then produce
40
+ * cookies that parse as each other. Rejected at BOTH ends: signing throws,
41
+ * verifying returns invalid, because a check on only one side is a check that
42
+ * a hand-written cookie skips.
43
+ */
44
+ export declare const MEMBER_ID_RE: RegExp;
45
+ /**
46
+ * Sign a session cookie.
47
+ *
48
+ * With `memberId` → `v2.<memberId>.<expiresAt>.<HMAC>`, an ATTRIBUTED session.
49
+ * Without → `v1.<expiresAt>.<HMAC>`, exactly as before.
50
+ *
51
+ * v1 is still minted on purpose. The dashboard password gate authenticates a
52
+ * SECRET, not a person, so there is nobody to name; minting a v2 with a
53
+ * placeholder subject would turn "we do not know who this is" into a claim
54
+ * about someone. v2 appears only once a real member has authenticated.
55
+ */
56
+ export declare function signSession(expiresAtOrOpts?: number | {
57
+ memberId?: string;
58
+ expiresAt?: number;
59
+ }): Promise<string>;
60
+ /**
61
+ * Verify a session cookie.
62
+ *
63
+ * `memberId` is present ONLY for a valid v2 cookie. For v1 it is `undefined`,
64
+ * and that is the whole point of this function's shape:
65
+ *
66
+ * **a v1 cookie must never read as an attributed v2.**
67
+ *
68
+ * A v1 cookie means nobody was identified. If verification returned some
69
+ * stand-in subject there — the implicit owner, the first member, a literal
70
+ * "unknown" — the absence of a subject would become a CLAIM of one, and every
71
+ * record stamped from it would name a person on no evidence. Undefined stays
72
+ * undefined; callers decide, and must treat it as unattributed.
73
+ *
74
+ * The version is inside the SIGNED payload, so a v1 cookie cannot be
75
+ * re-spelled as a v2 one: the HMAC covers `v1.<exp>` or `v2.<id>.<exp>`, and
76
+ * those are different strings under the same key.
77
+ *
78
+ * With NO secret configured this returns invalid for everything. On the bridge
79
+ * that is the expected state — the bridge has never been given
80
+ * `DASHBOARD_SESSION_SECRET` — and it is the correct one: no secret means no
81
+ * verification means no actor, and an absent actor already means "nobody
82
+ * recorded this".
83
+ */
84
+ export declare function verifySession(value: string | undefined | null): Promise<{
85
+ valid: boolean;
86
+ expiresAt?: number;
87
+ memberId?: string;
88
+ }>;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The dashboard session cookie format — ONE implementation, two processes.
3
+ *
4
+ * ADR-0020 Phase A. The dashboard mints these at login (`v2.<memberId>.<exp>.<HMAC>`
5
+ * once a real member authenticates, `v1.<exp>.<HMAC>` when only the shared
6
+ * password was presented). The BRIDGE now needs to read them too, so that an
7
+ * approval arriving over the dashboard's proxy can name the human who gave it.
8
+ *
9
+ * ## Why this moved here rather than being reimplemented
10
+ *
11
+ * The obvious shape is a second verifier on the bridge side. It is also how
12
+ * this subsystem has failed before: two implementations of one wire format
13
+ * drift, and the drift here is not a crash. A bridge verifier that is slightly
14
+ * more permissive than the dashboard's signer accepts a cookie the dashboard
15
+ * would reject, and the consequence is an audit record naming a person on a
16
+ * credential nobody would have issued. A format whose two halves disagree is
17
+ * worse than a format with no second half.
18
+ *
19
+ * So the signing and verification live here, once, and `dashboard/src/lib/session.ts`
20
+ * re-exports them. The dashboard remains the only thing that MINTS a cookie;
21
+ * the bridge only ever verifies.
22
+ *
23
+ * ## Web Crypto only — no `node:` imports in this file
24
+ *
25
+ * Load-bearing. The dashboard's consumer runs in the Edge Runtime (middleware),
26
+ * where `node:crypto` is unavailable. `crypto.subtle` is present in both the
27
+ * Edge Runtime and every Node version the bridge targets, so one implementation
28
+ * covers all three. Adding a `node:` import here breaks the dashboard build,
29
+ * and it breaks it in middleware — the one place where a failure locks every
30
+ * user out of the dashboard.
31
+ */
32
+ export const SESSION_COOKIE_NAME = "patchwork_session";
33
+ export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
34
+ function getSecret() {
35
+ return process.env.DASHBOARD_SESSION_SECRET ?? "";
36
+ }
37
+ function base64url(bytes) {
38
+ const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
39
+ let s = "";
40
+ for (let i = 0; i < buf.length; i++)
41
+ s += String.fromCharCode(buf[i] ?? 0);
42
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
43
+ }
44
+ function base64urlDecode(input) {
45
+ const padded = input.replace(/-/g, "+").replace(/_/g, "/");
46
+ const padding = "=".repeat((4 - (padded.length % 4)) % 4);
47
+ const raw = atob(padded + padding);
48
+ const bytes = new Uint8Array(new ArrayBuffer(raw.length));
49
+ for (let i = 0; i < raw.length; i++)
50
+ bytes[i] = raw.charCodeAt(i);
51
+ return bytes.buffer;
52
+ }
53
+ async function importKey() {
54
+ return crypto.subtle.importKey("raw", new TextEncoder().encode(getSecret()), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
55
+ }
56
+ async function sign(payload) {
57
+ const key = await importKey();
58
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
59
+ return base64url(sig);
60
+ }
61
+ /**
62
+ * Member ids that are safe inside a dot-delimited cookie payload.
63
+ *
64
+ * Load-bearing, not cosmetic. The payload is split on ".", so an id
65
+ * containing one makes `v2.a.b.123.<sig>` ambiguous — is the subject `a` with
66
+ * expiry `b.123`, or `a.b` with expiry `123`? Two members could then produce
67
+ * cookies that parse as each other. Rejected at BOTH ends: signing throws,
68
+ * verifying returns invalid, because a check on only one side is a check that
69
+ * a hand-written cookie skips.
70
+ */
71
+ export const MEMBER_ID_RE = /^[A-Za-z0-9_-]+$/;
72
+ /**
73
+ * Sign a session cookie.
74
+ *
75
+ * With `memberId` → `v2.<memberId>.<expiresAt>.<HMAC>`, an ATTRIBUTED session.
76
+ * Without → `v1.<expiresAt>.<HMAC>`, exactly as before.
77
+ *
78
+ * v1 is still minted on purpose. The dashboard password gate authenticates a
79
+ * SECRET, not a person, so there is nobody to name; minting a v2 with a
80
+ * placeholder subject would turn "we do not know who this is" into a claim
81
+ * about someone. v2 appears only once a real member has authenticated.
82
+ */
83
+ export async function signSession(expiresAtOrOpts = Date.now() + SESSION_TTL_MS) {
84
+ const opts = typeof expiresAtOrOpts === "number"
85
+ ? { expiresAt: expiresAtOrOpts }
86
+ : expiresAtOrOpts;
87
+ const expiresAt = opts.expiresAt ?? Date.now() + SESSION_TTL_MS;
88
+ const memberId = opts.memberId;
89
+ if (memberId === undefined) {
90
+ const payload = `v1.${expiresAt}`;
91
+ return `${payload}.${await sign(payload)}`;
92
+ }
93
+ if (!MEMBER_ID_RE.test(memberId)) {
94
+ throw new Error("memberId must match /^[A-Za-z0-9_-]+$/ — a dot would make the payload ambiguous");
95
+ }
96
+ const payload = `v2.${memberId}.${expiresAt}`;
97
+ return `${payload}.${await sign(payload)}`;
98
+ }
99
+ /**
100
+ * Verify a session cookie.
101
+ *
102
+ * `memberId` is present ONLY for a valid v2 cookie. For v1 it is `undefined`,
103
+ * and that is the whole point of this function's shape:
104
+ *
105
+ * **a v1 cookie must never read as an attributed v2.**
106
+ *
107
+ * A v1 cookie means nobody was identified. If verification returned some
108
+ * stand-in subject there — the implicit owner, the first member, a literal
109
+ * "unknown" — the absence of a subject would become a CLAIM of one, and every
110
+ * record stamped from it would name a person on no evidence. Undefined stays
111
+ * undefined; callers decide, and must treat it as unattributed.
112
+ *
113
+ * The version is inside the SIGNED payload, so a v1 cookie cannot be
114
+ * re-spelled as a v2 one: the HMAC covers `v1.<exp>` or `v2.<id>.<exp>`, and
115
+ * those are different strings under the same key.
116
+ *
117
+ * With NO secret configured this returns invalid for everything. On the bridge
118
+ * that is the expected state — the bridge has never been given
119
+ * `DASHBOARD_SESSION_SECRET` — and it is the correct one: no secret means no
120
+ * verification means no actor, and an absent actor already means "nobody
121
+ * recorded this".
122
+ */
123
+ export async function verifySession(value) {
124
+ if (!value || !getSecret())
125
+ return { valid: false };
126
+ const parts = value.split(".");
127
+ const version = parts[0];
128
+ let memberId;
129
+ let expiresAtStr;
130
+ let sig;
131
+ if (version === "v1" && parts.length === 3) {
132
+ expiresAtStr = parts[1];
133
+ sig = parts[2];
134
+ }
135
+ else if (version === "v2" && parts.length === 4) {
136
+ memberId = parts[1];
137
+ expiresAtStr = parts[2];
138
+ sig = parts[3];
139
+ // Defence in depth, and honestly redundant TODAY — probed, not assumed.
140
+ // Removing this line leaves every test green, because an id containing a
141
+ // "." makes the cookie 5 parts and the arity check above rejects it, while
142
+ // any other malformed id can only appear in a cookie we signed — and
143
+ // signing refuses it. The signature stops the rest.
144
+ //
145
+ // Kept because the arity check is what currently carries it, and arity
146
+ // stops discriminating the moment a fifth field is added to this format.
147
+ // A constraint that survives a format change is worth one line.
148
+ if (!memberId || !MEMBER_ID_RE.test(memberId))
149
+ return { valid: false };
150
+ }
151
+ else {
152
+ return { valid: false };
153
+ }
154
+ if (!expiresAtStr || !sig)
155
+ return { valid: false };
156
+ const expiresAt = Number.parseInt(expiresAtStr, 10);
157
+ if (!Number.isFinite(expiresAt))
158
+ return { valid: false };
159
+ if (Date.now() > expiresAt)
160
+ return { valid: false };
161
+ const payload = memberId === undefined
162
+ ? `v1.${expiresAtStr}`
163
+ : `v2.${memberId}.${expiresAtStr}`;
164
+ try {
165
+ const key = await importKey();
166
+ const sigBytes = base64urlDecode(sig);
167
+ const ok = await crypto.subtle.verify("HMAC", key, sigBytes, new TextEncoder().encode(payload));
168
+ // `memberId` is spread only when defined, so a v1 result has no such key
169
+ // at all — not a key holding undefined that some later `in` check reads
170
+ // as present.
171
+ return ok
172
+ ? { valid: true, expiresAt, ...(memberId !== undefined && { memberId }) }
173
+ : { valid: false };
174
+ }
175
+ catch {
176
+ return { valid: false };
177
+ }
178
+ }
179
+ //# sourceMappingURL=dashboardSession.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboardSession.js","sourceRoot":"","sources":["../../src/identity/dashboardSession.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,CAAC,MAAM,mBAAmB,GAAG,mBAAmB,CAAC;AACvD,MAAM,CAAC,MAAM,cAAc,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,UAAU;AAElE,SAAS,SAAS;IAChB,OAAO,OAAO,CAAC,GAAG,CAAC,wBAAwB,IAAI,EAAE,CAAC;AACpD,CAAC;AAED,SAAS,SAAS,CAAC,KAA+B;IAChD,MAAM,GAAG,GAAG,KAAK,YAAY,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC;IACxE,IAAI,CAAC,GAAG,EAAE,CAAC;IACX,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE;QAAE,CAAC,IAAI,MAAM,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IAC3E,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;AAC5E,CAAC;AAED,SAAS,eAAe,CAAC,KAAa;IACpC,MAAM,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC3D,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAC1D,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,CAAC;IACnC,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,IAAI,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;IAC1D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE;QAAE,KAAK,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;IAClE,OAAO,KAAK,CAAC,MAAM,CAAC;AACtB,CAAC;AAED,KAAK,UAAU,SAAS;IACtB,OAAO,MAAM,CAAC,MAAM,CAAC,SAAS,CAC5B,KAAK,EACL,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC,EACrC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,EACjC,KAAK,EACL,CAAC,MAAM,EAAE,QAAQ,CAAC,CACnB,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,IAAI,CAAC,OAAe;IACjC,MAAM,GAAG,GAAG,MAAM,SAAS,EAAE,CAAC;IAC9B,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,CAClC,MAAM,EACN,GAAG,EACH,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAClC,CAAC;IACF,OAAO,SAAS,CAAC,GAAG,CAAC,CAAC;AACxB,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,kBAAkB,CAAC;AAE/C;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,kBAEgD,IAAI,CAAC,GAAG,EAAE,GAAG,cAAc;IAE3E,MAAM,IAAI,GACR,OAAO,eAAe,KAAK,QAAQ;QACjC,CAAC,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE;QAChC,CAAC,CAAC,eAAe,CAAC;IACtB,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,cAAc,CAAC;IAChE,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC;IAE/B,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,MAAM,OAAO,GAAG,MAAM,SAAS,EAAE,CAAC;QAClC,OAAO,GAAG,OAAO,IAAI,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;IAC7C,CAAC;IACD,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CACb,iFAAiF,CAClF,CAAC;IACJ,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;IAC9C,OAAO,GAAG,OAAO,IAAI,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;AAC7C,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,KAAgC;IAEhC,IAAI,CAAC,KAAK,IAAI,CAAC,SAAS,EAAE;QAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACpD,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC/B,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;IAEzB,IAAI,QAA4B,CAAC;IACjC,IAAI,YAAgC,CAAC;IACrC,IAAI,GAAuB,CAAC;IAE5B,IAAI,OAAO,KAAK,IAAI,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3C,YAAY,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACxB,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;IACjB,CAAC;SAAM,IAAI,OAAO,KAAK,IAAI,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClD,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACpB,YAAY,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACxB,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACf,wEAAwE;QACxE,yEAAyE;QACzE,2EAA2E;QAC3E,qEAAqE;QACrE,oDAAoD;QACpD,EAAE;QACF,uEAAuE;QACvE,yEAAyE;QACzE,gEAAgE;QAChE,IAAI,CAAC,QAAQ,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,QAAQ,CAAC;YAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACzE,CAAC;SAAM,CAAC;QACN,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IAC1B,CAAC;IAED,IAAI,CAAC,YAAY,IAAI,CAAC,GAAG;QAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACnD,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;IACpD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACzD,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS;QAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IAEpD,MAAM,OAAO,GACX,QAAQ,KAAK,SAAS;QACpB,CAAC,CAAC,MAAM,YAAY,EAAE;QACtB,CAAC,CAAC,MAAM,QAAQ,IAAI,YAAY,EAAE,CAAC;IAEvC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,SAAS,EAAE,CAAC;QAC9B,MAAM,QAAQ,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC;QACtC,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CACnC,MAAM,EACN,GAAG,EACH,QAAQ,EACR,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAClC,CAAC;QACF,yEAAyE;QACzE,wEAAwE;QACxE,cAAc;QACd,OAAO,EAAE;YACP,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,CAAC,QAAQ,KAAK,SAAS,IAAI,EAAE,QAAQ,EAAE,CAAC,EAAE;YACzE,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACvB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IAC1B,CAAC;AACH,CAAC"}
package/dist/index.js CHANGED
@@ -216,6 +216,7 @@ const KNOWN_SUBCOMMANDS = [
216
216
  "gate",
217
217
  "outcomes",
218
218
  "butler",
219
+ "members",
219
220
  // Dispatched at `process.argv[2] === "tools"` (and `help`) but absent from
220
221
  // this array until 2026-08. The comment below calls this "the dispatch
221
222
  // source", and it was not: `patchwork tool` could never be corrected to
@@ -4231,6 +4232,116 @@ if (process.argv[2] === "gate") {
4231
4232
  }
4232
4233
  })();
4233
4234
  }
4235
+ // Handle members subcommand — the roster and per-member credentials
4236
+ // (ADR-0020 Phase A).
4237
+ //
4238
+ // `set-password` prompts on a TTY and never takes the secret from argv:
4239
+ // argv lands in shell history and in `ps` output for every user on the box.
4240
+ if (process.argv[2] === "members") {
4241
+ const args = process.argv.slice(3);
4242
+ (async () => {
4243
+ try {
4244
+ const { loadRoster, describeRoster } = await import("./identity/roster.js");
4245
+ const { loadCredentials, defaultCredentialsPath } = await import("./identity/credentialStore.js");
4246
+ if (args[0] === "list" || args.length === 0) {
4247
+ const roster = loadRoster();
4248
+ const creds = loadCredentials();
4249
+ process.stdout.write(`${describeRoster(roster)}\n`);
4250
+ const withCreds = new Set(creds.ids());
4251
+ for (const m of roster.members) {
4252
+ process.stdout.write(` ${m.id} ${withCreds.has(m.id) ? "has a password" : "NO password — cannot authenticate"}\n`);
4253
+ }
4254
+ for (const bad of creds.malformed) {
4255
+ // "your record is corrupt" and "no such user" are different facts.
4256
+ process.stdout.write(` ${bad} credential present but UNREADABLE — treated as absent\n`);
4257
+ }
4258
+ if (creds.overlyPermissive) {
4259
+ process.stdout.write(`\n[members] ${defaultCredentialsPath()} was group/world-readable; tightened to 0600.\n`);
4260
+ }
4261
+ process.exit(0);
4262
+ }
4263
+ if (args[0] === "set-password") {
4264
+ const memberId = args[1];
4265
+ if (!memberId) {
4266
+ process.stderr.write("Usage: patchwork members set-password <memberId>\n");
4267
+ process.exit(2);
4268
+ }
4269
+ if (!process.stdin.isTTY) {
4270
+ // Refused rather than read from a pipe. A password arriving on stdin
4271
+ // from a script is a password in that script.
4272
+ process.stderr.write("[members] set-password needs an interactive terminal.\n");
4273
+ process.exit(2);
4274
+ }
4275
+ const { createInterface } = await import("node:readline");
4276
+ const rl = createInterface({
4277
+ input: process.stdin,
4278
+ output: process.stdout,
4279
+ });
4280
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
4281
+ // Node's readline cannot suppress echo portably; muting the output
4282
+ // stream is the standard trick and is why this is written by hand.
4283
+ const mute = (on) => {
4284
+ rl.output.write = on
4285
+ ? () => { }
4286
+ : process.stdout.write.bind(process.stdout);
4287
+ };
4288
+ process.stdout.write(`Password for ${memberId}: `);
4289
+ mute(true);
4290
+ const pw = (await ask("")).trim();
4291
+ mute(false);
4292
+ process.stdout.write("\nConfirm: ");
4293
+ mute(true);
4294
+ const again = (await ask("")).trim();
4295
+ mute(false);
4296
+ process.stdout.write("\n");
4297
+ rl.close();
4298
+ if (!pw) {
4299
+ process.stderr.write("[members] empty password refused.\n");
4300
+ process.exit(2);
4301
+ }
4302
+ if (pw !== again) {
4303
+ process.stderr.write("[members] passwords did not match.\n");
4304
+ process.exit(2);
4305
+ }
4306
+ const { hashPassword } = await import("./identity/credentials.js");
4307
+ const { readFileSync, writeFileSync, existsSync, chmodSync } = await import("node:fs");
4308
+ const file = defaultCredentialsPath();
4309
+ let existing = {};
4310
+ if (existsSync(file)) {
4311
+ try {
4312
+ const parsed = JSON.parse(readFileSync(file, "utf-8"));
4313
+ if (parsed &&
4314
+ typeof parsed === "object" &&
4315
+ !Array.isArray(parsed)) {
4316
+ existing = parsed;
4317
+ }
4318
+ }
4319
+ catch {
4320
+ // A corrupt file must not be silently replaced — that would
4321
+ // delete every other member's credential to store one.
4322
+ process.stderr.write(`[members] ${file} exists but is not readable JSON. Refusing to overwrite it.\n`);
4323
+ process.exit(2);
4324
+ }
4325
+ }
4326
+ existing[memberId] = await hashPassword(pw);
4327
+ writeFileSync(file, `${JSON.stringify(existing, null, 2)}\n`, {
4328
+ mode: 0o600,
4329
+ });
4330
+ // `mode` on writeFileSync only applies at CREATION, so an existing
4331
+ // file keeps its old permissions unless chmod'd explicitly.
4332
+ chmodSync(file, 0o600);
4333
+ process.stdout.write(`[members] password set for ${memberId}.\n`);
4334
+ process.exit(0);
4335
+ }
4336
+ process.stderr.write("Usage: patchwork members <list|set-password <memberId>>\n");
4337
+ process.exit(2);
4338
+ }
4339
+ catch (err) {
4340
+ process.stderr.write(`[members] ${err.message}\n`);
4341
+ process.exit(2);
4342
+ }
4343
+ })();
4344
+ }
4234
4345
  // Handle butler subcommand — SHADOW-only errand outcome grading.
4235
4346
  //
4236
4347
  // Operator path, like `outcomes` above and for the same reason: a recipe step