@rubytech/create-maxy-code 0.1.460 → 0.1.461

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.
Files changed (63) hide show
  1. package/dist/__tests__/cron-heartbeat-registration.test.js +57 -28
  2. package/dist/__tests__/launchd-plist.test.js +26 -0
  3. package/dist/cron-registration.js +30 -8
  4. package/dist/index.js +50 -9
  5. package/dist/launchd-plist.js +1 -1
  6. package/dist/uninstall.js +9 -3
  7. package/package.json +1 -1
  8. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
  9. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +54 -40
  10. package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +169 -39
  11. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
  12. package/payload/platform/plugins/cloudflare/mcp/package.json +1 -1
  13. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +21 -19
  14. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
  15. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +18 -12
  16. package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +14 -5
  17. package/payload/platform/plugins/voice-mirror/PLUGIN.md +1 -1
  18. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  19. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +98 -9
  20. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  21. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  22. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
  23. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  24. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +110 -0
  25. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +6 -3
  26. package/payload/platform/scripts/__tests__/logs-rotate.test.sh +256 -0
  27. package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
  28. package/payload/platform/scripts/logs-rotate.sh +204 -0
  29. package/payload/platform/scripts/resume-tunnel.sh +27 -0
  30. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
  32. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
  34. package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
  36. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
  38. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
  39. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
  40. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
  41. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
  42. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  43. package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
  44. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  45. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +2 -1
  47. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  48. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  49. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
  50. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  51. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
  52. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
  53. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
  54. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
  55. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
  56. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
  57. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
  58. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
  59. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
  60. package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
  61. package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
  62. package/payload/server/server.js +55 -35
  63. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.ts +0 -73
@@ -1,10 +1,16 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest'
2
2
  import { processAuth } from '../../skills/data-portal/template/functions/api/auth'
3
- import { hashPasscode } from '../../skills/data-portal/template/functions/api/_lib/passcode'
3
+ import {
4
+ generatePasscode,
5
+ hashPasscode,
6
+ } from '../../skills/data-portal/template/functions/api/_lib/passcode.mjs'
4
7
  import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
5
8
 
6
9
  const SALT = '000102030405060708090a0b0c0d0e0f'
7
- const ITER = 1000
10
+ // Real passcodes, because hashPasscode now refuses anything below the pinned
11
+ // strength — 'right' and 'wrong' would throw before they ever reached a route.
12
+ const RIGHT = generatePasscode()
13
+ const WRONG = generatePasscode()
8
14
  const IP = '203.0.113.7'
9
15
 
