@rubytech/create-maxy-code 0.1.458 → 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 +298 -3
- 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.d.ts +2 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +79 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -0
- 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 +13 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +165 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/house-credential.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/house-credential.js +4 -2
- package/payload/platform/lib/storage-broker/dist/house-credential.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.d.ts +2 -0
- package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.js +2 -0
- package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +80 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.js +92 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -0
- 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 +334 -5
- package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +106 -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 +221 -6
- package/payload/platform/lib/storage-broker/src/house-credential.ts +4 -2
- package/payload/platform/lib/storage-broker/src/index.ts +2 -0
- package/payload/platform/lib/storage-broker/src/object-limits.ts +95 -0
- package/payload/platform/lib/storage-broker/src/resource-limits.ts +105 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +7 -6
- package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -1
- package/payload/platform/plugins/cloudflare/bin/cf-token.sh +4 -3
- 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/api.md +1 -1
- 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 +5 -5
- 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__/attach.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.js +138 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.js.map +1 -0
- 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-attachments.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.js +121 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.js +102 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.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/__tests__/mail-send-attachments.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.js +209 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/index.js +12 -8
- package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.d.ts +37 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.js +79 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.js.map +1 -0
- 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/message.d.ts +8 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.js +4 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +36 -8
- 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 +40 -13
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.d.ts +9 -5
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.js +12 -8
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +49 -4
- 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 +82 -13
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts +8 -2
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +13 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.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/mcp/dist/tools/mail-reply.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js +3 -20
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts +27 -3
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js +53 -8
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js.map +1 -1
- package/payload/platform/plugins/outlook/references/auth.md +1 -1
- package/payload/platform/plugins/outlook/references/graph-surfaces.md +33 -6
- package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +22 -5
- package/payload/platform/plugins/storage-broker/PLUGIN.md +41 -2
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.d.ts +2 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.js +85 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.js.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.d.ts +2 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.js +77 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.js.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts +12 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js +107 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/index.js +86 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.d.ts +11 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.js +35 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.js.map +1 -0
- 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/logs-read-jsonl.test.sh +118 -0
- package/payload/platform/scripts/logs-read.sh +63 -9
- package/payload/platform/scripts/voice-author-key-audit.sh +155 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +4 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- 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-64FGYKNZ.js → chunk-JXWFVE5X.js} +148 -0
- package/payload/server/{chunk-F4D35LKL.js → chunk-POBXIHOF.js} +34 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/server.js +412 -37
- package/payload/server/{src-S7C4P6TT.js → src-4F37OHLK.js} +19 -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,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
|
+
})
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { processUpload } from '../../skills/data-portal/template/functions/api/upload'
|
|
3
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
4
|
+
|
|
5
|
+
// The fake must model the constraints the schema actually declares, not just
|
|
6
|
+
// the happy path. `objectKey TEXT UNIQUE` is what makes a re-upload an upsert
|
|
7
|
+
// rather than a second row; a fake that accepts duplicates would let the
|
|
8
|
+
// "exactly one manifest row" assertion pass while the real D1 threw.
|
|
9
|
+
function makeEnv() {
|
|
10
|
+
const manifestRows: unknown[][] = []
|
|
11
|
+
const objects = new Map<string, Uint8Array>()
|
|
12
|
+
const env = {
|
|
13
|
+
DB: {
|
|
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 INTO manifest/i.test(query)) {
|
|
23
|
+
const objectKey = bound[3] as string
|
|
24
|
+
const existing = manifestRows.findIndex((r) => r[3] === objectKey)
|
|
25
|
+
if (existing >= 0) {
|
|
26
|
+
if (!/ON CONFLICT\s*\(\s*objectKey\s*\)\s*DO UPDATE/i.test(query)) {
|
|
27
|
+
throw new Error('UNIQUE constraint failed: manifest.objectKey')
|
|
28
|
+
}
|
|
29
|
+
manifestRows[existing] = bound
|
|
30
|
+
} else {
|
|
31
|
+
manifestRows.push(bound)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { meta: { changes: 1 } }
|
|
35
|
+
},
|
|
36
|
+
async all() {
|
|
37
|
+
return { results: [] }
|
|
38
|
+
},
|
|
39
|
+
async first<T>() {
|
|
40
|
+
if (/FROM sessions/i.test(query)) {
|
|
41
|
+
return (bound[0] === 'sess-a' ? { ownerId: 'alice' } : null) as T | null
|
|
42
|
+
}
|
|
43
|
+
if (/FROM manifest/i.test(query)) {
|
|
44
|
+
const key = bound[0] as string
|
|
45
|
+
return (objects.has(key) ? { fileId: 'x' } : null) as T | null
|
|
46
|
+
}
|
|
47
|
+
return null as T | null
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
return stmt
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
BUCKET: {
|
|
54
|
+
async put(key: string, body: Uint8Array) {
|
|
55
|
+
objects.set(key, body)
|
|
56
|
+
return {}
|
|
57
|
+
},
|
|
58
|
+
async get(key: string) {
|
|
59
|
+
const v = objects.get(key)
|
|
60
|
+
return v ? { body: null, size: v.length } : null
|
|
61
|
+
},
|
|
62
|
+
async head(key: string) {
|
|
63
|
+
const v = objects.get(key)
|
|
64
|
+
return v ? { size: v.length } : null
|
|
65
|
+
},
|
|
66
|
+
async delete(key: string) {
|
|
67
|
+
objects.delete(key)
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
} as unknown as PortalEnv
|
|
71
|
+
return { env, manifestRows, objects }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('processUpload', () => {
|
|
75
|
+
it('writes the object under the owner prefix and exactly one manifest row', async () => {
|
|
76
|
+
const { env, manifestRows, objects } = makeEnv()
|
|
77
|
+
const lines: string[] = []
|
|
78
|
+
const res = await processUpload(
|
|
79
|
+
'sess-a',
|
|
80
|
+
'invoice.pdf',
|
|
81
|
+
new Uint8Array([1, 2, 3]),
|
|
82
|
+
env,
|
|
83
|
+
(l) => lines.push(l),
|
|
84
|
+
() => 'up-1',
|
|
85
|
+
() => 1_000_000,
|
|
86
|
+
)
|
|
87
|
+
expect(res.status).toBe(200)
|
|
88
|
+
expect([...objects.keys()]).toEqual(['alice/invoice.pdf'])
|
|
89
|
+
expect(manifestRows.length).toBe(1)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('writes the manifest row with ingested=0', async () => {
|
|
93
|
+
const { env, manifestRows } = makeEnv()
|
|
94
|
+
await processUpload(
|
|
95
|
+
'sess-a',
|
|
96
|
+
'invoice.pdf',
|
|
97
|
+
new Uint8Array([1]),
|
|
98
|
+
env,
|
|
99
|
+
() => {},
|
|
100
|
+
() => 'up-1',
|
|
101
|
+
() => 1_000_000,
|
|
102
|
+
)
|
|
103
|
+
// Column order: fileId, ownerId, filename, objectKey, size, uploadedAt, ingested
|
|
104
|
+
expect(manifestRows[0][6]).toBe(0)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('emits the full lifeline correlated by uploadId, ending in a verified post-condition', async () => {
|
|
108
|
+
const { env } = makeEnv()
|
|
109
|
+
const lines: string[] = []
|
|
110
|
+
await processUpload(
|
|
111
|
+
'sess-a',
|
|
112
|
+
'invoice.pdf',
|
|
113
|
+
new Uint8Array([1]),
|
|
114
|
+
env,
|
|
115
|
+
(l) => lines.push(l),
|
|
116
|
+
() => 'up-1',
|
|
117
|
+
() => 1_000_000,
|
|
118
|
+
)
|
|
119
|
+
const joined = lines.join('\n')
|
|
120
|
+
expect(joined).toContain('op=request uploadId=up-1')
|
|
121
|
+
expect(joined).toContain('op=r2-put uploadId=up-1')
|
|
122
|
+
expect(joined).toContain('op=manifest-write uploadId=up-1')
|
|
123
|
+
expect(joined).toMatch(/op=complete uploadId=up-1 .*objectPresent=true rowPresent=true/)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('denies an unauthenticated caller and writes nothing', async () => {
|
|
127
|
+
const { env, manifestRows, objects } = makeEnv()
|
|
128
|
+
const res = await processUpload(
|
|
129
|
+
'',
|
|
130
|
+
'invoice.pdf',
|
|
131
|
+
new Uint8Array([1]),
|
|
132
|
+
env,
|
|
133
|
+
() => {},
|
|
134
|
+
() => 'up-1',
|
|
135
|
+
() => 1_000_000,
|
|
136
|
+
)
|
|
137
|
+
expect(res.status).toBe(401)
|
|
138
|
+
expect(objects.size).toBe(0)
|
|
139
|
+
expect(manifestRows.length).toBe(0)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('rejects a filename that escapes the owner prefix, and writes nothing', async () => {
|
|
143
|
+
const { env, objects, manifestRows } = makeEnv()
|
|
144
|
+
const res = await processUpload(
|
|
145
|
+
'sess-a',
|
|
146
|
+
'../bob/evil.pdf',
|
|
147
|
+
new Uint8Array([1]),
|
|
148
|
+
env,
|
|
149
|
+
() => {},
|
|
150
|
+
() => 'up-1',
|
|
151
|
+
() => 1_000_000,
|
|
152
|
+
)
|
|
153
|
+
expect(res.status).toBe(400)
|
|
154
|
+
expect(objects.size).toBe(0)
|
|
155
|
+
expect(manifestRows.length).toBe(0)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('rejects an empty filename', async () => {
|
|
159
|
+
const { env } = makeEnv()
|
|
160
|
+
expect(
|
|
161
|
+
(
|
|
162
|
+
await processUpload(
|
|
163
|
+
'sess-a',
|
|
164
|
+
'',
|
|
165
|
+
new Uint8Array([1]),
|
|
166
|
+
env,
|
|
167
|
+
() => {},
|
|
168
|
+
() => 'up-1',
|
|
169
|
+
() => 1_000_000,
|
|
170
|
+
)
|
|
171
|
+
).status,
|
|
172
|
+
).toBe(400)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('caps the upload size and says so, rather than OOMing the isolate silently', async () => {
|
|
176
|
+
const { env, objects, manifestRows } = makeEnv()
|
|
177
|
+
const lines: string[] = []
|
|
178
|
+
const tooBig = new Uint8Array(25 * 1024 * 1024 + 1)
|
|
179
|
+
const res = await processUpload(
|
|
180
|
+
'sess-a',
|
|
181
|
+
'huge.bin',
|
|
182
|
+
tooBig,
|
|
183
|
+
env,
|
|
184
|
+
(l) => lines.push(l),
|
|
185
|
+
() => 'up-1',
|
|
186
|
+
() => 1_000_000,
|
|
187
|
+
)
|
|
188
|
+
expect(res.status).toBe(413)
|
|
189
|
+
expect(objects.size).toBe(0)
|
|
190
|
+
expect(manifestRows.length).toBe(0)
|
|
191
|
+
expect(lines.join('\n')).toContain('result=too-large')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('measures elapsed time rather than printing a fabricated zero', async () => {
|
|
195
|
+
const { env } = makeEnv()
|
|
196
|
+
const lines: string[] = []
|
|
197
|
+
let t = 1_000_000
|
|
198
|
+
await processUpload(
|
|
199
|
+
'sess-a',
|
|
200
|
+
'invoice.pdf',
|
|
201
|
+
new Uint8Array([1]),
|
|
202
|
+
env,
|
|
203
|
+
(l) => lines.push(l),
|
|
204
|
+
() => 'up-1',
|
|
205
|
+
() => (t += 5),
|
|
206
|
+
)
|
|
207
|
+
// A hardcoded ms=0 reads to an operator as "took no time", not "was never
|
|
208
|
+
// measured". With a real clock the value moves.
|
|
209
|
+
expect(lines.join('\n')).toMatch(/op=complete uploadId=up-1 ms=([1-9]\d*)/)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// R2 overwrites an existing key unconditionally, so a re-upload of the same
|
|
213
|
+
// filename REPLACES the object whatever the manifest does. The manifest must
|
|
214
|
+
// therefore replace its row too, or it ends up describing a file that no
|
|
215
|
+
// longer exists. Re-sending a corrected invoice.pdf is the single most
|
|
216
|
+
// ordinary thing this portal will ever be asked to do.
|
|
217
|
+
describe('re-uploading the same filename', () => {
|
|
218
|
+
it('replaces the object, keeps exactly one row, and succeeds', async () => {
|
|
219
|
+
const { env, manifestRows, objects } = makeEnv()
|
|
220
|
+
await processUpload('sess-a', 'invoice.pdf', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
|
|
221
|
+
const second = await processUpload(
|
|
222
|
+
'sess-a',
|
|
223
|
+
'invoice.pdf',
|
|
224
|
+
new Uint8Array([9, 9, 9]),
|
|
225
|
+
env,
|
|
226
|
+
() => {},
|
|
227
|
+
() => 'up-2',
|
|
228
|
+
() => 2_000,
|
|
229
|
+
)
|
|
230
|
+
expect(second.status).toBe(200)
|
|
231
|
+
expect(manifestRows.length).toBe(1)
|
|
232
|
+
expect(objects.get('alice/invoice.pdf')?.length).toBe(3)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('updates the row to describe the new object, not the old one', async () => {
|
|
236
|
+
const { env, manifestRows } = makeEnv()
|
|
237
|
+
await processUpload('sess-a', 'invoice.pdf', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
|
|
238
|
+
await processUpload('sess-a', 'invoice.pdf', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000)
|
|
239
|
+
// Column order: fileId, ownerId, filename, objectKey, size, uploadedAt, ingested
|
|
240
|
+
expect(manifestRows[0][4]).toBe(3)
|
|
241
|
+
expect(manifestRows[0][5]).toBe(new Date(2_000).toISOString())
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('re-arms ingestion by resetting ingested to 0', async () => {
|
|
245
|
+
// The load-bearing part. If the first file was already swept to
|
|
246
|
+
// ingested=1, a replacement that left the flag set would never be
|
|
247
|
+
// ingested, and nothing would ever say so.
|
|
248
|
+
const { env, manifestRows } = makeEnv()
|
|
249
|
+
await processUpload('sess-a', 'invoice.pdf', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
|
|
250
|
+
manifestRows[0][6] = 1 // the device-side sweep marks it ingested
|
|
251
|
+
await processUpload('sess-a', 'invoice.pdf', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000)
|
|
252
|
+
expect(manifestRows[0][6]).toBe(0)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
})
|
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "tsc",
|
|
9
|
-
"start": "node dist/index.js"
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"typecheck:templates": "tsc --noEmit --strict --target es2022 --module esnext --moduleResolution bundler --lib es2022,dom ../skills/*/template/functions/api/*.ts ../skills/*/template/functions/api/_lib/*.ts"
|
|
10
12
|
},
|
|
11
13
|
"dependencies": {
|
|
12
14
|
"@modelcontextprotocol/sdk": "^1.12.1"
|
|
13
15
|
},
|
|
14
16
|
"devDependencies": {
|
|
15
17
|
"@types/node": "^22.0.0",
|
|
16
|
-
"typescript": "^5.7.0"
|
|
18
|
+
"typescript": "^5.7.0",
|
|
19
|
+
"vitest": "^4.1.10"
|
|
17
20
|
}
|
|
18
21
|
}
|
|
@@ -53,7 +53,7 @@ chmod 600 "${SECRETS_DIR}/cloudflare.env"
|
|
|
53
53
|
|
|
54
54
|
### Multi-tenant installs — the master lives house-only
|
|
55
55
|
|
|
56
|
-
The account-scoped storage above is correct for a single-tenant install (one Cloudflare account, one client). On a **multi-client install** (many client sub-accounts sharing one Cloudflare account, for example SiteDesk), an account-wide token in a sub-account's `cloudflare.env` lets that sub-account reach every other client's D1 and R2. There, the master lives house-only at `${PLATFORM_ROOT}/config/cloudflare-house.env` (a location no sub-account spawn sources), per-account `cloudflare.env` files carry no account-wide token, and a sub-account reaches D1/R2 only through the storage broker, which scopes every operation to the caller's account. See [`../../../../.docs/cloudflare-storage-isolation.md`](../../../../.docs/cloudflare-storage-isolation.md). The per-scope DNS/Pages/Access minting below stays admin-run, and its mint-or-reuse mechanism is unchanged; the one difference on a multi-tenant install is the source file. Because the per-account `cloudflare.env` no longer carries the master, `cf-token.sh` falls back to the house credential (`${PLATFORM_ROOT}/config/cloudflare-house.env`) for these scopes, mints and persists the scope token there, and reports `src=house` on its `[cf-token]` lifeline. `cf-token.sh` gates that fallback on the caller being the house account (its `account.json` carries `role:"house"`, or it is the sole account), so the sanctioned path never mints a hosting token for a client sub-account; a denied caller gets an `action=deny` lifeline naming the reason. This gate is a boundary of intent, not of permission: every account's session runs as the same unix user and the house env path is deterministic, so an agent holding `Bash` can read the master directly or export a false `ACCOUNT_ID`. Do not cite the gate as proof a sub-account cannot reach the master. OS user separation
|
|
56
|
+
The account-scoped storage above is correct for a single-tenant install (one Cloudflare account, one client). On a **multi-client install** (many client sub-accounts sharing one Cloudflare account, for example SiteDesk), an account-wide token in a sub-account's `cloudflare.env` lets that sub-account reach every other client's D1 and R2. There, the master lives house-only at `${PLATFORM_ROOT}/config/cloudflare-house.env` (a location no sub-account spawn sources), per-account `cloudflare.env` files carry no account-wide token, and a sub-account reaches D1/R2 only through the storage broker, which scopes every operation to the caller's account. See [`../../../../.docs/cloudflare-storage-isolation.md`](../../../../.docs/cloudflare-storage-isolation.md). The per-scope DNS/Pages/Access minting below stays admin-run, and its mint-or-reuse mechanism is unchanged; the one difference on a multi-tenant install is the source file. Because the per-account `cloudflare.env` no longer carries the master, `cf-token.sh` falls back to the house credential (`${PLATFORM_ROOT}/config/cloudflare-house.env`) for these scopes, mints and persists the scope token there, and reports `src=house` on its `[cf-token]` lifeline. `cf-token.sh` gates that fallback on the caller being the house account (its `account.json` carries `role:"house"`, or it is the sole account), so the sanctioned path never mints a hosting token for a client sub-account; a denied caller gets an `action=deny` lifeline naming the reason. This gate is a boundary of intent, not of permission: every account's session runs as the same unix user and the house env path is deterministic, so an agent holding `Bash` can read the master directly or export a false `ACCOUNT_ID`. Do not cite the gate as proof a sub-account cannot reach the master. This is permanent, not pending: OS user separation was costed and declined as disproportionate, so nothing tracks closing it.
|
|
57
57
|
|
|
58
58
|
Load the master for a mint call:
|
|
59
59
|
|
|
@@ -37,3 +37,49 @@ When a bucket is created or reused, log the establishment decision so a reuse is
|
|
|
37
37
|
```
|
|
38
38
|
op=storage-provision resource=r2 purpose=<purpose> account=<accountId> name=<canonical-name> action=create|reuse
|
|
39
39
|
```
|
|
40
|
+
|
|
41
|
+
## Bind the bucket in wrangler.toml
|
|
42
|
+
|
|
43
|
+
The binding name (`BUCKET` below) is the variable the Pages Function reads off `env`. Keep `wrangler.toml` in the site's project root; it carries no secret, only the bucket's name.
|
|
44
|
+
|
|
45
|
+
```toml
|
|
46
|
+
name = "<project>"
|
|
47
|
+
pages_build_output_dir = "public"
|
|
48
|
+
|
|
49
|
+
[[r2_buckets]]
|
|
50
|
+
binding = "BUCKET"
|
|
51
|
+
bucket_name = "<accountId>-<purpose>" # from bin/cf-store-name.sh
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**R2 binds by name and has no `database_id` analogue.** That is the one asymmetry with the `[[d1_databases]]` block in `d1-data-capture.md` § Bind the database, where a `database_id` from `wrangler d1 create` must also be recorded. An R2 bucket needs no id, so there is no second value to carry into the tree.
|
|
55
|
+
|
|
56
|
+
The deploy-time token needs **no** R2 scope: the binding is declarative config carried in the deploy payload, covered by Pages Write. `wrangler r2 bucket create` **does** need `Workers R2 Storage Write` — the `storage` scope from `bin/cf-token.sh`. Reaching for an R2 token at deploy time is a sign the binding is being confused with the provision.
|
|
57
|
+
|
|
58
|
+
## The Pages Function
|
|
59
|
+
|
|
60
|
+
The bound bucket arrives on `env` and exposes `put` / `get` / `delete` / `head`. Keep the handler a thin wrapper over a pure function taking the env injected, so the logic is verifiable without the Pages runtime — the shape `d1-data-capture.md` § The Pages Function prescribes for D1.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
interface R2Bucket {
|
|
64
|
+
put: (key: string, value: ArrayBuffer | Uint8Array) => Promise<unknown>
|
|
65
|
+
get: (key: string) => Promise<{ body: ReadableStream | null; size: number } | null>
|
|
66
|
+
delete: (key: string) => Promise<void>
|
|
67
|
+
head: (key: string) => Promise<{ size: number } | null>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function storeObject(
|
|
71
|
+
env: { BUCKET: R2Bucket },
|
|
72
|
+
key: string,
|
|
73
|
+
bytes: Uint8Array,
|
|
74
|
+
log: (line: string) => void,
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
await env.BUCKET.put(key, bytes)
|
|
77
|
+
// Re-read rather than assume: a put that stored nothing emits no error, and
|
|
78
|
+
// an object with no manifest row is invisible to whatever later reads it.
|
|
79
|
+
const present = (await env.BUCKET.head(key)) !== null
|
|
80
|
+
log(`[r2-store] op=put key=${key} present=${present}`)
|
|
81
|
+
return present
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Declaring the interface structurally, rather than importing `@cloudflare/workers-types`, is what lets a test inject a fake bucket. No Workers-specific test tooling ships in this repo and none is needed for a Function shaped this way.
|