@rubytech/create-maxy-code 0.1.459 → 0.1.460
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/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/mcp/__tests__/auth-route.test.ts +206 -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 +74 -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 +123 -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.ts +73 -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 +113 -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 +64 -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 +13 -3
- 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-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 +334 -0
- package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +13 -0
- package/payload/platform/scripts/voice-author-key-audit.sh +155 -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-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 +34 -23
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/server/{chunk-Q6W4U6HL.js → chunk-JXWFVE5X.js} +83 -39
- package/payload/server/server.js +213 -38
- 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,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
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
PBKDF2_ITERATIONS,
|
|
4
|
+
constantTimeEqual,
|
|
5
|
+
hashPasscode,
|
|
6
|
+
verifyPasscode,
|
|
7
|
+
randomSaltHex,
|
|
8
|
+
} from '../../skills/data-portal/template/functions/api/_lib/passcode'
|
|
9
|
+
|
|
10
|
+
const SALT = '000102030405060708090a0b0c0d0e0f'
|
|
11
|
+
|
|
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)
|
|
15
|
+
})
|
|
16
|
+
it('is false when a byte differs', () => {
|
|
17
|
+
expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
it('is false for different lengths, without throwing', () => {
|
|
20
|
+
expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false)
|
|
21
|
+
})
|
|
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)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
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}$/)
|
|
37
|
+
})
|
|
38
|
+
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)
|
|
42
|
+
})
|
|
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)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('verifyPasscode', () => {
|
|
51
|
+
it('accepts the right passcode', async () => {
|
|
52
|
+
const h = await hashPasscode('hunter2', SALT, 1000)
|
|
53
|
+
expect(await verifyPasscode('hunter2', SALT, h, 1000)).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
it('rejects the wrong passcode', async () => {
|
|
56
|
+
const h = await hashPasscode('hunter2', SALT, 1000)
|
|
57
|
+
expect(await verifyPasscode('hunter3', SALT, h, 1000)).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
it('rejects a malformed stored hash without throwing', async () => {
|
|
60
|
+
expect(await verifyPasscode('hunter2', SALT, 'not-hex', 1000)).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('randomSaltHex', () => {
|
|
65
|
+
it('renders bytes as lowercase hex', () => {
|
|
66
|
+
expect(randomSaltHex(new Uint8Array([0, 1, 255]))).toBe('0001ff')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('PBKDF2_ITERATIONS', () => {
|
|
71
|
+
it('is at least the OWASP floor for PBKDF2-SHA256', () => {
|
|
72
|
+
expect(PBKDF2_ITERATIONS).toBeGreaterThanOrEqual(600_000)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
RATE_WINDOW_MS,
|
|
4
|
+
RATE_MAX_ATTEMPTS,
|
|
5
|
+
checkAndRecordAttempt,
|
|
6
|
+
} from '../../skills/data-portal/template/functions/api/_lib/ratelimit'
|
|
7
|
+
import type { D1Database } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
8
|
+
|
|
9
|
+
// Minimal D1 fake: one row per (scope, windowStart), matching the schema's
|
|
10
|
+
// unique index. The INSERT ... ON CONFLICT DO UPDATE is modelled as an upsert.
|
|
11
|
+
function fakeDb(seed: Array<{ scope: string; windowStart: number; count: number }> = []) {
|
|
12
|
+
const rows = [...seed]
|
|
13
|
+
const db: D1Database = {
|
|
14
|
+
prepare(query: string) {
|
|
15
|
+
let bound: unknown[] = []
|
|
16
|
+
const stmt = {
|
|
17
|
+
bind(...v: unknown[]) {
|
|
18
|
+
bound = v
|
|
19
|
+
return stmt
|
|
20
|
+
},
|
|
21
|
+
async run() {
|
|
22
|
+
if (/INSERT/i.test(query)) {
|
|
23
|
+
const [scope, windowStart] = bound as [string, number]
|
|
24
|
+
const hit = rows.find((r) => r.scope === scope && r.windowStart === windowStart)
|
|
25
|
+
if (hit) hit.count++
|
|
26
|
+
else rows.push({ scope, windowStart, count: 1 })
|
|
27
|
+
}
|
|
28
|
+
return { meta: { changes: 1 } }
|
|
29
|
+
},
|
|
30
|
+
async all() {
|
|
31
|
+
return { results: [] }
|
|
32
|
+
},
|
|
33
|
+
async first<T>() {
|
|
34
|
+
const [scope, windowStart] = bound as [string, number]
|
|
35
|
+
const hit = rows.find((r) => r.scope === scope && r.windowStart === windowStart)
|
|
36
|
+
return (hit ? { count: hit.count } : null) as T | null
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
return stmt
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
return { db, rows }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('checkAndRecordAttempt', () => {
|
|
46
|
+
it('allows the first attempt in a window', async () => {
|
|
47
|
+
const { db } = fakeDb()
|
|
48
|
+
expect(await checkAndRecordAttempt(db, 'alice', 1_000_000)).toEqual({ allowed: true, count: 1 })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('allows up to the cap', async () => {
|
|
52
|
+
const w = Math.floor(1_000_000 / RATE_WINDOW_MS) * RATE_WINDOW_MS
|
|
53
|
+
const { db } = fakeDb([{ scope: 'alice', windowStart: w, count: RATE_MAX_ATTEMPTS - 1 }])
|
|
54
|
+
expect((await checkAndRecordAttempt(db, 'alice', 1_000_000)).allowed).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('denies past the cap', async () => {
|
|
58
|
+
const w = Math.floor(1_000_000 / RATE_WINDOW_MS) * RATE_WINDOW_MS
|
|
59
|
+
const { db } = fakeDb([{ scope: 'alice', windowStart: w, count: RATE_MAX_ATTEMPTS }])
|
|
60
|
+
const res = await checkAndRecordAttempt(db, 'alice', 1_000_000)
|
|
61
|
+
expect(res.allowed).toBe(false)
|
|
62
|
+
expect(res.count).toBeGreaterThan(RATE_MAX_ATTEMPTS)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('starts a fresh count in the next window', async () => {
|
|
66
|
+
const w = Math.floor(1_000_000 / RATE_WINDOW_MS) * RATE_WINDOW_MS
|
|
67
|
+
const { db } = fakeDb([{ scope: 'alice', windowStart: w, count: RATE_MAX_ATTEMPTS }])
|
|
68
|
+
const later = 1_000_000 + RATE_WINDOW_MS
|
|
69
|
+
expect((await checkAndRecordAttempt(db, 'alice', later)).allowed).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('scopes counts independently per person', async () => {
|
|
73
|
+
const w = Math.floor(1_000_000 / RATE_WINDOW_MS) * RATE_WINDOW_MS
|
|
74
|
+
const { db } = fakeDb([{ scope: 'alice', windowStart: w, count: RATE_MAX_ATTEMPTS }])
|
|
75
|
+
expect((await checkAndRecordAttempt(db, 'bob', 1_000_000)).allowed).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const skill = () => readFileSync(join(__dirname, '../../skills/data-portal/SKILL.md'), 'utf8')
|
|
6
|
+
|
|
7
|
+
describe('data-portal SKILL.md', () => {
|
|
8
|
+
it('opens with frontmatter carrying name and description', () => {
|
|
9
|
+
// Without these the skill is invisible to the available-skills reminder and
|
|
10
|
+
// to skill-search. The defect is silent: no error, no log line.
|
|
11
|
+
const text = skill()
|
|
12
|
+
expect(text.startsWith('---\n')).toBe(true)
|
|
13
|
+
const fm = text.slice(4, text.indexOf('\n---', 4))
|
|
14
|
+
expect(fm).toMatch(/^name:\s*data-portal\s*$/m)
|
|
15
|
+
expect(fm).toMatch(/^description:\s*\S/m)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('declares the plugin-read convention (check-plugin-references.mjs)', () => {
|
|
19
|
+
expect(skill()).toMatch(/plugin-read/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('records the two-session provisioning sequence', () => {
|
|
23
|
+
// The load-bearing sequencing decision: the broker registers a bucket to
|
|
24
|
+
// whoever creates it, ON CREATE and never reassigned, so the client
|
|
25
|
+
// account must create its own or its ingestion sweep is denied forever.
|
|
26
|
+
const text = skill()
|
|
27
|
+
expect(text).toMatch(/storage-r2-bucket-create/)
|
|
28
|
+
expect(text).toMatch(/client|sub-account/i)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('names the passcode discipline', () => {
|
|
32
|
+
expect(skill()).toMatch(/once/i)
|
|
33
|
+
expect(skill()).toMatch(/never.*plaintext|plaintext.*never/i)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('routes store naming through cf-store-name.sh rather than hand-building', () => {
|
|
37
|
+
// The script is already tested (bin/__tests__/cf-store-name.test.sh). What
|
|
38
|
+
// is untested is that this skill defers to it — a hand-built name would
|
|
39
|
+
// break the "name identifies owner and purpose" contract silently.
|
|
40
|
+
expect(skill()).toMatch(/cf-store-name\.sh/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('leaves no unfilled placeholder marker in its own prose', () => {
|
|
44
|
+
expect(skill()).not.toMatch(/\bTBD\b|\bTODO\b/)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const TEMPLATE = join(__dirname, '../../skills/data-portal/template')
|
|
6
|
+
const wrangler = () => readFileSync(join(TEMPLATE, 'wrangler.toml'), 'utf8')
|
|
7
|
+
const schema = () => readFileSync(join(TEMPLATE, 'schema.sql'), 'utf8')
|
|
8
|
+
|
|
9
|
+
describe('wrangler.toml', () => {
|
|
10
|
+
it('binds D1 as DB', () => {
|
|
11
|
+
expect(wrangler()).toMatch(/\[\[d1_databases\]\]/)
|
|
12
|
+
expect(wrangler()).toMatch(/binding\s*=\s*"DB"/)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('binds R2 as BUCKET — the resource class the plugin has never bound', () => {
|
|
16
|
+
expect(wrangler()).toMatch(/\[\[r2_buckets\]\]/)
|
|
17
|
+
expect(wrangler()).toMatch(/binding\s*=\s*"BUCKET"/)
|
|
18
|
+
expect(wrangler()).toMatch(/bucket_name\s*=\s*"__R2_BUCKET_NAME__"/)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('declares every placeholder it uses in its header comment', () => {
|
|
22
|
+
const text = wrangler()
|
|
23
|
+
const used = [...text.matchAll(/__[A-Z0-9_]+__/g)].map((m) => m[0])
|
|
24
|
+
for (const p of new Set(used)) {
|
|
25
|
+
// Each placeholder appears at least twice: once documented, once used.
|
|
26
|
+
expect(text.split(p).length - 1).toBeGreaterThanOrEqual(2)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('schema.sql', () => {
|
|
32
|
+
it('is re-appliable — every create is IF NOT EXISTS', () => {
|
|
33
|
+
const creates = [...schema().matchAll(/CREATE (TABLE|INDEX|UNIQUE INDEX)/gi)]
|
|
34
|
+
expect(creates.length).toBeGreaterThan(0)
|
|
35
|
+
expect(schema()).not.toMatch(/CREATE TABLE (?!IF NOT EXISTS)/i)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('has the four tables the portal needs', () => {
|
|
39
|
+
for (const t of ['people', 'sessions', 'manifest', 'auth_attempts']) {
|
|
40
|
+
expect(schema()).toMatch(new RegExp(`CREATE TABLE IF NOT EXISTS ${t}`, 'i'))
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('defaults manifest.ingested to 0', () => {
|
|
45
|
+
expect(schema()).toMatch(/ingested\s+INTEGER\s+NOT NULL\s+DEFAULT 0/i)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('stores no plaintext passcode column', () => {
|
|
49
|
+
expect(schema()).not.toMatch(/passcode\s+TEXT/i)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('enforces one attempt row per (scope, window)', () => {
|
|
53
|
+
expect(schema()).toMatch(/CREATE UNIQUE INDEX IF NOT EXISTS auth_attempts_scope_window/i)
|
|
54
|
+
})
|
|
55
|
+
})
|