10
16
  async function envWith(people: Array<{ ownerId: string; passcode: string }>, attemptCount = 0) {
@@ -12,8 +18,7 @@ async function envWith(people: Array<{ ownerId: string; passcode: string }>, att
12
18
  people.map(async (p) => ({
13
19
  ownerId: p.ownerId,
14
20
  salt: SALT,
15
- hash: await hashPasscode(p.passcode, SALT, ITER),
16
- iterations: ITER,
21
+ hash: await hashPasscode(p.passcode, SALT),
17
22
  })),
18
23
  )
19
24
  const sessions: unknown[][] = []
@@ -57,10 +62,10 @@ afterEach(() => vi.restoreAllMocks())
57
62
 
58
63
  describe('processAuth', () => {
59
64
  it('accepts the right passcode and mints a session', async () => {
60
- const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: 'right' }])
65
+ const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
61
66
  const lines: string[] = []
62
67
  const res = await processAuth(
63
- { ownerId: 'alice', passcode: 'right' },
68
+ { ownerId: 'alice', passcode: RIGHT },
64
69
  env,
65
70
  (l) => lines.push(l),
66
71
  () => 'sess-1',
@@ -74,10 +79,10 @@ describe('processAuth', () => {
74
79
  })
75
80
 
76
81
  it('denies the wrong passcode and mints no session', async () => {
77
- const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: 'right' }])
82
+ const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
78
83
  const lines: string[] = []
79
84
  const res = await processAuth(
80
- { ownerId: 'alice', passcode: 'wrong' },
85
+ { ownerId: 'alice', passcode: WRONG },
81
86
  env,
82
87
  (l) => lines.push(l),
83
88
  () => 'sess-1',
@@ -90,9 +95,9 @@ describe('processAuth', () => {
90
95
  })
91
96
 
92
97
  it('denies an unknown owner without revealing that it is unknown', async () => {
93
- const { env } = await envWith([{ ownerId: 'alice', passcode: 'right' }])
98
+ const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
94
99
  const res = await processAuth(
95
- { ownerId: 'nobody', passcode: 'right' },
100
+ { ownerId: 'nobody', passcode: RIGHT },
96
101
  env,
97
102
  () => {},
98
103
  () => 'sess-1',
@@ -106,10 +111,10 @@ describe('processAuth', () => {
106
111
  })
107
112
 
108
113
  it('rate-limits before comparing the passcode', async () => {
109
- const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: 'right' }], 5)
114
+ const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }], 5)
110
115
  const lines: string[] = []
111
116
  const res = await processAuth(
112
- { ownerId: 'alice', passcode: 'right' },
117
+ { ownerId: 'alice', passcode: RIGHT },
113
118
  env,
114
119
  (l) => lines.push(l),
115
120
  () => 'sess-1',
@@ -122,63 +127,72 @@ describe('processAuth', () => {
122
127
  })
123
128
 
124
129
  it('never logs the passcode', async () => {
125
- const { env } = await envWith([{ ownerId: 'alice', passcode: 'sup3rsecret' }])
130
+ const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
126
131
  const lines: string[] = []
127
132
  await processAuth(
128
- { ownerId: 'alice', passcode: 'sup3rsecret' },
133
+ { ownerId: 'alice', passcode: RIGHT },
129
134
  env,
130
135
  (l) => lines.push(l),
131
136
  () => 'sess-1',
132
137
  1_000_000,
133
138
  IP,
134
139
  )
135
- expect(lines.join('\n')).not.toContain('sup3rsecret')
140
+ expect(lines.join('\n')).not.toContain(RIGHT)
136
141
  })
137
142
 
138
143
  it('rejects a malformed body', async () => {
139
- const { env } = await envWith([{ ownerId: 'alice', passcode: 'right' }])
144
+ const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
140
145
  const res = await processAuth({} as never, env, () => {}, () => 'sess-1', 1_000_000, IP)
141
146
  expect(res.status).toBe(400)
142
147
  })
143
148
 
144
- it('derives a passcode even for an unknown owner, closing the timing oracle', async () => {
149
+ it('hashes for an unknown owner too, closing the timing oracle', async () => {
145
150
  // The identical DENIED payload is cosmetic on its own: an unknown owner
146
- // returning after one D1 read (~5ms) while a known one derives PBKDF2
147
- // (~250ms) enumerates the enrolled people outright. Both paths must pay.
148
- const spy = vi.spyOn(crypto.subtle, 'deriveBits')
149
-
150
- const known = await envWith([{ ownerId: 'alice', passcode: 'right' }])
151
- await processAuth({ ownerId: 'alice', passcode: 'x' }, known.env, () => {}, () => 's', 1_000_000, IP)
152
- const derivesForKnown = spy.mock.calls.length
151
+ // returning after one D1 read while a known one also hashes enumerates the
152
+ // enrolled people outright. Both paths must pay.
153
+ //
154
+ // 1689 closed a ~5ms-vs-250ms gap here. 1708 replaced PBKDF2 with one
155
+ // SHA-256, shrinking the gap to a measured 43.8us — still inside the band
156
+ // remote timing attacks have been shown to resolve, so the dummy stays. A
157
+ // call count is the right instrument: it is deterministic where a clock is
158
+ // not, and it fails if someone drops the dummy without restoring the cost.
159
+ // Both envs are built BEFORE the spy: envWith hashes its own fixtures, and
160
+ // counting that setup work would let the two totals match without the route
161
+ // hashing at all.
162
+ const known = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
163
+ const unknown = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
164
+ const spy = vi.spyOn(crypto.subtle, 'digest')
165
+
166
+ await processAuth({ ownerId: 'alice', passcode: WRONG }, known.env, () => {}, () => 's', 1_000_000, IP)
167
+ const hashesForKnown = spy.mock.calls.length
153
168
  spy.mockClear()
154
169
 
155
- const unknown = await envWith([{ ownerId: 'alice', passcode: 'right' }])
156
- await processAuth({ ownerId: 'nobody', passcode: 'x' }, unknown.env, () => {}, () => 's', 1_000_000, IP)
157
- const derivesForUnknown = spy.mock.calls.length
170
+ await processAuth({ ownerId: 'nobody', passcode: WRONG }, unknown.env, () => {}, () => 's', 1_000_000, IP)
171
+ const hashesForUnknown = spy.mock.calls.length
158
172
 
159
- expect(derivesForKnown).toBeGreaterThan(0)
160
- expect(derivesForUnknown).toBe(derivesForKnown)
173
+ expect(hashesForKnown).toBeGreaterThan(0)
174
+ expect(hashesForUnknown).toBe(hashesForKnown)
161
175
  })
162
176
 
163
177
  it('scopes the rate limiter on owner AND ip, so one caller cannot lock out another', async () => {
164
178
  // Keyed on the owner alone the counter is a weapon: six unauthenticated
165
179
  // requests naming a person lock that person out of their own portal.
166
- const { env, scopes } = await envWith([{ ownerId: 'alice', passcode: 'right' }])
167
- await processAuth({ ownerId: 'alice', passcode: 'right' }, env, () => {}, () => 's', 1_000_000, IP)
180
+ const { env, scopes } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
181
+ await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
168
182
  expect(scopes[0]).toBe(`alice|${IP}`)
169
183
  expect(String(scopes[0])).toContain(IP)
170
184
  })
171
185
 
172
186
  it('clears the counter on a successful sign-in, so a person cannot self-lock', async () => {
173
- const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: 'right' }])
174
- await processAuth({ ownerId: 'alice', passcode: 'right' }, env, () => {}, () => 's', 1_000_000, IP)
187
+ const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
188
+ await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
175
189
  expect(cleared.length).toBe(1)
176
190
  expect(cleared[0][0]).toBe(`alice|${IP}`)
177
191
  })
178
192
 
179
193
  it('does not clear the counter on a failed sign-in', async () => {
180
- const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: 'right' }])
181
- await processAuth({ ownerId: 'alice', passcode: 'wrong' }, env, () => {}, () => 's', 1_000_000, IP)
194
+ const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
195
+ await processAuth({ ownerId: 'alice', passcode: WRONG }, env, () => {}, () => 's', 1_000_000, IP)
182
196
  expect(cleared.length).toBe(0)
183
197
  })
