@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.
Files changed (145) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +116 -6
  3. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  4. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +20 -0
  5. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -1
  6. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts +2 -0
  7. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts.map +1 -0
  8. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js +41 -0
  9. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js.map +1 -0
  10. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +1 -0
  11. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  12. package/payload/platform/lib/storage-broker/dist/cf-exec.js +116 -48
  13. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  14. package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
  15. package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
  16. package/payload/platform/lib/storage-broker/dist/index.js +1 -0
  17. package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
  18. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +27 -0
  19. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/object-limits.js +27 -7
  21. package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts +102 -0
  23. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts.map +1 -0
  24. package/payload/platform/lib/storage-broker/dist/resource-limits.js +105 -0
  25. package/payload/platform/lib/storage-broker/dist/resource-limits.js.map +1 -0
  26. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +130 -7
  27. package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +30 -0
  28. package/payload/platform/lib/storage-broker/src/__tests__/resource-limits.test.ts +49 -0
  29. package/payload/platform/lib/storage-broker/src/cf-exec.ts +129 -56
  30. package/payload/platform/lib/storage-broker/src/index.ts +1 -0
  31. package/payload/platform/lib/storage-broker/src/object-limits.ts +26 -6
  32. package/payload/platform/lib/storage-broker/src/resource-limits.ts +105 -0
  33. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -4
  34. package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -1
  35. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +206 -0
  36. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +201 -0
  37. package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +178 -0
  38. package/payload/platform/plugins/cloudflare/mcp/__tests__/harness.test.ts +32 -0
  39. package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +74 -0
  40. package/payload/platform/plugins/cloudflare/mcp/__tests__/ratelimit.test.ts +77 -0
  41. package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +46 -0
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +55 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +255 -0
  44. package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
  45. package/payload/platform/plugins/cloudflare/references/r2-object-storage.md +46 -0
  46. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +123 -0
  47. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +32 -0
  48. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/log.ts +12 -0
  49. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.ts +73 -0
  50. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +55 -0
  51. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +85 -0
  52. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +44 -0
  53. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +113 -0
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +89 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +70 -0
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +41 -0
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +151 -0
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/index.html +42 -0
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.css +113 -0
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.js +155 -0
  61. package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +64 -0
  62. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +21 -0
  63. package/payload/platform/plugins/cloudflare/skills/site-deploy/SKILL.md +8 -0
  64. package/payload/platform/plugins/docs/references/outlook-guide.md +3 -3
  65. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +1 -0
  66. package/payload/platform/plugins/filesystem/PLUGIN.md +0 -1
  67. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts +14 -10
  68. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts.map +1 -1
  69. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js +14 -10
  70. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js.map +1 -1
  71. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  72. package/payload/platform/plugins/outlook/PLUGIN.md +3 -5
  73. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts +2 -0
  74. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts.map +1 -0
  75. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js +75 -0
  76. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js.map +1 -0
  77. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts +2 -0
  78. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts.map +1 -0
  79. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js +187 -0
  80. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js.map +1 -0
  81. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +1 -1
  82. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
  83. package/payload/platform/plugins/outlook/mcp/dist/index.js +3 -2
  84. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  85. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts +43 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js +65 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts +65 -0
  90. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts.map +1 -0
  91. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js +99 -0
  92. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js.map +1 -0
  93. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +6 -1
  94. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
  95. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
  96. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +42 -22
  97. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
  98. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +59 -23
  99. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
  100. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts +15 -16
  101. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
  102. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +8 -41
  103. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
  104. package/payload/platform/plugins/outlook/references/auth.md +1 -1
  105. package/payload/platform/plugins/outlook/references/graph-surfaces.md +7 -3
  106. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +1 -1
  107. package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -1
  108. package/payload/platform/plugins/voice-mirror/PLUGIN.md +19 -7
  109. package/payload/platform/plugins/voice-mirror/mcp/dist/index.d.ts.map +1 -1
  110. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +35 -16
  111. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
  112. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts +31 -0
  113. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts.map +1 -0
  114. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js +87 -0
  115. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js.map +1 -0
  116. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  117. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +13 -3
  118. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  119. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
  120. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js +2 -1
  121. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
  122. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  123. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js +2 -1
  124. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  125. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts +2 -2
  126. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  127. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js +12 -3
  128. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js.map +1 -1
  129. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +334 -0
  130. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +13 -0
  131. package/payload/platform/scripts/voice-author-key-audit.sh +155 -0
  132. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts +17 -0
  133. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts.map +1 -0
  134. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js +88 -0
  135. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js.map +1 -0
  136. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +0 -1
  137. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  138. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +34 -23
  139. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  140. package/payload/server/{chunk-Q6W4U6HL.js → chunk-JXWFVE5X.js} +83 -39
  141. package/payload/server/server.js +213 -38
  142. package/payload/server/{src-3I2RYZFB.js → src-4F37OHLK.js} +11 -1
  143. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.d.ts +0 -2
  144. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js +0 -215
  145. 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
+ })