waku-memory 0.2.0 → 0.3.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.
package/dist/hook.js CHANGED
@@ -1,49 +1,69 @@
1
1
  // The `hook` subcommand's decision logic. Wired by cli.ts: run() reads one
2
- // Claude Code hook event as JSON off stdin (that part deliberately does not
3
- // live here -- see the note on handleHookEvent below) and calls
4
- // handleHookEvent with the parsed event and a real deps bag. Everything in
5
- // this file takes its inputs as plain values, so hook.test.mjs can call it
6
- // directly against temp files and an injected fetchImpl, the same way
7
- // cli.test.mjs drives applyToHarness without touching a real ~/.claude.json.
2
+ // hook event as JSON off stdin (that part deliberately does not live here --
3
+ // see the note on handleHookEvent below) and calls handleHookEvent with the
4
+ // parsed event and a real deps bag. Everything in this file takes its
5
+ // inputs as plain values, so hook.test.mjs can call it directly against temp
6
+ // files and an injected fetchImpl, the same way cli.test.mjs drives
7
+ // applyToHarness without touching a real ~/.claude.json.
8
+ //
9
+ // Claude Code and Codex both land here (spec 012 §4): the event shape and
10
+ // the stdin fields this file reads were measured identical across the two,
11
+ // so this module itself never asks which harness sent an event -- deps
12
+ // carries a HarnessId, handleHookEvent resolves it to one HarnessProfile
13
+ // (harnesses.ts's PROFILES) up front, and every harness-specific choice
14
+ // (content format, delta formatter, timing budgets, how the brief is
15
+ // printed) reads off that one value. A third harness is a third profile,
16
+ // never a new branch in this file.
8
17
  //
9
18
  // This is the one piece of the shim that runs unattended, on every turn, in
10
19
  // the middle of somebody else's tool -- see task 14's brief for the full
11
20
  // argument. Two consequences shape every decision below:
12
21
  //
13
22
  // 1. It must never throw and never leave a nonzero exit behind. A capture
14
- // bug must not read to the user as their actual Claude Code session
15
- // failing. handleHookEvent wraps its entire body in one try/catch for
16
- // exactly this reason, and every expected failure (missing config, no
17
- // new bytes, a non-2xx response, a rejected fetch) returns through its
18
- // own branch before ever reaching that catch -- the catch is the net
19
- // under the net, for whatever this file's author did not think of.
23
+ // bug must not read to the user as their actual session failing.
24
+ // handleHookEvent wraps its entire body in one try/catch for exactly
25
+ // this reason, and every expected failure (missing config, no new
26
+ // bytes, a non-2xx response, a rejected fetch) returns through its own
27
+ // branch before ever reaching that catch -- the catch is the net under
28
+ // the net, for whatever this file's author did not think of.
20
29
  // 2. Content goes to the ingest endpoint and nowhere else, with one
21
30
  // deliberate exception: SessionStart's postBrief writes POST /brief's
22
- // `text` to stdout, which Claude Code folds into the session's
23
- // context -- that is the entire point of task 9. stdout carries the
24
- // brief and nothing else, ever. Every other channel is stderr
25
- // (console.error), and never carries a transcript byte, a delta, or
26
- // any other response body -- only counts, status codes, and the
27
- // event/session names Claude Code itself already put on stdin.
31
+ // `text` (run through the harness profile's briefOutput -- identity
32
+ // for Claude Code, the hook JSON contract for Codex) to stdout, which
33
+ // the harness folds into the session's context -- that is the entire
34
+ // point of task 9. stdout carries the brief and nothing else, ever.
35
+ // Every other channel is stderr (console.error), and never carries a
36
+ // transcript byte, a delta, or any other response body -- only counts,
37
+ // status codes, and the event/session names already on stdin. The one
38
+ // further exception is ruling R4 (spec 012 §4.1): the Codex profile
39
+ // stays silent on stderr too when there is no usable credential, since
40
+ // Codex has no interactive onboarding to have warned the user first.
28
41
  //
29
42
  // Zero dependencies, per the brief: only node:fs and node:path here (plus