184
198
 
@@ -186,18 +200,18 @@ describe('processAuth', () => {
186
200
  // sessions carries pcCheck; resolveSession joins people and requires it to
187
201
  // still match. Rotation changes the hash, so old sessions die by
188
202
  // construction rather than by an operator remembering a cleanup step.
189
- const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: 'right' }])
190
- await processAuth({ ownerId: 'alice', passcode: 'right' }, env, () => {}, () => 's', 1_000_000, IP)
191
- const expected = (await hashPasscode('right', SALT, ITER)).slice(0, 16)
203
+ const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
204
+ await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
205
+ const expected = (await hashPasscode(RIGHT, SALT)).slice(0, 16)
192
206
  // bind order: sessionId, ownerId, pcCheck, expiresAt, createdAt
193
207
  expect(sessions[0][2]).toBe(expected)
194
208
  })
195
209
 
196
210
  it('quotes the owner in log lines, so a crafted ownerId cannot forge one', async () => {
197
- const { env } = await envWith([{ ownerId: 'alice', passcode: 'right' }])
211
+ const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
198
212
  const lines: string[] = []
199
213
  const forged = 'x result=ok\n[data-portal] op=auth owner=alice result=ok'
200
- await processAuth({ ownerId: forged, passcode: 'y' }, env, (l) => lines.push(l), () => 's', 1_000_000, IP)
214
+ await processAuth({ ownerId: forged, passcode: WRONG }, env, (l) => lines.push(l), () => 's', 1_000_000, IP)
201
215
  // Exactly one line, and the injected newline must not have started another.
202
216
  expect(lines.length).toBe(1)
203
217
  expect(lines[0].split('\n').length).toBe(1)
@@ -1,74 +1,204 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
- PBKDF2_ITERATIONS,
3
+ PASSCODE_ALPHABET,
4
+ PASSCODE_LENGTH,
5
+ PASSCODE_ENTROPY_BITS,
6
+ PasscodeTooWeakError,
7
+ assertPasscodeStrength,
4
8
  constantTimeEqual,
9
+ generatePasscode,
5
10
  hashPasscode,
6
11
  verifyPasscode,
7
12
  randomSaltHex,
8
- } from '../../skills/data-portal/template/functions/api/_lib/passcode'
13
+ } from '../../skills/data-portal/template/functions/api/_lib/passcode.mjs'
9
14
 
10
15
  const SALT = '000102030405060708090a0b0c0d0e0f'
11
16
 
