patchwork-os 1.2.0-beta.2.canary.657 → 1.2.0-beta.2.canary.660

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,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":"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"}
package/dist/server.d.ts CHANGED
@@ -347,6 +347,21 @@ export declare class Server extends EventEmitter<ServerEvents> {
347
347
  ntfyServer: string | undefined;
348
348
  /** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
349
349
  onApprovalDecision: ((event: string, meta: Record<string, unknown>) => void) | undefined;
350
+ /**
351
+ * Patchwork: resolves the human behind a forwarded dashboard session cookie
352
+ * so an approve/reject can be attributed (ADR-0020 Phase A).
353
+ *
354
+ * Built lazily and ONLY when `DASHBOARD_SESSION_SECRET` is present, because
355
+ * without it `verifySession` rejects every cookie — a resolver that can
356
+ * never succeed is worth neither the roster read nor the confusion of
357
+ * appearing wired. Left undefined ⇒ decisions record no actor, which is the
358
+ * status quo and the honest answer.
359
+ */
360
+ resolveApprover: ((sessionCookie?: string) => Promise<{
361
+ id: string;
362
+ kind: "human" | "worker";
363
+ displayName: string;
364
+ } | undefined>) | undefined;
350
365
  /**
351
366
  * Patchwork: activity log handle, used by approvalHttp to compute
352
367
  * passive risk personalization signals (`src/approvalSignals.ts`).
package/dist/server.js CHANGED
@@ -284,6 +284,17 @@ export class Server extends EventEmitter {
284
284
  ntfyServer = undefined;
285
285
  /** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
286
286
  onApprovalDecision = undefined;
287
+ /**
288
+ * Patchwork: resolves the human behind a forwarded dashboard session cookie
289
+ * so an approve/reject can be attributed (ADR-0020 Phase A).
290
+ *
291
+ * Built lazily and ONLY when `DASHBOARD_SESSION_SECRET` is present, because
292
+ * without it `verifySession` rejects every cookie — a resolver that can
293
+ * never succeed is worth neither the roster read nor the confusion of
294
+ * appearing wired. Left undefined ⇒ decisions record no actor, which is the
295
+ * status quo and the honest answer.
296
+ */
297
+ resolveApprover = undefined;
287
298
  /**
288
299
  * Patchwork: activity log handle, used by approvalHttp to compute
289
300
  * passive risk personalization signals (`src/approvalSignals.ts`).
@@ -2475,11 +2486,21 @@ export class Server extends EventEmitter {
2475
2486
  // approvalHttp.ts — is an HMAC over the path and is unaffected.)
2476
2487
  approvalToken: req.headers["x-approval-token"] ??
2477
2488
  undefined,
2489
+ // ADR-0020 Phase A. The dashboard proxy forwards the member's
2490
+ // own session cookie; the bridge VERIFIES it against
2491
+ // DASHBOARD_SESSION_SECRET rather than trusting an asserted id,
2492
+ // so holding the shared bridge token is not enough to put a
2493
+ // person's name on a decision. A dedicated header, not the
2494
+ // `Cookie` header: the bridge is not a browser origin and must
2495
+ // not start reading ambient cookies.
2496
+ sessionCookie: req.headers["x-patchwork-session"] ??
2497
+ undefined,
2478
2498
  }, {
2479
2499
  queue: getApprovalQueue(),
2480
2500
  workspace: this.workspace,
2481
2501
  managedSettingsPath: this.managedSettingsPath,
2482
2502
  onDecision: this.onApprovalDecision,
2503
+ resolveApprover: this.resolveApprover,
2483
2504
  webhookUrl: this.approvalWebhookUrl,
2484
2505
  approvalGate: this.approvalGate,
2485
2506
  pushServiceUrl: this.pushServiceUrl,