@rubytech/create-maxy-code 0.1.459 → 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.
- package/dist/__tests__/cron-heartbeat-registration.test.js +57 -28
- package/dist/__tests__/launchd-plist.test.js +26 -0
- package/dist/cron-registration.js +30 -8
- package/dist/index.js +50 -9
- package/dist/launchd-plist.js +1 -1
- package/dist/uninstall.js +9 -3
- package/package.json +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +116 -6
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +20 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts +2 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js +41 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js.map +1 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +1 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +116 -48
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
- package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.js +1 -0
- package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +27 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/object-limits.js +27 -7
- package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts +102 -0
- package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/resource-limits.js +105 -0
- package/payload/platform/lib/storage-broker/dist/resource-limits.js.map +1 -0
- package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +130 -7
- package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +30 -0
- package/payload/platform/lib/storage-broker/src/__tests__/resource-limits.test.ts +49 -0
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +129 -56
- package/payload/platform/lib/storage-broker/src/index.ts +1 -0
- package/payload/platform/lib/storage-broker/src/object-limits.ts +26 -6
- package/payload/platform/lib/storage-broker/src/resource-limits.ts +105 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -4
- package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -1
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +220 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +201 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +178 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/harness.test.ts +32 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +204 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/ratelimit.test.ts +77 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +46 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +55 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +255 -0
- package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
- package/payload/platform/plugins/cloudflare/references/r2-object-storage.md +46 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +125 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +32 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/log.ts +12 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +55 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +85 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +44 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +119 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +89 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +70 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +41 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +151 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/index.html +42 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.css +113 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.js +155 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +73 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +21 -0
- package/payload/platform/plugins/cloudflare/skills/site-deploy/SKILL.md +8 -0
- package/payload/platform/plugins/docs/references/outlook-guide.md +3 -3
- package/payload/platform/plugins/docs/references/voice-mirror-guide.md +1 -0
- package/payload/platform/plugins/filesystem/PLUGIN.md +0 -1
- package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts +14 -10
- package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts.map +1 -1
- package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js +14 -10
- package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js.map +1 -1
- package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/outlook/PLUGIN.md +3 -5
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js +75 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js +187 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/index.js +3 -2
- package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts +43 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js +65 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts +65 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js +99 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +6 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +42 -22
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +59 -23
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts +15 -16
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +8 -41
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
- package/payload/platform/plugins/outlook/references/auth.md +1 -1
- package/payload/platform/plugins/outlook/references/graph-surfaces.md +7 -3
- package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +1 -1
- package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -1
- package/payload/platform/plugins/voice-mirror/PLUGIN.md +19 -7
- package/payload/platform/plugins/voice-mirror/mcp/dist/index.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +35 -16
- package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts +31 -0
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts.map +1 -0
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js +87 -0
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js.map +1 -0
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +111 -12
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js +2 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js +2 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts +2 -2
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js +12 -3
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +444 -0
- package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +18 -2
- package/payload/platform/scripts/__tests__/logs-rotate.test.sh +256 -0
- package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
- package/payload/platform/scripts/logs-rotate.sh +204 -0
- package/payload/platform/scripts/resume-tunnel.sh +27 -0
- package/payload/platform/scripts/voice-author-key-audit.sh +155 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts +17 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js +88 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
- package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
- package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +0 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +36 -24
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
- package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
- package/payload/server/{chunk-Q6W4U6HL.js → chunk-JXWFVE5X.js} +83 -39
- package/payload/server/server.js +268 -73
- package/payload/server/{src-3I2RYZFB.js → src-4F37OHLK.js} +11 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.d.ts +0 -2
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js +0 -215
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js.map +0 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { processAuth } from '../../skills/data-portal/template/functions/api/auth'
|
|
3
|
+
import {
|
|
4
|
+
generatePasscode,
|
|
5
|
+
hashPasscode,
|
|
6
|
+
} from '../../skills/data-portal/template/functions/api/_lib/passcode.mjs'
|
|
7
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
8
|
+
|
|
9
|
+
const SALT = '000102030405060708090a0b0c0d0e0f'
|
|
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()
|
|
14
|
+
const IP = '203.0.113.7'
|
|
15
|
+
|
|
16
|
+
async function envWith(people: Array<{ ownerId: string; passcode: string }>, attemptCount = 0) {
|
|
17
|
+
const rows = await Promise.all(
|
|
18
|
+
people.map(async (p) => ({
|
|
19
|
+
ownerId: p.ownerId,
|
|
20
|
+
salt: SALT,
|
|
21
|
+
hash: await hashPasscode(p.passcode, SALT),
|
|
22
|
+
})),
|
|
23
|
+
)
|
|
24
|
+
const sessions: unknown[][] = []
|
|
25
|
+
const cleared: unknown[][] = []
|
|
26
|
+
const scopes: unknown[] = []
|
|
27
|
+
const env = {
|
|
28
|
+
DB: {
|
|
29
|
+
prepare(query: string) {
|
|
30
|
+
let bound: unknown[] = []
|
|
31
|
+
const stmt = {
|
|
32
|
+
bind(...v: unknown[]) {
|
|
33
|
+
bound = v
|
|
34
|
+
if (/auth_attempts/i.test(query)) scopes.push(v[0])
|
|
35
|
+
return stmt
|
|
36
|
+
},
|
|
37
|
+
async run() {
|
|
38
|
+
if (/INSERT INTO sessions/i.test(query)) sessions.push(bound)
|
|
39
|
+
if (/DELETE FROM auth_attempts/i.test(query)) cleared.push(bound)
|
|
40
|
+
return { meta: { changes: 1 } }
|
|
41
|
+
},
|
|
42
|
+
async all() {
|
|
43
|
+
return { results: [] }
|
|
44
|
+
},
|
|
45
|
+
async first<T>() {
|
|
46
|
+
if (/FROM auth_attempts/i.test(query)) return { count: attemptCount + 1 } as T
|
|
47
|
+
if (/FROM people/i.test(query)) {
|
|
48
|
+
return (rows.find((r) => r.ownerId === bound[0]) ?? null) as T | null
|
|
49
|
+
}
|
|
50
|
+
return null as T | null
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
return stmt
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
BUCKET: {} as never,
|
|
57
|
+
} as unknown as PortalEnv
|
|
58
|
+
return { env, sessions, cleared, scopes }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
afterEach(() => vi.restoreAllMocks())
|
|
62
|
+
|
|
63
|
+
describe('processAuth', () => {
|
|
64
|
+
it('accepts the right passcode and mints a session', async () => {
|
|
65
|
+
const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
|
|
66
|
+
const lines: string[] = []
|
|
67
|
+
const res = await processAuth(
|
|
68
|
+
{ ownerId: 'alice', passcode: RIGHT },
|
|
69
|
+
env,
|
|
70
|
+
(l) => lines.push(l),
|
|
71
|
+
() => 'sess-1',
|
|
72
|
+
1_000_000,
|
|
73
|
+
IP,
|
|
74
|
+
)
|
|
75
|
+
expect(res.status).toBe(200)
|
|
76
|
+
expect(res.sessionId).toBe('sess-1')
|
|
77
|
+
expect(sessions.length).toBe(1)
|
|
78
|
+
expect(lines.join('\n')).toContain('op=auth owner="alice" result=ok')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('denies the wrong passcode and mints no session', async () => {
|
|
82
|
+
const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
|
|
83
|
+
const lines: string[] = []
|
|
84
|
+
const res = await processAuth(
|
|
85
|
+
{ ownerId: 'alice', passcode: WRONG },
|
|
86
|
+
env,
|
|
87
|
+
(l) => lines.push(l),
|
|
88
|
+
() => 'sess-1',
|
|
89
|
+
1_000_000,
|
|
90
|
+
IP,
|
|
91
|
+
)
|
|
92
|
+
expect(res.status).toBe(401)
|
|
93
|
+
expect(sessions.length).toBe(0)
|
|
94
|
+
expect(lines.join('\n')).toContain('result=denied')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('denies an unknown owner without revealing that it is unknown', async () => {
|
|
98
|
+
const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
|
|
99
|
+
const res = await processAuth(
|
|
100
|
+
{ ownerId: 'nobody', passcode: RIGHT },
|
|
101
|
+
env,
|
|
102
|
+
() => {},
|
|
103
|
+
() => 'sess-1',
|
|
104
|
+
1_000_000,
|
|
105
|
+
IP,
|
|
106
|
+
)
|
|
107
|
+
expect(res.status).toBe(401)
|
|
108
|
+
// Same payload as a wrong passcode: an enumeration oracle would otherwise
|
|
109
|
+
// tell an attacker which people exist.
|
|
110
|
+
expect(res.payload).toEqual({ ok: false, error: 'denied' })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('rate-limits before comparing the passcode', async () => {
|
|
114
|
+
const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }], 5)
|
|
115
|
+
const lines: string[] = []
|
|
116
|
+
const res = await processAuth(
|
|
117
|
+
{ ownerId: 'alice', passcode: RIGHT },
|
|
118
|
+
env,
|
|
119
|
+
(l) => lines.push(l),
|
|
120
|
+
() => 'sess-1',
|
|
121
|
+
1_000_000,
|
|
122
|
+
IP,
|
|
123
|
+
)
|
|
124
|
+
expect(res.status).toBe(429)
|
|
125
|
+
expect(sessions.length).toBe(0)
|
|
126
|
+
expect(lines.join('\n')).toContain('result=rate-limited')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('never logs the passcode', async () => {
|
|
130
|
+
const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
|
|
131
|
+
const lines: string[] = []
|
|
132
|
+
await processAuth(
|
|
133
|
+
{ ownerId: 'alice', passcode: RIGHT },
|
|
134
|
+
env,
|
|
135
|
+
(l) => lines.push(l),
|
|
136
|
+
() => 'sess-1',
|
|
137
|
+
1_000_000,
|
|
138
|
+
IP,
|
|
139
|
+
)
|
|
140
|
+
expect(lines.join('\n')).not.toContain(RIGHT)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('rejects a malformed body', async () => {
|
|
144
|
+
const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
|
|
145
|
+
const res = await processAuth({} as never, env, () => {}, () => 'sess-1', 1_000_000, IP)
|
|
146
|
+
expect(res.status).toBe(400)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('hashes for an unknown owner too, closing the timing oracle', async () => {
|
|
150
|
+
// The identical DENIED payload is cosmetic on its own: an unknown owner
|
|
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
|
|
168
|
+
spy.mockClear()
|
|
169
|
+
|
|
170
|
+
await processAuth({ ownerId: 'nobody', passcode: WRONG }, unknown.env, () => {}, () => 's', 1_000_000, IP)
|
|
171
|
+
const hashesForUnknown = spy.mock.calls.length
|
|
172
|
+
|
|
173
|
+
expect(hashesForKnown).toBeGreaterThan(0)
|
|
174
|
+
expect(hashesForUnknown).toBe(hashesForKnown)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('scopes the rate limiter on owner AND ip, so one caller cannot lock out another', async () => {
|
|
178
|
+
// Keyed on the owner alone the counter is a weapon: six unauthenticated
|
|
179
|
+
// requests naming a person lock that person out of their own portal.
|
|
180
|
+
const { env, scopes } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
|
|
181
|
+
await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
|
|
182
|
+
expect(scopes[0]).toBe(`alice|${IP}`)
|
|
183
|
+
expect(String(scopes[0])).toContain(IP)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('clears the counter on a successful sign-in, so a person cannot self-lock', async () => {
|
|
187
|
+
const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
|
|
188
|
+
await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP)
|
|
189
|
+
expect(cleared.length).toBe(1)
|
|
190
|
+
expect(cleared[0][0]).toBe(`alice|${IP}`)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('does not clear the counter on a failed sign-in', async () => {
|
|
194
|
+
const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
|
|
195
|
+
await processAuth({ ownerId: 'alice', passcode: WRONG }, env, () => {}, () => 's', 1_000_000, IP)
|
|
196
|
+
expect(cleared.length).toBe(0)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('binds the session to the passcode, so rotation revokes it', async () => {
|
|
200
|
+
// sessions carries pcCheck; resolveSession joins people and requires it to
|
|
201
|
+
// still match. Rotation changes the hash, so old sessions die by
|
|
202
|
+
// construction rather than by an operator remembering a cleanup step.
|
|
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)
|
|
206
|
+
// bind order: sessionId, ownerId, pcCheck, expiresAt, createdAt
|
|
207
|
+
expect(sessions[0][2]).toBe(expected)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('quotes the owner in log lines, so a crafted ownerId cannot forge one', async () => {
|
|
211
|
+
const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }])
|
|
212
|
+
const lines: string[] = []
|
|
213
|
+
const forged = 'x result=ok\n[data-portal] op=auth owner=alice result=ok'
|
|
214
|
+
await processAuth({ ownerId: forged, passcode: WRONG }, env, (l) => lines.push(l), () => 's', 1_000_000, IP)
|
|
215
|
+
// Exactly one line, and the injected newline must not have started another.
|
|
216
|
+
expect(lines.length).toBe(1)
|
|
217
|
+
expect(lines[0].split('\n').length).toBe(1)
|
|
218
|
+
expect(lines[0]).toMatch(/result=denied/)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
SESSION_COOKIE,
|
|
4
|
+
SESSION_TTL_MS,
|
|
5
|
+
mintSession,
|
|
6
|
+
resolveSession,
|
|
7
|
+
readSessionCookie,
|
|
8
|
+
} from '../../skills/data-portal/template/functions/api/_lib/session'
|
|
9
|
+
import {
|
|
10
|
+
ownerPrefix,
|
|
11
|
+
authorizeKey,
|
|
12
|
+
} from '../../skills/data-portal/template/functions/api/_lib/authorize'
|
|
13
|
+
import type { D1Database } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
14
|
+
|
|
15
|
+
// Models the real resolveSession query: sessions JOIN people, requiring the
|
|
16
|
+
// session's pcCheck to still match substr(people.hash, 1, 16). A fake that
|
|
17
|
+
// skipped the join would let the revocation tests below pass vacuously.
|
|
18
|
+
function fakeDb(
|
|
19
|
+
seed: Array<{ sessionId: string; ownerId: string; pcCheck: string; expiresAt: number }> = [],
|
|
20
|
+
people: Array<{ ownerId: string; hash: string }> = [{ ownerId: 'alice', hash: 'HASH-v1-padded00' }],
|
|
21
|
+
) {
|
|
22
|
+
const rows = [...seed]
|
|
23
|
+
const peopleRows = [...people]
|
|
24
|
+
const db: D1Database = {
|
|
25
|
+
prepare(query: string) {
|
|
26
|
+
let bound: unknown[] = []
|
|
27
|
+
const stmt = {
|
|
28
|
+
bind(...v: unknown[]) {
|
|
29
|
+
bound = v
|
|
30
|
+
return stmt
|
|
31
|
+
},
|
|
32
|
+
async run() {
|
|
33
|
+
if (/INSERT INTO sessions/i.test(query)) {
|
|
34
|
+
const [sessionId, ownerId, pcCheck, expiresAt] = bound as [string, string, string, number]
|
|
35
|
+
rows.push({ sessionId, ownerId, pcCheck, expiresAt })
|
|
36
|
+
}
|
|
37
|
+
if (/DELETE FROM sessions/i.test(query)) {
|
|
38
|
+
const [ownerId, nowMs] = bound as [string, number]
|
|
39
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
40
|
+
if (rows[i].ownerId === ownerId && rows[i].expiresAt <= nowMs) rows.splice(i, 1)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { meta: { changes: 1 } }
|
|
44
|
+
},
|
|
45
|
+
async all() {
|
|
46
|
+
return { results: [] }
|
|
47
|
+
},
|
|
48
|
+
async first<T>() {
|
|
49
|
+
const [sessionId, nowMs] = bound as [string, number]
|
|
50
|
+
const hit = rows.find((r) => r.sessionId === sessionId && r.expiresAt > nowMs)
|
|
51
|
+
if (!hit) return null as T | null
|
|
52
|
+
const person = peopleRows.find((p) => p.ownerId === hit.ownerId)
|
|
53
|
+
if (!person) return null as T | null // unenrolled -> the JOIN drops it
|
|
54
|
+
if (person.hash.slice(0, 16) !== hit.pcCheck) return null as T | null // rotated
|
|
55
|
+
return { ownerId: hit.ownerId } as T
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
return stmt
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
return { db, rows, peopleRows }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PC = 'HASH-v1-padded00'.slice(0, 16)
|
|
65
|
+
|
|
66
|
+
describe('sessions', () => {
|
|
67
|
+
it('mints a session resolvable to its owner', async () => {
|
|
68
|
+
const { db } = fakeDb()
|
|
69
|
+
await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
|
|
70
|
+
expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice' })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not resolve an expired session', async () => {
|
|
74
|
+
const { db } = fakeDb()
|
|
75
|
+
await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
|
|
76
|
+
expect(await resolveSession(db, 'sess-1', 1_000_000 + SESSION_TTL_MS + 1)).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('does not resolve an unknown session', async () => {
|
|
80
|
+
const { db } = fakeDb()
|
|
81
|
+
expect(await resolveSession(db, 'nope', 1_000_000)).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('prunes that owner expired rows on mint, so sessions do not grow forever', async () => {
|
|
85
|
+
const { db, rows } = fakeDb([
|
|
86
|
+
{ sessionId: 'old', ownerId: 'alice', pcCheck: PC, expiresAt: 1 },
|
|
87
|
+
])
|
|
88
|
+
await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
|
|
89
|
+
expect(rows.map((r) => r.sessionId)).toEqual(['sess-1'])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('session revocation — the property the design claims', () => {
|
|
94
|
+
it('stops resolving once the passcode is rotated', async () => {
|
|
95
|
+
// The whole reason a leaked passcode has a remedy. Rotation changes
|
|
96
|
+
// people.hash, so pcCheck no longer matches and the live session dies —
|
|
97
|
+
// by construction, not by remembering to delete anything.
|
|
98
|
+
const { db, peopleRows } = fakeDb()
|
|
99
|
+
await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
|
|
100
|
+
expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice' })
|
|
101
|
+
|
|
102
|
+
peopleRows[0].hash = 'HASH-v2-rotated0' // operator re-runs enrolment
|
|
103
|
+
|
|
104
|
+
expect(await resolveSession(db, 'sess-1', 1_000_000)).toBeNull()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('stops resolving once the person is unenrolled', async () => {
|
|
108
|
+
const { db, peopleRows } = fakeDb()
|
|
109
|
+
await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
|
|
110
|
+
peopleRows.length = 0
|
|
111
|
+
expect(await resolveSession(db, 'sess-1', 1_000_000)).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('readSessionCookie', () => {
|
|
116
|
+
it('reads the portal session cookie', () => {
|
|
117
|
+
expect(readSessionCookie('__Host-__portal_session=abc123')).toBe('abc123')
|
|
118
|
+
})
|
|
119
|
+
it('reads it from among other cookies', () => {
|
|
120
|
+
expect(readSessionCookie('foo=1; __Host-__portal_session=abc123; bar=2')).toBe('abc123')
|
|
121
|
+
})
|
|
122
|
+
it('is null when absent', () => {
|
|
123
|
+
expect(readSessionCookie('foo=1')).toBeNull()
|
|
124
|
+
})
|
|
125
|
+
it('is null when the header is absent', () => {
|
|
126
|
+
expect(readSessionCookie(null)).toBeNull()
|
|
127
|
+
})
|
|
128
|
+
it('does not match a lookalike cookie name', () => {
|
|
129
|
+
expect(readSessionCookie('x__Host-__portal_session=abc123')).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
it('uses the __Host- prefix, which forbids a Domain attribute', () => {
|
|
132
|
+
// Without the prefix, a cookie set on a sibling subdomain with
|
|
133
|
+
// Domain=.example.com reaches this host and can shadow the real session,
|
|
134
|
+
// so a victim uploads into the attacker's prefix. The prefix is the only
|
|
135
|
+
// thing browsers enforce that against, and we already meet its
|
|
136
|
+
// preconditions (Secure, Path=/, no Domain).
|
|
137
|
+
expect(SESSION_COOKIE.startsWith('__Host-')).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
it('keeps scanning past a valueless duplicate rather than returning null', () => {
|
|
140
|
+
// An attacker-planted empty cookie sorted first must not blank the real
|
|
141
|
+
// session — that is a persistent logout denial-of-service.
|
|
142
|
+
expect(readSessionCookie('__Host-__portal_session=; __Host-__portal_session=real')).toBe('real')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('authorizeKey — the isolation rule', () => {
|
|
147
|
+
it('allows a key under the owner prefix', () => {
|
|
148
|
+
expect(authorizeKey('alice', 'alice/invoice.pdf')).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('denies another owner prefix', () => {
|
|
152
|
+
expect(authorizeKey('alice', 'bob/invoice.pdf')).toBe(false)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('denies a traversal escape', () => {
|
|
156
|
+
expect(authorizeKey('alice', 'alice/../bob/invoice.pdf')).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('denies a prefix that merely starts with the owner id', () => {
|
|
160
|
+
// 'alice' must not authorize 'alice-evil/...': a startsWith check on the
|
|
161
|
+
// bare id would pass this. The separator is part of the rule.
|
|
162
|
+
expect(authorizeKey('alice', 'alice-evil/invoice.pdf')).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('denies a bare key with no prefix', () => {
|
|
166
|
+
expect(authorizeKey('alice', 'invoice.pdf')).toBe(false)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('denies an absolute-looking key', () => {
|
|
170
|
+
expect(authorizeKey('alice', '/alice/invoice.pdf')).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('denies an empty key', () => {
|
|
174
|
+
expect(authorizeKey('alice', '')).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// authorizeKey's correctness silently depends on ownerId containing no
|
|
178
|
+
// separator. If two people were ever enrolled as 'alice' and 'alice/2024',
|
|
179
|
+
// then 'alice/2024/x.pdf' starts with 'alice/' and alice reads the other
|
|
180
|
+
// person's files. Enrolment is an agent writing rows, so the invariant
|
|
181
|
+
// cannot rest on the skill's prose — enforce it at the gate.
|
|
182
|
+
it('denies when the ownerId itself contains a separator', () => {
|
|
183
|
+
expect(authorizeKey('alice/2024', 'alice/2024/invoice.pdf')).toBe(false)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('denies an ownerId outside [a-z0-9-], failing closed', () => {
|
|
187
|
+
for (const bad of ['alice/2024', 'Alice', 'alice..', 'alice bob', '../alice', 'alice%2f']) {
|
|
188
|
+
expect(authorizeKey(bad, `${bad}/invoice.pdf`)).toBe(false)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('still allows a well-formed ownerId', () => {
|
|
193
|
+
expect(authorizeKey('alice-smith-2', 'alice-smith-2/invoice.pdf')).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('ownerPrefix', () => {
|
|
198
|
+
it('is the owner id plus a separator', () => {
|
|
199
|
+
expect(ownerPrefix('alice')).toBe('alice/')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { processFiles } from '../../skills/data-portal/template/functions/api/files'
|
|
3
|
+
import { processDownload } from '../../skills/data-portal/template/functions/api/download'
|
|
4
|
+
import { processDelete } from '../../skills/data-portal/template/functions/api/delete'
|
|
5
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
6
|
+
|
|
7
|
+
// alice's session is 'sess-a'; bob owns 'bob/secret.pdf'.
|
|
8
|
+
function makeEnv() {
|
|
9
|
+
const manifest = [
|
|
10
|
+
{ fileId: 'f1', ownerId: 'alice', filename: 'a.pdf', objectKey: 'alice/a.pdf', size: 10 },
|
|
11
|
+
{ fileId: 'f2', ownerId: 'bob', filename: 'secret.pdf', objectKey: 'bob/secret.pdf', size: 20 },
|
|
12
|
+
]
|
|
13
|
+
const objects = new Map<string, string>([
|
|
14
|
+
['alice/a.pdf', 'A'],
|
|
15
|
+
['bob/secret.pdf', 'B'],
|
|
16
|
+
])
|
|
17
|
+
const deleted: string[] = []
|
|
18
|
+
const env = {
|
|
19
|
+
DB: {
|
|
20
|
+
prepare(query: string) {
|
|
21
|
+
let bound: unknown[] = []
|
|
22
|
+
const stmt = {
|
|
23
|
+
bind(...v: unknown[]) {
|
|
24
|
+
bound = v
|
|
25
|
+
return stmt
|
|
26
|
+
},
|
|
27
|
+
async run() {
|
|
28
|
+
if (/DELETE FROM manifest/i.test(query)) {
|
|
29
|
+
const i = manifest.findIndex((m) => m.objectKey === bound[0])
|
|
30
|
+
if (i >= 0) manifest.splice(i, 1)
|
|
31
|
+
}
|
|
32
|
+
return { meta: { changes: 1 } }
|
|
33
|
+
},
|
|
34
|
+
async all<T>() {
|
|
35
|
+
const owner = bound[0] as string
|
|
36
|
+
return { results: manifest.filter((m) => m.ownerId === owner) as T[] }
|
|
37
|
+
},
|
|
38
|
+
async first<T>() {
|
|
39
|
+
if (/FROM sessions/i.test(query)) {
|
|
40
|
+
const map: Record<string, string> = { 'sess-a': 'alice', 'sess-b': 'bob' }
|
|
41
|
+
const owner = map[bound[0] as string]
|
|
42
|
+
return (owner ? { ownerId: owner } : null) as T | null
|
|
43
|
+
}
|
|
44
|
+
return (manifest.find((m) => m.objectKey === bound[0]) ?? null) as T | null
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
return stmt
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
BUCKET: {
|
|
51
|
+
async get(key: string) {
|
|
52
|
+
const v = objects.get(key)
|
|
53
|
+
return v ? { body: null, size: v.length } : null
|
|
54
|
+
},
|
|
55
|
+
async delete(key: string) {
|
|
56
|
+
deleted.push(key)
|
|
57
|
+
objects.delete(key)
|
|
58
|
+
},
|
|
59
|
+
async put() {
|
|
60
|
+
return {}
|
|
61
|
+
},
|
|
62
|
+
async head(key: string) {
|
|
63
|
+
const v = objects.get(key)
|
|
64
|
+
return v ? { size: v.length } : null
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
} as unknown as PortalEnv
|
|
68
|
+
return { env, deleted, manifest, objects }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('processFiles', () => {
|
|
72
|
+
it("lists only the session owner's files", async () => {
|
|
73
|
+
const { env } = makeEnv()
|
|
74
|
+
const res = await processFiles('sess-a', env, () => {}, 1_000_000)
|
|
75
|
+
expect(res.status).toBe(200)
|
|
76
|
+
const files = res.payload.files as Array<{ objectKey: string }>
|
|
77
|
+
expect(files.map((f) => f.objectKey)).toEqual(['alice/a.pdf'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("never includes another owner's file", async () => {
|
|
81
|
+
const { env } = makeEnv()
|
|
82
|
+
const res = await processFiles('sess-a', env, () => {}, 1_000_000)
|
|
83
|
+
expect(JSON.stringify(res.payload)).not.toContain('bob')
|
|
84
|
+
expect(JSON.stringify(res.payload)).not.toContain('secret.pdf')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('denies an unauthenticated caller', async () => {
|
|
88
|
+
const { env } = makeEnv()
|
|
89
|
+
expect((await processFiles('', env, () => {}, 1_000_000)).status).toBe(401)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('processDownload — isolation', () => {
|
|
94
|
+
it("allows the owner's own key", async () => {
|
|
95
|
+
const { env } = makeEnv()
|
|
96
|
+
expect((await processDownload('sess-a', 'alice/a.pdf', env, () => {}, 1_000_000)).status).toBe(
|
|
97
|
+
200,
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("denies person A's session on person B's key", async () => {
|
|
102
|
+
const { env } = makeEnv()
|
|
103
|
+
const res = await processDownload('sess-a', 'bob/secret.pdf', env, () => {}, 1_000_000)
|
|
104
|
+
expect(res.status).toBe(403)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('denies a traversal escape', async () => {
|
|
108
|
+
const { env } = makeEnv()
|
|
109
|
+
const res = await processDownload('sess-a', 'alice/../bob/secret.pdf', env, () => {}, 1_000_000)
|
|
110
|
+
expect(res.status).toBe(403)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('denies an unauthenticated caller', async () => {
|
|
114
|
+
const { env } = makeEnv()
|
|
115
|
+
expect((await processDownload('', 'alice/a.pdf', env, () => {}, 1_000_000)).status).toBe(401)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('processDelete — isolation and both-sides', () => {
|
|
120
|
+
it('removes object and manifest row together', async () => {
|
|
121
|
+
const { env, deleted, manifest } = makeEnv()
|
|
122
|
+
const res = await processDelete('sess-a', 'alice/a.pdf', env, () => {}, () => 1_000_000)
|
|
123
|
+
expect(res.status).toBe(200)
|
|
124
|
+
expect(deleted).toEqual(['alice/a.pdf'])
|
|
125
|
+
expect(manifest.find((m) => m.objectKey === 'alice/a.pdf')).toBeUndefined()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("denies person A's session on person B's key, and deletes nothing", async () => {
|
|
129
|
+
const { env, deleted, manifest } = makeEnv()
|
|
130
|
+
const res = await processDelete('sess-a', 'bob/secret.pdf', env, () => {}, () => 1_000_000)
|
|
131
|
+
expect(res.status).toBe(403)
|
|
132
|
+
expect(deleted).toEqual([])
|
|
133
|
+
expect(manifest.find((m) => m.objectKey === 'bob/secret.pdf')).toBeDefined()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('denies an unauthenticated caller', async () => {
|
|
137
|
+
const { env, deleted } = makeEnv()
|
|
138
|
+
expect((await processDelete('', 'alice/a.pdf', env, () => {}, () => 1_000_000)).status).toBe(401)
|
|
139
|
+
expect(deleted).toEqual([])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('emits a per-step lifeline, not one bare line', async () => {
|
|
143
|
+
const { env } = makeEnv()
|
|
144
|
+
const lines: string[] = []
|
|
145
|
+
await processDelete('sess-a', 'alice/a.pdf', env, (l) => lines.push(l), () => 1_000_000)
|
|
146
|
+
const joined = lines.join('\n')
|
|
147
|
+
expect(joined).toContain('op=r2-delete')
|
|
148
|
+
expect(joined).toContain('op=manifest-delete')
|
|
149
|
+
expect(joined).toMatch(/op=complete .*result=ok objectPresent=false rowPresent=false/)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('reports the R2 failure instead of throwing out of the handler', async () => {
|
|
153
|
+
// Unwrapped, a throw here escapes to a bare Pages 500 with zero
|
|
154
|
+
// [data-portal] lines — the lifeline goes silent exactly when it is needed.
|
|
155
|
+
const { env, manifest } = makeEnv()
|
|
156
|
+
;(env.BUCKET as unknown as { delete: () => Promise<void> }).delete = () => {
|
|
157
|
+
throw new Error('r2 unavailable')
|
|
158
|
+
}
|
|
159
|
+
const lines: string[] = []
|
|
160
|
+
const res = await processDelete('sess-a', 'alice/a.pdf', env, (l) => lines.push(l), () => 1_000_000)
|
|
161
|
+
expect(res.status).toBe(502)
|
|
162
|
+
expect(lines.join('\n')).toContain('op=r2-delete')
|
|
163
|
+
expect(lines.join('\n')).toContain('result=failed')
|
|
164
|
+
// The row must survive an object that was never deleted.
|
|
165
|
+
expect(manifest.find((m) => m.objectKey === 'alice/a.pdf')).toBeDefined()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('derives result from the post-condition rather than hardcoding ok', async () => {
|
|
169
|
+
// A delete that left the object behind must not print result=ok next to
|
|
170
|
+
// objectPresent=true — a self-contradicting line.
|
|
171
|
+
const { env } = makeEnv()
|
|
172
|
+
;(env.BUCKET as unknown as { delete: () => Promise<void> }).delete = async () => {}
|
|
173
|
+
const lines: string[] = []
|
|
174
|
+
const res = await processDelete('sess-a', 'alice/a.pdf', env, (l) => lines.push(l), () => 1_000_000)
|
|
175
|
+
expect(res.status).toBe(500)
|
|
176
|
+
expect(lines.join('\n')).toMatch(/op=complete .*result=incomplete objectPresent=true/)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { processBooking } from '../../skills/calendar-site/template/functions/api/book'
|
|
3
|
+
|
|
4
|
+
// The harness must reach code inside a skill's template/ directory, which is
|
|
5
|
+
// part of no tsc project. This pins that reach; if it breaks, every template
|
|
6
|
+
// test below it is unreachable.
|
|
7
|
+
describe('harness reaches skill template functions', () => {
|
|
8
|
+
it('imports and runs a template Pages Function against an injected fake D1', async () => {
|
|
9
|
+
const lines: string[] = []
|
|
10
|
+
const fakeDb = {
|
|
11
|
+
prepare: () => ({
|
|
12
|
+
bind() {
|
|
13
|
+
return this
|
|
14
|
+
},
|
|
15
|
+
run: async () => ({ meta: { changes: 1 } }),
|
|
16
|
+
}),
|
|
17
|
+
}
|
|
18
|
+
const res = await processBooking(
|
|
19
|
+
{
|
|
20
|
+
slotStart: '2026-01-01T09:00:00Z',
|
|
21
|
+
slotEnd: '2026-01-01T09:30:00Z',
|
|
22
|
+
name: 'harness',
|
|
23
|
+
email: 'h@e.com',
|
|
24
|
+
} as never,
|
|
25
|
+
{ DB: fakeDb } as never,
|
|
26
|
+
(l) => lines.push(l),
|
|
27
|
+
() => 'fixed-id',
|
|
28
|
+
)
|
|
29
|
+
expect(res.status).toBe(200)
|
|
30
|
+
expect(lines.join('\n')).toContain('[calendar-booking]')
|
|
31
|
+
})
|
|
32
|
+
})
|