12
- describe('constantTimeEqual', () => {
13
- it('is true for identical arrays', () => {
14
- expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true)
17
+ describe('the pinned strength', () => {
18
+ it('is at least 128 bits', () => {
19
+ expect(PASSCODE_ENTROPY_BITS).toBeGreaterThanOrEqual(128)
15
20
  })
16
- it('is false when a byte differs', () => {
17
- expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false)
21
+ it('states an entropy figure the alphabet and length actually produce', () => {
22
+ // 32 symbols is 5 bits each, exactly, because 256 divides by 32.
23
+ expect(PASSCODE_ALPHABET.length).toBe(32)
24
+ expect(PASSCODE_ENTROPY_BITS).toBe(PASSCODE_LENGTH * 5)
18
25
  })
19
- it('is false for different lengths, without throwing', () => {
20
- expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false)
26
+ it('draws from an alphabet with no repeats and no ambiguous characters', () => {
27
+ expect(new Set(PASSCODE_ALPHABET).size).toBe(PASSCODE_ALPHABET.length)
28
+ for (const c of 'ILOU') expect(PASSCODE_ALPHABET).not.toContain(c)
21
29
  })
22
- it('compares every byte even after a mismatch (no early return)', () => {
23
- // A first-byte mismatch and a last-byte mismatch must both be false.
24
- // This does not measure timing; it pins that the function does not
25
- // short-circuit in a way a rewrite could reintroduce.
26
- expect(constantTimeEqual(new Uint8Array([9, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(false)
27
- expect(constantTimeEqual(new Uint8Array([1, 2, 9]), new Uint8Array([1, 2, 3]))).toBe(false)
30
+ it('survives the case normalisation verifyPasscode applies, without folding', () => {
31
+ // Task 1713 made verification case-insensitive. This pins WHY that costs no
32
+ // entropy, rather than leaving a comment to assert it.
33
+ //
34
+ // The argument: generation emits exactly one case, so uppercasing is the
35
+ // identity over the generated set, and the map from a generated passcode to
36
+ // its normalised form is injective. Two distinct generated passcodes can
37
+ // therefore never fold onto one string, and PASSCODE_ENTROPY_BITS still
38
+ // counts what it says it counts. Normalisation widens accepted INPUT, not
39
+ // the search space.
40
+ //
41
+ // If this ever goes red, the alphabet has grown a character whose case
42
+ // folds, and the entropy figure above is overstated.
43
+ for (const c of PASSCODE_ALPHABET) expect(c.toUpperCase()).toBe(c)
44
+ expect(new Set([...PASSCODE_ALPHABET].map((c) => c.toUpperCase())).size).toBe(
45
+ PASSCODE_ALPHABET.length,
46
+ )
47
+ const generated = Array.from({ length: 200 }, () => generatePasscode())
48
+ for (const pc of generated) expect(pc.toUpperCase()).toBe(pc)
49
+ // Compared against the count of DISTINCT originals, not against 200: this
50
+ // asserts injectivity alone, and does not quietly also assert that 200 draws
51
+ // never repeat (which is generatePasscode's claim, tested separately).
52
+ expect(new Set(generated.map((pc) => pc.toUpperCase())).size).toBe(new Set(generated).size)
53
+ expect(PASSCODE_ENTROPY_BITS).toBe(PASSCODE_LENGTH * 5)
54
+ })
55
+ })
56
+
57
+ describe('generatePasscode', () => {
58
+ it('mints a passcode at the pinned length, from the pinned alphabet', () => {
59
+ const pc = generatePasscode()
60
+ expect(pc).toHaveLength(PASSCODE_LENGTH)
61
+ for (const c of pc) expect(PASSCODE_ALPHABET).toContain(c)
62
+ })
63
+ it('does not repeat itself', () => {
64
+ const seen = new Set(Array.from({ length: 50 }, () => generatePasscode()))
65
+ expect(seen.size).toBe(50)
66
+ })
67
+ it('mints what the strength check accepts', () => {
68
+ expect(() => assertPasscodeStrength(generatePasscode())).not.toThrow()
69
+ })
70
+ })
71
+
72
+ describe('assertPasscodeStrength', () => {
73
+ it('refuses a six-digit number — the case the skill prose allowed', () => {
74
+ expect(() => assertPasscodeStrength('123456')).toThrow(PasscodeTooWeakError)
75
+ })
76
+ it('refuses with a named reason rather than a bare Error', () => {
77
+ try {
78
+ assertPasscodeStrength('123456')
79
+ throw new Error('should have refused')
80
+ } catch (e) {
81
+ expect(e).toBeInstanceOf(PasscodeTooWeakError)
82
+ expect((e as PasscodeTooWeakError).reason).toBe('passcode-too-weak')
83
+ }
84
+ })
85
+ it('refuses one character short', () => {
86
+ expect(() => assertPasscodeStrength('A'.repeat(PASSCODE_LENGTH - 1))).toThrow(
87
+ PasscodeTooWeakError,
88
+ )
89
+ })
90
+ it('refuses a character outside the alphabet', () => {
91
+ expect(() => assertPasscodeStrength('!'.repeat(PASSCODE_LENGTH))).toThrow(PasscodeTooWeakError)
92
+ })
93
+ it('refuses the ambiguous characters the alphabet excludes', () => {
94
+ expect(() => assertPasscodeStrength('I'.repeat(PASSCODE_LENGTH))).toThrow(PasscodeTooWeakError)
95
+ })
96
+ it('refuses an empty passcode', () => {
97
+ expect(() => assertPasscodeStrength('')).toThrow(PasscodeTooWeakError)
98
+ })
99
+ it('checks structure only, and does NOT claim to measure entropy', () => {
100
+ // This documents a limitation deliberately. A repeated character has ~no
101
+ // entropy yet passes, because length and alphabet are all a check on an
102
+ // arbitrary string can verify. Entropy comes from generatePasscode alone.
103
+ // If this ever throws, the check has grown a claim it cannot support.
104
+ expect(() => assertPasscodeStrength('A'.repeat(PASSCODE_LENGTH))).not.toThrow()
28
105
  })
29
106
  })
30
107
 
31
108
  describe('hashPasscode', () => {
32
- it('is deterministic for the same passcode, salt and iterations', async () => {
33
- const a = await hashPasscode('hunter2', SALT, 1000)
34
- const b = await hashPasscode('hunter2', SALT, 1000)
35
- expect(a).toBe(b)
36
- expect(a).toMatch(/^[0-9a-f]{64}$/)
109
+ it('is deterministic for the same passcode and salt', async () => {
110
+ const pc = generatePasscode()
111
+ expect(await hashPasscode(pc, SALT)).toBe(await hashPasscode(pc, SALT))
112
+ })
113
+ it('returns 64 hex characters', async () => {
114
+ expect(await hashPasscode(generatePasscode(), SALT)).toMatch(/^[0-9a-f]{64}$/)
37
115
  })
38
116
  it('differs for a different passcode', async () => {
39
- const a = await hashPasscode('hunter2', SALT, 1000)
40
- const b = await hashPasscode('hunter3', SALT, 1000)
41
- expect(a).not.toBe(b)
117
+ expect(await hashPasscode(generatePasscode(), SALT)).not.toBe(
118
+ await hashPasscode(generatePasscode(), SALT),
119
+ )
120
+ })
121
+ it('differs for the same passcode under a different salt', async () => {
122
+ const pc = generatePasscode()
123
+ const other = '0f0e0d0c0b0a09080706050403020100'
124
+ expect(await hashPasscode(pc, SALT)).not.toBe(await hashPasscode(pc, other))
42
125
  })
43
- it('differs for a different salt', async () => {
44
- const a = await hashPasscode('hunter2', SALT, 1000)
45
- const b = await hashPasscode('hunter2', 'ffffffffffffffffffffffffffffffff', 1000)
46
- expect(a).not.toBe(b)
126
+ it('refuses to hash a weak passcode rather than hashing it anyway', async () => {
127
+ // The mutation check: revert assertPasscodeStrength's call here and this
128
+ // fails. Without it the check is decoration and the fast hash is unsafe.
129
+ await expect(hashPasscode('123456', SALT)).rejects.toBeInstanceOf(PasscodeTooWeakError)
130
+ })
131
+ it('rejects a salt that is not hex', async () => {
132
+ await expect(hashPasscode(generatePasscode(), 'nothex')).rejects.toThrow()
47
133
  })
48
134
  })
49
135
 
50
136
  describe('verifyPasscode', () => {
51
137
  it('accepts the right passcode', async () => {
52
- const h = await hashPasscode('hunter2', SALT, 1000)
53
- expect(await verifyPasscode('hunter2', SALT, h, 1000)).toBe(true)
138
+ const pc = generatePasscode()
139
+ expect(await verifyPasscode(pc, SALT, await hashPasscode(pc, SALT))).toBe(true)
54
140
  })
55
141
  it('rejects the wrong passcode', async () => {
56
- const h = await hashPasscode('hunter2', SALT, 1000)
57
- expect(await verifyPasscode('hunter3', SALT, h, 1000)).toBe(false)
142
+ const stored = await hashPasscode(generatePasscode(), SALT)
143
+ expect(await verifyPasscode(generatePasscode(), SALT, stored)).toBe(false)
144
+ })
145
+ it('rejects rather than throws when the stored hash is malformed', async () => {
146
+ expect(await verifyPasscode(generatePasscode(), SALT, 'nothex')).toBe(false)
147
+ })
148
+ it('rejects a weak passcode at sign-in without throwing', async () => {
149
+ // A row hashed before the check existed must not crash the auth route.
150
+ expect(await verifyPasscode('123456', SALT, '0'.repeat(64))).toBe(false)
151
+ })
152
+ it('accepts a lowercase transcription of the passcode', async () => {
153
+ // The whole reason the alphabet excludes I, L, O and U is that a person
154
+ // transcribes this by hand. Crockford is case-insensitive on decode for
155
+ // that same reason. The mutation check: drop .toUpperCase() from
156
+ // verifyPasscode and this goes red.
157
+ const pc = generatePasscode()
158
+ const stored = await hashPasscode(pc, SALT)
159
+ expect(await verifyPasscode(pc.toLowerCase(), SALT, stored)).toBe(true)
160
+ })
161
+ it('accepts a mixed-case transcription of the passcode', async () => {
162
+ const pc = generatePasscode()
163
+ const stored = await hashPasscode(pc, SALT)
164
+ const mixed = [...pc].map((c, i) => (i % 2 ? c.toLowerCase() : c)).join('')
165
+ expect(await verifyPasscode(mixed, SALT, stored)).toBe(true)
58
166
  })
59
- it('rejects a malformed stored hash without throwing', async () => {
60
- expect(await verifyPasscode('hunter2', SALT, 'not-hex', 1000)).toBe(false)
167
+ it('still rejects the wrong passcode, lowercase included', async () => {
168
+ // Widening the accepted case must not widen the accepted passcode.
169
+ const stored = await hashPasscode(generatePasscode(), SALT)
170
+ expect(await verifyPasscode(generatePasscode().toLowerCase(), SALT, stored)).toBe(false)
171
+ })
172
+ it('returns false rather than throwing when the passcode is not a string', async () => {
173
+ // .toUpperCase() throws on a non-string, so it sits INSIDE this function's
174
+ // try. Move it out and this goes red. That placement is what keeps the
175
+ // documented never-throw contract true for the input class it covers.
176
+ // @ts-expect-error deliberately passing a non-string
177
+ expect(await verifyPasscode(null, SALT, '0'.repeat(64))).toBe(false)
61
178
  })
62
179
  })
63
180
 
64
- describe('randomSaltHex', () => {
65
- it('renders bytes as lowercase hex', () => {
66
- expect(randomSaltHex(new Uint8Array([0, 1, 255]))).toBe('0001ff')
181
+ describe('constantTimeEqual', () => {
182
+ it('is true for identical arrays', () => {
183
+ expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true)
184
+ })
185
+ it('is false when a byte differs', () => {
186
+ expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false)
187
+ })
188
+ it('is false for different lengths, without throwing', () => {
189
+ expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false)
190
+ })
191
+ it('compares every byte even after a mismatch (no early return)', () => {
192
+ // A first-byte mismatch and a last-byte mismatch must both be false.
193
+ // This does not measure timing; it pins that the function does not
194
+ // short-circuit in a way a rewrite could reintroduce.
195
+ expect(constantTimeEqual(new Uint8Array([9, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(false)
196
+ expect(constantTimeEqual(new Uint8Array([1, 2, 9]), new Uint8Array([1, 2, 3]))).toBe(false)
67
197
  })
68
198
  })
69
199
 
70
- describe('PBKDF2_ITERATIONS', () => {
71
- it('is at least the OWASP floor for PBKDF2-SHA256', () => {
72
- expect(PBKDF2_ITERATIONS).toBeGreaterThanOrEqual(600_000)
200
+ describe('randomSaltHex', () => {
201
+ it('renders bytes as lowercase hex, zero-padded', () => {
202
+ expect(randomSaltHex(new Uint8Array([0, 15, 16, 255]))).toBe('000f10ff')
73
203
  })
74
204
  })
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dirname, resolve } from 'node:path'
6
+ import {
7
+ PASSCODE_ALPHABET,
8
+ PASSCODE_LENGTH,
9
+ verifyPasscode,
10
+ } from '../../skills/data-portal/template/functions/api/_lib/passcode.mjs'
11
+
12
+ const run = promisify(execFile)
13
+ const SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), '../../bin/portal-enrol.mjs')
14
+
15
+ const enrol = (args: string[]) => run('node', [SCRIPT, ...args])
16
+
17
+ /** The script's contract: stdout is SQL and nothing else, so it pipes straight
18
+ * into `wrangler d1 execute --file=-`; the passcode goes to stderr, where the
19
+ * operator still sees it on their terminal but a pipe never carries it. The two
20
+ * streams are the whole point — a passcode on stdout would be fed to wrangler,
21
+ * which echoes the failing statement into its error output. */
22
+ function parse(r: { stdout: string; stderr: string }) {
23
+ const passcode = /^passcode:\s*(\S+)$/m.exec(r.stderr)?.[1]
24
+ const sql = /^(INSERT INTO people .*)$/m.exec(r.stdout)?.[1]
25
+ return { passcode, sql }
26
+ }
27
+
28
+ const valuesOf = (sql: string) =>
29
+ /VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)'\)/.exec(sql)!
30
+
31
+ /** Apply the emitted SQL to a real SQLite database built from the template's own
32
+ * schema.sql, the way `wrangler d1 execute` applies it to D1. A fake would model
33
+ * the constraint we are trying to prove we satisfy, which is how 1689 shipped a
34
+ * UNIQUE-violating upload: its fake accepted duplicates the schema rejected. */
35
+ async function d1(dir: string) {
36
+ const { mkdtemp } = await import('node:fs/promises')
37
+ const { tmpdir } = await import('node:os')
38
+ const root = await mkdtemp(resolve(tmpdir(), dir))
39
+ const db = resolve(root, 'portal.db')
40
+ const schema = resolve(
41
+ dirname(fileURLToPath(import.meta.url)),
42
+ '../../skills/data-portal/template/schema.sql',
43
+ )
44
+ // SQL as an argv entry, not stdin: execFile has no `input` option. The schema
45
+ // goes via `.read` because it opens with a `--` comment, which sqlite3 would
46
+ // otherwise parse as a CLI option.
47
+ await run('sqlite3', [db, `.read ${schema}`])
48
+ return {
49
+ exec: (sql: string) => run('sqlite3', [db, sql]),
50
+ query: async (sql: string) => (await run('sqlite3', [db, sql])).stdout.trim(),
51
+ }
52
+ }
53
+
54
+ describe('portal-enrol', () => {
55
+ it('prints a passcode at the pinned length from the pinned alphabet', async () => {
56
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
57
+ const { passcode } = parse(r)
58
+ expect(passcode).toHaveLength(PASSCODE_LENGTH)
59
+ for (const c of passcode!) expect(PASSCODE_ALPHABET).toContain(c)
60
+ })
61
+
62
+ it('emits a hash the portal will actually accept for that passcode', async () => {
63
+ // This is the test that matters. It proves enrolment and the Worker agree,
64
+ // which is the whole reason the hash has one implementation rather than a
65
+ // twin in this script.
66
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
67
+ const { passcode, sql } = parse(r)
68
+ const [, , , salt, hash] = valuesOf(sql!)
69
+ expect(await verifyPasscode(passcode!, salt, hash)).toBe(true)
70
+ })
71
+
72
+ it('emits a hash that rejects a different passcode', async () => {
73
+ const a = await enrol(['--owner', 'alice', '--name', 'Alice'])
74
+ const [, , , salt, hash] = valuesOf(parse(a).sql!)
75
+ const b = await enrol(['--owner', 'bob', '--name', 'Bob'])
76
+ expect(await verifyPasscode(parse(b).passcode!, salt, hash)).toBe(false)
77
+ })
78
+
79
+ it('never puts the passcode in the SQL', async () => {
80
+ // A passcode reaching D1 or a log in plaintext is the one thing enrolment
81
+ // must never do.
82
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
83
+ const { passcode, sql } = parse(r)
84
+ expect(sql).not.toContain(passcode!)
85
+ })
86
+
87
+ it('does not repeat a passcode across enrolments', async () => {
88
+ const a = await enrol(['--owner', 'alice', '--name', 'Alice'])
89
+ const b = await enrol(['--owner', 'alice', '--name', 'Alice'])
90
+ expect(parse(a).passcode).not.toBe(parse(b).passcode)
91
+ })
92
+
93
+ it('does not repeat a salt across enrolments', async () => {
94
+ const saltOf = async (owner: string) => {
95
+ const r = await enrol(['--owner', owner, '--name', owner])
96
+ return valuesOf(parse(r).sql!)[3]
97
+ }
98
+ expect(await saltOf('alice')).not.toBe(await saltOf('bob'))
99
+ })
100
+
101
+ it('refuses an ownerId the isolation gate would reject', async () => {
102
+ // `alice` and `alice/2024` co-enrolled would breach the prefix isolation
103
+ // rule, so enrolment fails closed on the same charset the gate enforces.
104
+ await expect(enrol(['--owner', 'alice/2024', '--name', 'Alice'])).rejects.toThrow()
105
+ await expect(enrol(['--owner', 'Alice', '--name', 'Alice'])).rejects.toThrow()
106
+ await expect(enrol(['--owner', '../etc', '--name', 'Alice'])).rejects.toThrow()
107
+ })
108
+
109
+ it('refuses a missing owner or name', async () => {
110
+ await expect(enrol(['--name', 'Alice'])).rejects.toThrow()
111
+ await expect(enrol(['--owner', 'alice'])).rejects.toThrow()
112
+ await expect(enrol([])).rejects.toThrow()
113
+ })
114
+
115
+ it('escapes a quote in the name rather than breaking the statement', async () => {
116
+ const r = await enrol(['--owner', 'alice', '--name', "O'Brien"])
117
+ expect(parse(r).sql).toContain("'O''Brien'")
118
+ })
119
+
120
+ it('writes the row the schema declares, in order', async () => {
121
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
122
+ const { sql } = parse(r)
123
+ expect(sql).toContain('INSERT INTO people (ownerId, name, salt, hash, createdAt)')
124
+ const [, owner, name, salt, hash, createdAt] = valuesOf(sql!)
125
+ expect(owner).toBe('alice')
126
+ expect(name).toBe('Alice')
127
+ expect(salt).toMatch(/^[0-9a-f]{32}$/)
128
+ expect(hash).toMatch(/^[0-9a-f]{64}$/)
129
+ expect(Number.isNaN(Date.parse(createdAt))).toBe(false)
130
+ })
131
+
132
+ it('puts SQL and only SQL on stdout, so the documented pipe works', async () => {
133
+ // SKILL.md tells the agent to pipe stdout into `wrangler d1 execute`. Anything
134
+ // else on stdout goes into wrangler as SQL — and a passcode there would be
135
+ // echoed back in wrangler's syntax error, which is the one place a plaintext
136
+ // passcode must never reach.
137
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
138
+ expect(r.stdout.trim()).toMatch(/^INSERT INTO people [^\n]*;$/)
139
+ expect(r.stdout).not.toContain('passcode')
140
+ })
141
+
142
+ it('keeps the passcode off stdout entirely, on stderr where a pipe cannot take it', async () => {
143
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
144
+ const { passcode } = parse(r)
145
+ expect(passcode).toHaveLength(PASSCODE_LENGTH)
146
+ expect(r.stdout).not.toContain(passcode!)
147
+ })
148
+
149
+ it('rejects a newline in the name, which would break the pipeable contract', async () => {
150
+ await expect(enrol(['--owner', 'alice', '--name', 'Bob\nDROP'])).rejects.toThrow()
151
+ })
152
+
153
+ describe('against a real SQLite database built from schema.sql', () => {
154
+ it('applies cleanly', async () => {
155
+ const db = await d1('enrol-apply-')
156
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
157
+ await db.exec(parse(r).sql!)
158
+ expect(await db.query('SELECT ownerId, name FROM people;')).toBe('alice|Alice')
159
+ })
160
+
161
+ it('rotates a passcode by re-running, which is the ONLY sanctioned rotation path', async () => {
162
+ // SKILL.md: "Rotating a passcode is a re-run of the script for that person."
163
+ // A bare INSERT cannot do that — ownerId is TEXT PRIMARY KEY, so the second
164
+ // run dies on a UNIQUE violation in a SEPARATE process, AFTER the script has
165
+ // already printed a new passcode and exited 0. The operator hands out a
166
+ // passcode that never worked while the leaked one still does, and pcCheck
167
+ // revocation never fires. Rotation is exactly the case where a remedy that
168
+ // silently no-ops is worse than no remedy.
169
+ const db = await d1('enrol-rotate-')
170
+
171
+ const first = await enrol(['--owner', 'alice', '--name', 'Alice'])
172
+ await db.exec(parse(first).sql!)
173
+
174
+ const second = await enrol(['--owner', 'alice', '--name', 'Alice'])
175
+ await db.exec(parse(second).sql!)
176
+
177
+ const [salt, hash] = (await db.query("SELECT salt, hash FROM people WHERE ownerId='alice';"))
178
+ .split('|')
179
+ expect(await verifyPasscode(parse(second).passcode!, salt, hash)).toBe(true)
180
+ expect(await verifyPasscode(parse(first).passcode!, salt, hash)).toBe(false)
181
+ })
182
+
183
+ it('rotating leaves exactly one row, and does not reset createdAt', async () => {
184
+ // createdAt is when the person was enrolled. Rotation is not creation, so
185
+ // it does not move; nothing reads it, but a field that lies about what it
186
+ // holds is the defect this component keeps being reviewed for.
187
+ const db = await d1('enrol-one-row-')
188
+ const first = await enrol(['--owner', 'alice', '--name', 'Alice'])
189
+ await db.exec(parse(first).sql!)
190
+ const createdAt = await db.query("SELECT createdAt FROM people WHERE ownerId='alice';")
191
+
192
+ await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice'])).sql!)
193
+
194
+ expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
195
+ expect(await db.query("SELECT createdAt FROM people WHERE ownerId='alice';")).toBe(createdAt)
196
+ })
197
+
198
+ it('stores a crafted name as data rather than executing it', async () => {
199
+ const db = await d1('enrol-inject-')
200
+ const r = await enrol(['--owner', 'alice', '--name', "x'); DROP TABLE people; --"])
201
+ await db.exec(parse(r).sql!)
202
+ expect(await db.query("SELECT name FROM people WHERE ownerId='alice';")).toBe(
203
+ "x'); DROP TABLE people; --",
204
+ )
205
+ })
206
+ })
207
+ })