30
- // the sibling dialogue.ts module for the cc-dialogue-v1 rule, spec 011
31
- // §1 -- a local file, not an npm package), plus the global fetch (Node
32
- // 18+, no import needed) that deps.fetchImpl defaults to at the real call
33
- // site in cli.ts.
43
+ // the sibling dialogue.ts and harnesses.ts modules -- local files, not npm
44
+ // packages), plus the global fetch (Node 18+, no import needed) that
45
+ // deps.fetchImpl defaults to at the real call site in cli.ts.
34
46
  import { appendFileSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
35
47
  import { basename, dirname, join } from 'node:path';
36
- import { CONTENT_FORMAT, formatDelta, renderEntries } from "./dialogue.js";
48
+ import { formatDelta, renderEntries } from "./dialogue.js";
49
+ import { profileFor } from "./harnesses.js";
37
50
  import { resolveProject } from "./project.js";
51
+ // SESSION_END_HOOK_TIMEOUT_S/SESSION_END_TIMEOUT_MS are the Claude Code
52
+ // profile's numbers (harnesses.ts's PROFILES.claude_code) -- defined there,
53
+ // not here, to avoid an import cycle (this file imports profileFor from
54
+ // harnesses.ts; harnesses.ts must not import back). Re-exported unchanged so
55
+ // capture.ts's existing `import { ... } from './hook.ts'` keeps working. See
56
+ // harnesses.ts's own comment on them for the full derivation.
57
+ export { SESSION_END_HOOK_TIMEOUT_S, SESSION_END_TIMEOUT_MS } from "./harnesses.js";
38
58
  // Bumped by hand at release time alongside package.json's "version" -- the
39
59
  // brief's call: a build-time constant here is fine, and it keeps this file
40
60
  // free of any fs reach into its own package.json (which would also be
41
61
  // wrong at runtime, since dist/hook.js does not sit next to package.json
42
62
  // the way src/hook.ts does). Exported since postBrief's request body and
43
63
  // hook.test.mjs's drift check (against package.json) both need it; task 14
44
- // bumps this and package.json to 0.2.0 together.
45
- export const SHIM_VERSION = '0.2.0';
46
- const HARNESS = 'claude_code';
64
+ // (spec 012 §9) bumps this, package.json and the plugin manifest
65
+ // (.codex-plugin/plugin.json) to 0.3.0 together.
66
+ export const SHIM_VERSION = '0.3.0';
47
67
  // Exported for capture.ts, which writes the file this module reads.
48
68
  export const CONFIG_FILE_NAME = 'config.json';
49
69
  // Same-directory temp file + rename, exactly atomicWriteJson's technique in
@@ -133,7 +153,14 @@ export function droppedMarkerPath(watermarkPath) {
133
153
  // zero pieces -- every new line was dropped by the formatting rule -- and
134
154
  // the caller still advances the watermark to `end` in that case, per
135
155
  // formatDelta's own contract.
136
- export function readDelta(transcriptPath, watermarkPath) {
156
+ export function readDelta(transcriptPath, watermarkPath,
157
+ // Which formatting rule to run past the watermark -- dialogue.ts's
158
+ // cc-dialogue-v1 by default (every caller before task 8, and every reader
159
+ // test above, wants exactly that); postDelta below passes the running
160
+ // event's profile.formatDelta instead, so the same watermark/piece
161
+ // machinery serves codex-dialogue-v1 too without a second copy of this
162
+ // function.
163
+ formatDeltaFn = formatDelta) {
137
164
  let buf;
138
165
  try {
139
166
  buf = readFileSync(transcriptPath);
@@ -144,7 +171,7 @@ export function readDelta(transcriptPath, watermarkPath) {
144
171
  const offset = readWatermark(watermarkPath);
145
172
  if (offset >= buf.length)
146
173
  return null; // nothing new since last watermark (also covers a shrunk/rotated file)
147
- const { pieces, end } = formatDelta(buf.subarray(offset));
174
+ const { pieces, end } = formatDeltaFn(buf.subarray(offset));
148
175
  if (end === 0)
149
176
  return null; // the only new bytes are an incomplete trailing line
150
177
  return { pieces: pieces.map((p) => ({ ...p, end: offset + p.end })), end: offset + end, offset };
@@ -190,27 +217,6 @@ function postJson(deps, config, path, body, signal) {
190
217
  signal,
191
218
  });
192
219
  }
193
- // The SessionEnd flush has to finish inside the hook entry capture.ts
194
- // installs for it, or Claude Code kills the process mid-request with no
195
- // chance to log why. That entry's `timeout` (seconds) is
196
- // SESSION_END_HOOK_TIMEOUT_S, defined here so capture.ts imports it and the
197
- // two numbers cannot drift apart again: until 2026-09-04 the network budget
198
- // below was 800 ms, derived from Claude Code's 1.5 s *default* SessionEnd
199
- // budget, while the installed entry had already raised that budget to 10 s
200
- // -- and 800 ms is one cold round trip to api.waku.one from Yang's machine
201
- // (three real POST /ingest/session calls measured 822, 644 and 1174 ms), so
202
- // two of every three flushes were aborted by our own timer. That is the
203
- // last turn of every session, and all of a single-turn one.
204
- //
205
- // The network budget is the process budget minus what happens before the
206
- // request: `npx -y waku-memory hook` took 2.8 s to reach this code cold
207
- // (2026-09-04), and Node startup, stdin and the transcript read are a few
208
- // hundred milliseconds more. 5 s leaves about 2 s of the 10 s for a slower
209
- // machine. hook.test.mjs pins both bounds. Stop is not bounded this way
210
- // (600 s budget, and runs "async": true), so its path through postDelta
211
- // below does not use this.
212
- export const SESSION_END_HOOK_TIMEOUT_S = 10;
213
- export const SESSION_END_TIMEOUT_MS = 5000;
214
220
  // undici wraps every network-level failure as TypeError("fetch failed") and
215
221
  // puts the reason on `cause` -- a DNS name, a refused connection, our own
216
222
  // abort, or (2026-09-04) an Authorization header carrying a control
@@ -238,10 +244,15 @@ export const BRIEF_TIMEOUT_MS = 3000;
238
244
  // do-nothing-and-log branches) but now bounded by an AbortController --
239
245
  // unlike a heartbeat nobody was waiting on, a hung /brief request sits in
240
246
  // the middle of the user's SessionStart, so this cannot wait forever.
241
- async function postBrief(event, deps) {
247
+ async function postBrief(event, deps, profile) {
242
248
  const config = readConfig(deps.configDir);
243
249
  if (!config) {
244
- console.error(`waku-memory hook: no usable config at ${join(deps.configDir, CONFIG_FILE_NAME)} -- no brief.`);
250
+ // Ruling R4 (spec 012 §4.1): Codex-only, quiet without a credential --
251
+ // no stdout, no stderr, no fetch call. Claude Code keeps this line
252
+ // (spec 011 A9: its output is unchanged).
253
+ if (!profile.silentWithoutCredential) {
254
+ console.error(`waku-memory hook: no usable config at ${join(deps.configDir, CONFIG_FILE_NAME)} -- no brief.`);
255
+ }
245
256
  return;
246
257
  }
247
258
  if (typeof event.cwd !== 'string' || event.cwd === '') {
@@ -254,15 +265,19 @@ async function postBrief(event, deps) {
254
265
  const timer = setTimeout(() => controller.abort(), deps.briefTimeoutMs ?? BRIEF_TIMEOUT_MS);
255
266
  try {
256
267
  // POST, not GET: `project` is caller-supplied free text and a query
257
- // string would put it in every access log (spec 011 §5).
258
- const res = await postJson(deps, config, '/brief', { harness: HARNESS, version: SHIM_VERSION, project }, controller.signal);
268
+ // string would put it in every access log (spec 011 §5). session_id
269
+ // travels here too (spec 012 §4) -- every harness, not just Codex.
270
+ const res = await postJson(deps, config, '/brief', { harness: profile.id, version: SHIM_VERSION, project, session_id: event.session_id }, controller.signal);
259
271
  if (!res.ok) {
260
272
  console.error(`waku-memory hook: /brief responded ${res.status} -- no brief.`);
261
273
  return;
262
274
  }
263
275
  const body = (await res.json());
264
276
  if (typeof body.text === 'string' && body.text !== '') {
265
- (deps.stdout ?? defaultStdout)(body.text);
277
+ // Identity for Claude Code (plain text); Codex wraps it in the hook
278
+ // JSON contract (spec 012 §4) -- the only place that knows which is
279
+ // profile.briefOutput itself, never a check here.
280
+ (deps.stdout ?? defaultStdout)(profile.briefOutput(body.text));
266
281
  }
267
282
  else {
268
283
  // A 2xx with missing, non-string, or empty text is a failure like any other.
@@ -302,15 +317,22 @@ export function trailingAssistantText(entries) {
302
317
  .map((e) => e.text)
303
318
  .join(' ');
304
319
  }
305
- async function postDelta(event, deps, source) {
306
- if (!event.transcript_path) {
307
- console.error(`waku-memory hook: no transcript_path on this ${event.hook_event_name} event -- skipping.`);
308
- return;
309
- }
320
+ async function postDelta(event, deps, source, profile) {
321
+ // Config is checked before transcript_path, deliberately: ruling R4 (spec
322
+ // 012 §4.1) says a Codex event with no usable config.json exits quietly
323
+ // regardless of what else about the event is missing or malformed --
324
+ // checking transcript_path first would let that branch's stderr line fire
325
+ // ahead of the credential check and break the "no stderr at all" promise.
310
326
  const configPath = join(deps.configDir, CONFIG_FILE_NAME);
311
327
  const config = readConfig(deps.configDir);
312
328
  if (!config) {
313
- console.error(`waku-memory hook: no usable config at ${configPath} -- skipping.`);
329
+ if (!profile.silentWithoutCredential) {
330
+ console.error(`waku-memory hook: no usable config at ${configPath} -- skipping.`);
331
+ }
332
+ return;
333
+ }
334
+ if (!event.transcript_path) {
335
+ console.error(`waku-memory hook: no transcript_path on this ${event.hook_event_name} event -- skipping.`);
314
336
  return;
315
337
  }
316
338
  // session_id arrives parsed from stdin and is used as a path segment
@@ -319,7 +341,7 @@ async function postDelta(event, deps, source) {
319
341
  // configDir/state. The same-user trust boundary already limits the blast
320
342
  // radius, but this removes the footgun rather than relying on that alone.
321
343
  const watermarkPath = join(deps.configDir, 'state', basename(event.session_id));
322
- let delta = readDelta(event.transcript_path, watermarkPath);
344
+ let delta = readDelta(event.transcript_path, watermarkPath, profile.formatDelta);
323
345
  if (delta === null) {
324
346
  // spec 011 §2: the transcript can lag the turn past every complete
325
347
  // line it holds. On Stop, a non-empty last_assistant_message is
@@ -364,14 +386,15 @@ async function postDelta(event, deps, source) {
364
386
  }
365
387
  }
366
388
  }
367
- // Only session-end is bounded -- see SESSION_END_TIMEOUT_MS's comment.
368
- // One deadline for the whole flush, not one budget per piece: several
369
- // pieces each getting the full SESSION_END_TIMEOUT_MS would let a
370
- // many-piece session run far longer than the budget the installed hook
371
- // entry actually gives the process. Stop's deadline stays undefined, so
372
- // every piece's controller below is undefined too -- identical to not
373
- // passing a signal at all.
374
- const budgetMs = deps.sessionEndTimeoutMs ?? SESSION_END_TIMEOUT_MS;
389
+ // Only session-end is bounded -- see harnesses.ts's SESSION_END_TIMEOUT_MS
390
+ // comment. One deadline for the whole flush, not one budget per piece:
391
+ // several pieces each getting the full budget would let a many-piece
392
+ // session run far longer than the budget the installed hook entry
393
+ // actually gives the process. Stop's deadline stays undefined, so every
394
+ // piece's controller below is undefined too -- identical to not passing a
395
+ // signal at all. profile.sessionEndTimeoutMs is Codex's 2000 ms or Claude
396
+ // Code's 5000 ms; deps.sessionEndTimeoutMs (test-only) overrides either.
397
+ const budgetMs = deps.sessionEndTimeoutMs ?? profile.sessionEndTimeoutMs;
375
398
  const deadline = source === 'session-end' ? Date.now() + budgetMs : undefined;
376
399
  let previousEnd = delta.offset;
377
400
  for (const piece of delta.pieces) {
@@ -380,11 +403,11 @@ async function postDelta(event, deps, source) {
380
403
  let res;
381
404
  try {
382
405
  res = await postJson(deps, config, '/ingest/session', {
383
- harness: HARNESS,
406
+ harness: profile.id,
384
407
  session_id: event.session_id,
385
408
  source,
386
409
  content: piece.text,
387
- content_format: CONTENT_FORMAT,
410
+ content_format: profile.contentFormat,
388
411
  ...(event.cwd ? { project: resolveProject(event.cwd) } : {}),
389
412
  }, controller?.signal);
390
413
  }
@@ -459,15 +482,21 @@ export async function handleHookEvent(event, deps) {
459
482
  console.error('waku-memory hook: received a malformed hook event on stdin -- skipping.');
460
483
  return;
461
484
  }
485
+ // The only place that asks "which harness?" -- everything downstream
486
+ // (postBrief, postDelta, readDelta) takes the answer as a profile, never
487
+ // as a string to compare against. deps.harness is typed HarnessId, so
488
+ // profileFor always finds an entry in PROFILES; cli.ts's dispatch() is
489
+ // what rejects an unknown --harness value before this is ever reached.
490
+ const profile = profileFor(deps.harness ?? 'claude_code');
462
491
  switch (event.hook_event_name) {
463
492
  case 'SessionStart':
464
- await postBrief(event, deps);
493
+ await postBrief(event, deps, profile);
465
494
  return;
466
495
  case 'Stop':
467
- await postDelta(event, deps, 'stop');
496
+ await postDelta(event, deps, 'stop', profile);
468
497
  return;
469
498
  case 'SessionEnd':
470
- await postDelta(event, deps, 'session-end');
499
+ await postDelta(event, deps, 'session-end', profile);
471
500
  return;
472
501
  default:
473
502
  console.error(`waku-memory hook: ignoring unhandled event "${String(event.hook_event_name)}".`);
package/dist/login.js ADDED
@@ -0,0 +1,312 @@
1
+ // `login` -- the browser OAuth flow that mints an API key without the
2
+ // person ever seeing one (spec 012 §7). Wired by cli.ts's `login` command
3
+ // and, since task 13, `capture enable`'s third path (a person who has no
4
+ // key yet is offered this instead of the paste prompt); this file only
5
+ // orchestrates the flow itself against an injected deps bag, the same
6
+ // split hook.ts/capture.ts already established, so login.test.mjs can
7
+ // drive it against a fake authorization server and a fake API on
8
+ // 127.0.0.1 and never touch a real browser or a real waku.one.
9
+ //
10
+ // The shape, in order: discover the authorization server from the API's
11
+ // protected-resource metadata, dynamically register a public client,
12
+ // listen on a loopback port, send the person to the authorization
13
+ // endpoint (PKCE S256, a random state, printed and opened), wait for the
14
+ // redirect back, exchange the code for a token, mint a key with it, and
15
+ // write that key exactly where a pasted one goes (writeConfigJson,
16
+ // sanitizeKey -- both capture.ts's, not reimplemented here).
17
+ //
18
+ // Zero new runtime dependency, per the shim's package.json: node:http,
19
+ // node:crypto, node:os and capture.ts (itself dependency-free) only.
20
+ import { createServer } from 'node:http';
21
+ import { randomBytes, createHash } from 'node:crypto';
22
+ import { hostname } from 'node:os';
23
+ import { sanitizeKey, writeConfigJson } from "./capture.js";
24
+ // §7: five minutes to complete a browser round trip is generous without
25
+ // being indefinite -- a person who wanders off leaves nothing running past
26
+ // that, and the loopback server closes either way (see login()'s finish).
27
+ export const LOGIN_TIMEOUT_MS = 300_000;
28
+ export const LOGIN_SCOPES = 'openid profile email offline_access';
29
+ // The `resource` parameter (RFC 8707) login sends in the authorize URL and
30
+ // the token request is always derived at runtime from deps.apiBase --
31
+ // `${apiBase}/mcp` with a trailing slash stripped first -- never this
32
+ // constant. LOGIN_RESOURCE exists only as the documented value for the
33
+ // default apiBase, so a reader (or a test asserting against the default)
34
+ // has something to compare the runtime value to without recomputing it.
35
+ export const LOGIN_RESOURCE = 'https://api.waku.one/mcp';
36
+ const REGISTERED_REDIRECT_URI = 'http://127.0.0.1/callback';
37
+ // Carries which of the four network steps failed and the HTTP status that
38
+ // said so -- login()'s refusal branches read both off this and print
39
+ // neither a response body nor anything from the request (never the token,
40
+ // never the key): "Sign-in failed at <step> (HTTP <status>)." is the whole
41
+ // line.
42
+ class StepError extends Error {
43
+ step;
44
+ status;
45
+ constructor(step, status) {
46
+ super(`${step} step failed with HTTP ${status}`);
47
+ this.step = step;
48
+ this.status = status;
49
+ }
50
+ }
51
+ function stripSlash(url) {
52
+ return url.replace(/\/+$/, '');
53
+ }
54
+ // GET <apiBase>/.well-known/oauth-protected-resource for the resource's
55
+ // authorization server, then that server's own
56
+ // /.well-known/oauth-authorization-server for the three endpoints login
57
+ // needs. Throws StepError('discovery', status) on anything short of a
58
+ // clean pair of 2xx JSON responses carrying the fields this reads --
59
+ // login() is the only caller and turns that into the one printed line.
60
+ export async function discover(apiBase, fetchImpl) {
61
+ const base = stripSlash(apiBase);
62
+ const resourceRes = await fetchImpl(`${base}/.well-known/oauth-protected-resource`);
63
+ if (!resourceRes.ok)
64
+ throw new StepError('discovery', resourceRes.status);
65
+ const resourceBody = (await resourceRes.json());
66
+ const issuer = Array.isArray(resourceBody.authorization_servers) ? resourceBody.authorization_servers[0] : undefined;
67
+ if (typeof issuer !== 'string' || issuer === '')
68
+ throw new StepError('discovery', resourceRes.status);
69
+ const asRes = await fetchImpl(`${stripSlash(issuer)}/.well-known/oauth-authorization-server`);
70
+ if (!asRes.ok)
71
+ throw new StepError('discovery', asRes.status);
72
+ const asBody = (await asRes.json());
73
+ const { authorization_endpoint, token_endpoint, registration_endpoint } = asBody;
74
+ if (typeof authorization_endpoint !== 'string' ||
75
+ typeof token_endpoint !== 'string' ||
76
+ typeof registration_endpoint !== 'string') {
77
+ throw new StepError('discovery', asRes.status);
78
+ }
79
+ return {
80
+ issuer,
81
+ authorizationEndpoint: authorization_endpoint,
82
+ tokenEndpoint: token_endpoint,
83
+ registrationEndpoint: registration_endpoint,
84
+ };
85
+ }
86
+ // Dynamic client registration (RFC 7591): a public client, redirect fixed
87
+ // at the bare loopback address (RFC 8252 §7.3 -- the *port* login actually
88
+ // redirects to is chosen per run and never registered, which is the whole
89
+ // point of the loopback exception). Returns client_id; throws
90
+ // StepError('registration', status) otherwise.
91
+ export async function registerClient(registrationEndpoint, fetchImpl, version) {
92
+ const res = await fetchImpl(registrationEndpoint, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({
96
+ client_name: `waku-memory ${version}`,
97
+ redirect_uris: [REGISTERED_REDIRECT_URI],
98
+ grant_types: ['authorization_code'],
99
+ response_types: ['code'],
100
+ token_endpoint_auth_method: 'none',
101
+ }),
102
+ });
103
+ if (!res.ok)
104
+ throw new StepError('registration', res.status);
105
+ const body = (await res.json());
106
+ if (typeof body.client_id !== 'string' || body.client_id === '')
107
+ throw new StepError('registration', res.status);
108
+ return body.client_id;
109
+ }
110
+ // PKCE (RFC 7636), S256 only: a 32-byte random verifier, base64url; the
111
+ // challenge is its SHA-256 digest, also base64url. Buffer's own
112
+ // 'base64url' encoding already omits padding, so neither value needs the
113
+ // usual base64 cleanup.
114
+ export function pkce() {
115
+ const verifier = randomBytes(32).toString('base64url');
116
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
117
+ return { verifier, challenge };
118
+ }
119
+ export function authorizeUrl(p) {
120
+ const url = new URL(p.authorizationEndpoint);
121
+ url.searchParams.set('response_type', 'code');
122
+ url.searchParams.set('client_id', p.clientId);
123
+ url.searchParams.set('redirect_uri', p.redirectUri);
124
+ url.searchParams.set('state', p.state);
125
+ url.searchParams.set('code_challenge', p.challenge);
126
+ url.searchParams.set('code_challenge_method', 'S256');
127
+ url.searchParams.set('scope', LOGIN_SCOPES);
128
+ url.searchParams.set('resource', p.resource);
129
+ return url.toString();
130
+ }
131
+ async function exchangeToken(tokenEndpoint, fetchImpl, p) {
132
+ const body = new URLSearchParams({
133
+ grant_type: 'authorization_code',
134
+ code: p.code,
135
+ redirect_uri: p.redirectUri,
136
+ client_id: p.clientId,
137
+ code_verifier: p.verifier,
138
+ resource: p.resource,
139
+ });
140
+ const res = await fetchImpl(tokenEndpoint, {
141
+ method: 'POST',
142
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
143
+ body: body.toString(),
144
+ });
145
+ if (!res.ok)
146
+ throw new StepError('token', res.status);
147
+ const json = (await res.json());
148
+ if (typeof json.access_token !== 'string' || json.access_token === '')
149
+ throw new StepError('token', res.status);
150
+ return { accessToken: json.access_token, idToken: typeof json.id_token === 'string' ? json.id_token : undefined };
151
+ }
152
+ // The email printed in the final "Signed in as <email>" line -- read from
153
+ // the token response's id_token, an unsigned decode of its middle segment
154
+ // (base64url JSON), never signature-checked: it arrived over TLS from the
155
+ // issuer discover() itself found, and the only use is one word in a
156
+ // stdout line, not an authorization decision. Anything short of a clean
157
+ // {email: string} there (no id_token, unparseable, wrong shape) falls back
158
+ // to the literal word "you" -- login() never blocks on this.
159
+ function emailFromIdToken(idToken) {
160
+ if (!idToken)
161
+ return null;
162
+ const parts = idToken.split('.');
163
+ if (parts.length < 2)
164
+ return null;
165
+ try {
166
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
167
+ return typeof payload.email === 'string' && payload.email !== '' ? payload.email : null;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ // keys.py's CreateKeyRequest.label: free text, default "unnamed", capped
174
+ // at 200 chars server-side -- truncated here too so a very long hostname
175
+ // never trips the server's own validation into a refusal this file would
176
+ // then have to explain.
177
+ function keyLabel() {
178
+ const label = `waku-memory on ${hostname()}`;
179
+ return label.length > 200 ? label.slice(0, 200) : label;
180
+ }
181
+ function reportRefusal(deps, fallbackStep, err) {
182
+ const step = err instanceof StepError ? err.step : fallbackStep;
183
+ const status = err instanceof StepError ? err.status : 0;
184
+ deps.stdout(`Sign-in failed at ${step} (HTTP ${status}).`);
185
+ }
186
+ // The whole flow. Never prints the token or the key on any path -- the
187
+ // only things written to deps.stdout are the authorize URL, the final
188
+ // "Signed in as <email>" line, and (on refusal) the one-line step/status
189
+ // report above. The loopback server, once opened, is closed on every path
190
+ // out of the returned promise: 'signed-in', 'timeout', and 'refused' from
191
+ // a bad token or keys response all go through the same finish().
192
+ // 'refused' from discovery or registration returns before any server
193
+ // exists, so there is nothing to close on those two paths.
194
+ export async function login(deps) {
195
+ const apiBase = stripSlash(deps.apiBase);
196
+ const resource = `${apiBase}/mcp`;
197
+ const timeoutMs = deps.timeoutMs ?? LOGIN_TIMEOUT_MS;
198
+ let discovered;
199
+ try {
200
+ discovered = await discover(apiBase, deps.fetchImpl);
201
+ }
202
+ catch (err) {
203
+ reportRefusal(deps, 'discovery', err);
204
+ return 'refused';
205
+ }
206
+ let clientId;
207
+ try {
208
+ clientId = await registerClient(discovered.registrationEndpoint, deps.fetchImpl, deps.version);
209
+ }
210
+ catch (err) {
211
+ reportRefusal(deps, 'registration', err);
212
+ return 'refused';
213
+ }
214
+ const { verifier, challenge } = pkce();
215
+ const state = randomBytes(16).toString('hex');
216
+ return new Promise((resolveLogin) => {
217
+ let settled = false;
218
+ let port = 0;
219
+ let accepted = false;
220
+ const server = createServer((req, res) => {
221
+ const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
222
+ if (requestUrl.pathname !== '/callback') {
223
+ res.writeHead(404);
224
+ res.end();
225
+ return;
226
+ }
227
+ const code = requestUrl.searchParams.get('code');
228
+ const receivedState = requestUrl.searchParams.get('state');
229
+ if (receivedState !== state || !code) {
230
+ // Wrong or missing state: keep waiting -- a stray or forged hit on
231
+ // this port must not end the flow the real browser is still
232
+ // carrying.
233
+ res.writeHead(400);
234
+ res.end();
235
+ return;
236
+ }
237
+ // OAuth codes are single-use and minting a credential should be
238
+ // idempotent only by accident; the client must not rely on the
239
+ // authorization server to defend against replay. Guard here: flag
240
+ // the code accepted before any async work, so a concurrent request
241
+ // sees the flag and returns 200 without re-running the exchange.
242
+ if (accepted) {
243
+ res.writeHead(200, { 'Content-Type': 'text/html' });
244
+ res.end('Signed in. You can close this tab.');
245
+ return;
246
+ }
247
+ accepted = true;
248
+ res.writeHead(200, { 'Content-Type': 'text/html' });
249
+ res.end('Signed in. You can close this tab.');
250
+ void finishSignIn(code);
251
+ });
252
+ const finish = (result) => {
253
+ if (settled)
254
+ return;
255
+ settled = true;
256
+ clearTimeout(timer);
257
+ server.close();
258
+ resolveLogin(result);
259
+ };
260
+ async function finishSignIn(code) {
261
+ try {
262
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
263
+ const tokenResult = await exchangeToken(discovered.tokenEndpoint, deps.fetchImpl, {
264
+ code,
265
+ redirectUri,
266
+ clientId,
267
+ verifier,
268
+ resource,
269
+ });
270
+ const keysRes = await deps.fetchImpl(`${apiBase}/keys`, {
271
+ method: 'POST',
272
+ headers: {
273
+ 'Content-Type': 'application/json',
274
+ Authorization: `Bearer ${tokenResult.accessToken}`,
275
+ },
276
+ body: JSON.stringify({ label: keyLabel() }),
277
+ });
278
+ if (!keysRes.ok)
279
+ throw new StepError('keys', keysRes.status);
280
+ const keysBody = (await keysRes.json());
281
+ if (typeof keysBody.plaintext !== 'string' || keysBody.plaintext === '') {
282
+ throw new StepError('keys', keysRes.status);
283
+ }
284
+ const { key } = sanitizeKey(keysBody.plaintext);
285
+ writeConfigJson(deps.configDir, { url: apiBase, key });
286
+ const email = emailFromIdToken(tokenResult.idToken) ?? 'you';
287
+ deps.stdout(`Signed in as ${email}. Key stored in ${deps.configDir}/config.json.`);
288
+ finish('signed-in');
289
+ }
290
+ catch (err) {
291
+ reportRefusal(deps, 'token', err);
292
+ finish('refused');
293
+ }
294
+ }
295
+ const timer = setTimeout(() => finish('timeout'), timeoutMs);
296
+ server.listen(0, '127.0.0.1', () => {
297
+ const address = server.address();
298
+ port = typeof address === 'object' && address !== null ? address.port : 0;
299
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
300
+ const url = authorizeUrl({
301
+ authorizationEndpoint: discovered.authorizationEndpoint,
302
+ clientId,
303
+ redirectUri,
304
+ state,
305
+ challenge,
306
+ resource,
307
+ });
308
+ deps.stdout(`Open this URL if your browser did not: ${url}`);
309
+ deps.openBrowser(url);
310
+ });
311
+ });
312
+ }