@rubytech/create-maxy-code 0.1.459 → 0.1.461
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/cron-heartbeat-registration.test.js +57 -28
- package/dist/__tests__/launchd-plist.test.js +26 -0
- package/dist/cron-registration.js +30 -8
- package/dist/index.js +50 -9
- package/dist/launchd-plist.js +1 -1
- package/dist/uninstall.js +9 -3
- package/package.json +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +116 -6
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +20 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts +2 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js +41 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js.map +1 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +1 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +116 -48
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
- package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.js +1 -0
- package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +27 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/object-limits.js +27 -7
- package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts +102 -0
- package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/resource-limits.js +105 -0
- package/payload/platform/lib/storage-broker/dist/resource-limits.js.map +1 -0
- package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +130 -7
- package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +30 -0
- package/payload/platform/lib/storage-broker/src/__tests__/resource-limits.test.ts +49 -0
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +129 -56
- package/payload/platform/lib/storage-broker/src/index.ts +1 -0
- package/payload/platform/lib/storage-broker/src/object-limits.ts +26 -6
- package/payload/platform/lib/storage-broker/src/resource-limits.ts +105 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -4
- package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -1
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +220 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +201 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +178 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/harness.test.ts +32 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +204 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/ratelimit.test.ts +77 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +46 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +55 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +255 -0
- package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
- package/payload/platform/plugins/cloudflare/references/r2-object-storage.md +46 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +125 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +32 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/log.ts +12 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +55 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +85 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +44 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +119 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +89 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +70 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +41 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +151 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/index.html +42 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.css +113 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.js +155 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +73 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +21 -0
- package/payload/platform/plugins/cloudflare/skills/site-deploy/SKILL.md +8 -0
- package/payload/platform/plugins/docs/references/outlook-guide.md +3 -3
- package/payload/platform/plugins/docs/references/voice-mirror-guide.md +1 -0
- package/payload/platform/plugins/filesystem/PLUGIN.md +0 -1
- package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts +14 -10
- package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts.map +1 -1
- package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js +14 -10
- package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js.map +1 -1
- package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/outlook/PLUGIN.md +3 -5
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js +75 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js +187 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/index.js +3 -2
- package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts +43 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js +65 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts +65 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js +99 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +6 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +42 -22
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +59 -23
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts +15 -16
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +8 -41
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
- package/payload/platform/plugins/outlook/references/auth.md +1 -1
- package/payload/platform/plugins/outlook/references/graph-surfaces.md +7 -3
- package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +1 -1
- package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -1
- package/payload/platform/plugins/voice-mirror/PLUGIN.md +19 -7
- package/payload/platform/plugins/voice-mirror/mcp/dist/index.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +35 -16
- package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts +31 -0
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts.map +1 -0
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js +87 -0
- package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js.map +1 -0
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +111 -12
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js +2 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js +2 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts +2 -2
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js +12 -3
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +444 -0
- package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +18 -2
- package/payload/platform/scripts/__tests__/logs-rotate.test.sh +256 -0
- package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
- package/payload/platform/scripts/logs-rotate.sh +204 -0
- package/payload/platform/scripts/resume-tunnel.sh +27 -0
- package/payload/platform/scripts/voice-author-key-audit.sh +155 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts +17 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js +88 -0
- package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
- package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
- package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +0 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +36 -24
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
- package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
- package/payload/server/{chunk-Q6W4U6HL.js → chunk-JXWFVE5X.js} +83 -39
- package/payload/server/server.js +268 -73
- package/payload/server/{src-3I2RYZFB.js → src-4F37OHLK.js} +11 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.d.ts +0 -2
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js +0 -215
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js.map +0 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Passcode generation, strength and hashing for the data portal.
|
|
2
|
+
//
|
|
3
|
+
// This module is JavaScript, not TypeScript, and that is deliberate: it has two
|
|
4
|
+
// consumers on two runtimes. The Worker imports it (wrangler bundles JS), and
|
|
5
|
+
// enrolment imports it from bare node on-device, which cannot load .ts. A second
|
|
6
|
+
// copy of the hash for the enrolment side would drift, and the failure mode of
|
|
7
|
+
// drift is that enrolment writes hashes the Worker rejects. JSDoc plus
|
|
8
|
+
// `typecheck:templates --checkJs` keeps the type cover 1689 added.
|
|
9
|
+
//
|
|
10
|
+
// constantTimeEqual is hand-rolled deliberately: crypto.subtle.timingSafeEqual
|
|
11
|
+
// is a Cloudflare extension with no Node equivalent, so depending on it would
|
|
12
|
+
// make the comparison untestable here.
|
|
13
|
+
|
|
14
|
+
/** Crockford base32: digits and letters minus I, L, O and U, which are the ones
|
|
15
|
+
* misread when the operator conveys a passcode to a person out of band. 32
|
|
16
|
+
* symbols is exactly 5 bits, and 256 divides by 32, so a masked random byte is
|
|
17
|
+
* an unbiased draw with no rejection sampling.
|
|
18
|
+
*
|
|
19
|
+
* How much of Crockford this portal takes, stated so nobody has to infer it
|
|
20
|
+
* from behaviour again (Task 1713):
|
|
21
|
+
*
|
|
22
|
+
* ADOPTED — the excluded letters, above, and case-insensitivity on decode.
|
|
23
|
+
* The alphabet is uppercase and so is everything stored;
|
|
24
|
+
* verifyPasscode uppercases what it is given, so a person may type
|
|
25
|
+
* either case. That is the one normalisation point.
|
|
26
|
+
* DECLINED — the letter-to-digit decode (I and L to 1, O to 0), check
|
|
27
|
+
* symbols, and hyphen tolerance. None are implemented.
|
|
28
|
+
*
|
|
29
|
+
* So "Crockford" here means this alphabet plus the case rule, and nothing
|
|
30
|
+
* further. 1708 shipped the first half without the second, which is the gap
|
|
31
|
+
* 1713 closed; a future reader adding the rest should treat it as a new
|
|
32
|
+
* decision, not as finishing this one.
|
|
33
|
+
*/
|
|
34
|
+
export const PASSCODE_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
35
|
+
|
|
36
|
+
/** 26 x 5 bits = 130, which clears the 128-bit floor this portal's security
|
|
37
|
+
* rests on. Changing this constant changes that guarantee: see the note at
|
|
38
|
+
* hashPasscode before touching it. */
|
|
39
|
+
export const PASSCODE_LENGTH = 26
|
|
40
|
+
|
|
41
|
+
/** What PASSCODE_LENGTH and PASSCODE_ALPHABET buy, stated once so a test can
|
|
42
|
+
* pin it rather than a comment asserting it. */
|
|
43
|
+
export const PASSCODE_ENTROPY_BITS = PASSCODE_LENGTH * 5
|
|
44
|
+
|
|
45
|
+
/** Enrolment refused a passcode below the pinned strength. Named rather than a
|
|
46
|
+
* bare Error so a caller can tell a weak passcode from a broken salt. */
|
|
47
|
+
export class PasscodeTooWeakError extends Error {
|
|
48
|
+
/** @param {string} detail */
|
|
49
|
+
constructor(detail) {
|
|
50
|
+
super(`passcode too weak: ${detail}`)
|
|
51
|
+
this.name = 'PasscodeTooWeakError'
|
|
52
|
+
/** @type {'passcode-too-weak'} */
|
|
53
|
+
this.reason = 'passcode-too-weak'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Mint a passcode. This is the ONLY source of passcode entropy in the portal —
|
|
58
|
+
* assertPasscodeStrength below cannot supply it, only check the shape.
|
|
59
|
+
* @returns {string} */
|
|
60
|
+
export function generatePasscode() {
|
|
61
|
+
const bytes = new Uint8Array(PASSCODE_LENGTH)
|
|
62
|
+
crypto.getRandomValues(bytes)
|
|
63
|
+
let out = ''
|
|
64
|
+
// & 31 is unbiased here precisely because the alphabet is 32 long.
|
|
65
|
+
for (const b of bytes) out += PASSCODE_ALPHABET[b & 31]
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Refuse a passcode that is not the pinned length drawn from the pinned
|
|
70
|
+
* alphabet.
|
|
71
|
+
*
|
|
72
|
+
* This is a STRUCTURAL check and nothing more. It cannot measure entropy: a
|
|
73
|
+
* 26-character run of 'A' passes it. Do not describe it as enforcing
|
|
74
|
+
* PASSCODE_ENTROPY_BITS — an arbitrary string's entropy is not a computable
|
|
75
|
+
* property of the string. What it does buy is the case that motivated it: an
|
|
76
|
+
* enrolling agent satisfying "high-entropy passcode" with a six-digit number
|
|
77
|
+
* can no longer store one. Entropy itself comes from generatePasscode.
|
|
78
|
+
* @param {string} passcode
|
|
79
|
+
* @returns {void} */
|
|
80
|
+
export function assertPasscodeStrength(passcode) {
|
|
81
|
+
if (typeof passcode !== 'string' || passcode.length !== PASSCODE_LENGTH) {
|
|
82
|
+
throw new PasscodeTooWeakError(`expected ${PASSCODE_LENGTH} characters`)
|
|
83
|
+
}
|
|
84
|
+
for (const c of passcode) {
|
|
85
|
+
if (!PASSCODE_ALPHABET.includes(c)) {
|
|
86
|
+
throw new PasscodeTooWeakError('character outside the pinned alphabet')
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {Uint8Array} bytes @returns {string} */
|
|
92
|
+
export function randomSaltHex(bytes) {
|
|
93
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Returns Uint8Array<ArrayBuffer>, not the bare Uint8Array: the bare form
|
|
97
|
+
* widens to ArrayBufferLike, which Web Crypto will not accept as a BufferSource
|
|
98
|
+
* because a SharedArrayBuffer could back it.
|
|
99
|
+
* @param {string} hex
|
|
100
|
+
* @returns {Uint8Array<ArrayBuffer> | null} */
|
|
101
|
+
function hexToBytes(hex) {
|
|
102
|
+
if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null
|
|
103
|
+
const out = new Uint8Array(hex.length / 2)
|
|
104
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Hash a passcode for storage: one SHA-256 over salt || passcode.
|
|
109
|
+
*
|
|
110
|
+
* ONE fast hash, no key stretching, and that is safe ONLY because passcodes are
|
|
111
|
+
* machine-generated by generatePasscode at PASSCODE_LENGTH. Stretching exists to
|
|
112
|
+
* make guessing a HUMAN-CHOSEN password expensive; 130 random bits are
|
|
113
|
+
* unguessable however fast the hash is, which is why nobody stretches a
|
|
114
|
+
* magic-link token either.
|
|
115
|
+
*
|
|
116
|
+
* So: if anyone ever lets an operator choose a memorable passcode, key
|
|
117
|
+
* stretching must come back in the SAME change. The danger here is not the fast
|
|
118
|
+
* hash. It is a later relaxation of generated-only while the fast hash stays.
|
|
119
|
+
*
|
|
120
|
+
* The salt earns its keep even without stretching: two people holding the same
|
|
121
|
+
* passcode still get different stored hashes, and no precomputed table over the
|
|
122
|
+
* alphabet is reusable across rows.
|
|
123
|
+
* @param {string} passcode
|
|
124
|
+
* @param {string} saltHex
|
|
125
|
+
* @returns {Promise<string>} */
|
|
126
|
+
export async function hashPasscode(passcode, saltHex) {
|
|
127
|
+
assertPasscodeStrength(passcode)
|
|
128
|
+
const salt = hexToBytes(saltHex)
|
|
129
|
+
if (!salt) throw new Error('salt must be hex')
|
|
130
|
+
const encoded = new TextEncoder().encode(passcode)
|
|
131
|
+
const message = new Uint8Array(salt.length + encoded.length)
|
|
132
|
+
message.set(salt, 0)
|
|
133
|
+
message.set(encoded, salt.length)
|
|
134
|
+
const digest = await crypto.subtle.digest('SHA-256', message)
|
|
135
|
+
return randomSaltHex(new Uint8Array(digest))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Verify a passcode against a stored salt and hash. Returns false rather than
|
|
139
|
+
* throwing on any malformed input, including a passcode that would not pass
|
|
140
|
+
* enrolment: sign-in is not the place to crash over a bad row.
|
|
141
|
+
* @param {string} passcode
|
|
142
|
+
* @param {string} saltHex
|
|
143
|
+
* @param {string} expectedHashHex
|
|
144
|
+
* @returns {Promise<boolean>} */
|
|
145
|
+
export async function verifyPasscode(passcode, saltHex, expectedHashHex) {
|
|
146
|
+
const expected = hexToBytes(expectedHashHex)
|
|
147
|
+
if (!expected) return false
|
|
148
|
+
/** @type {string} */
|
|
149
|
+
let actualHex
|
|
150
|
+
try {
|
|
151
|
+
// Crockford is case-insensitive on decode, and 1708 chose this alphabet for
|
|
152
|
+
// the very reason that rule exists: a person transcribes the passcode by
|
|
153
|
+
// hand. Uppercasing here is the second half of that choice (Task 1713), so
|
|
154
|
+
// the case someone types stops being something they can get wrong.
|
|
155
|
+
//
|
|
156
|
+
// This is the ONE normalisation point. hashPasscode and
|
|
157
|
+
// assertPasscodeStrength stay strict on the canonical uppercase form, so
|
|
158
|
+
// what enrolment stores is unchanged and only accepted INPUT widens.
|
|
159
|
+
//
|
|
160
|
+
// Inside the try deliberately: a malformed PASSCODE must return false rather
|
|
161
|
+
// than throw, and .toUpperCase() throws on a non-string. Outside the try,
|
|
162
|
+
// that would break for exactly the input class it exists to cover.
|
|
163
|
+
// (Scoped to the passcode on purpose. The docblock above says "any
|
|
164
|
+
// malformed input", which is not quite true of expectedHashHex — a null one
|
|
165
|
+
// throws out of hexToBytes before this try is entered. Unreachable today,
|
|
166
|
+
// since schema.sql pins hash TEXT NOT NULL; tracked in Task 1718 rather
|
|
167
|
+
// than restated here as though it held.)
|
|
168
|
+
//
|
|
169
|
+
// The strength gate therefore applies to the normalised form: verification
|
|
170
|
+
// accepts any string whose uppercase form is a valid passcode. One
|
|
171
|
+
// consequence, left unguarded on purpose — 'ß' uppercases to 'SS', so a
|
|
172
|
+
// 25-character string containing it can normalise onto a 26-character
|
|
173
|
+
// passcode. Building one requires already knowing the passcode, so it costs
|
|
174
|
+
// nothing an attacker could spend, and a fourth check to close it would buy
|
|
175
|
+
// no security.
|
|
176
|
+
actualHex = await hashPasscode(passcode.toUpperCase(), saltHex)
|
|
177
|
+
} catch {
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
const actual = hexToBytes(actualHex)
|
|
181
|
+
if (!actual) return false
|
|
182
|
+
return constantTimeEqual(actual, expected)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {Uint8Array} a @param {Uint8Array} b @returns {boolean} */
|
|
186
|
+
export function constantTimeEqual(a, b) {
|
|
187
|
+
// Length is not secret; an early return on it leaks nothing a caller cannot
|
|
188
|
+
// already see. The byte loop below never short-circuits.
|
|
189
|
+
if (a.length !== b.length) return false
|
|
190
|
+
let diff = 0
|
|
191
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
|
|
192
|
+
return diff === 0
|
|
193
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { D1Database } from './types'
|
|
2
|
+
|
|
3
|
+
// Fixed-window attempt counter in D1.
|
|
4
|
+
//
|
|
5
|
+
// Every rate limiter elsewhere in this platform is an in-memory Map in a
|
|
6
|
+
// long-lived Node process (ui/server/routes/client-error.ts). None of that
|
|
7
|
+
// ports here: Pages Functions are stateless and spread across edge locations,
|
|
8
|
+
// so the counter must live in storage the whole edge shares.
|
|
9
|
+
//
|
|
10
|
+
// D1 has no atomic increment, so two concurrent requests can read the same
|
|
11
|
+
// count and both be allowed. This is a speed bump against passcode guessing,
|
|
12
|
+
// not a hard limit, and the docs say so rather than implying a guarantee.
|
|
13
|
+
|
|
14
|
+
export const RATE_WINDOW_MS = 900_000 // 15 minutes
|
|
15
|
+
export const RATE_MAX_ATTEMPTS = 5
|
|
16
|
+
|
|
17
|
+
/** The counter's key. The client IP is part of it, not decoration: keyed on the
|
|
18
|
+
* owner alone, the limiter is a weapon rather than a shield — six
|
|
19
|
+
* unauthenticated requests naming a person lock that person out of their own
|
|
20
|
+
* portal for the window, from anywhere, repeatably. Keyed on owner+IP, an
|
|
21
|
+
* attacker throttles only themselves. The cost is that a distributed guesser
|
|
22
|
+
* gets RATE_MAX_ATTEMPTS per address, which the passcodes' entropy carries. */
|
|
23
|
+
export function rateScope(ownerId: string, clientIp: string): string {
|
|
24
|
+
return `${ownerId}|${clientIp}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Clear a scope's counter. Called on a successful sign-in, so a person doing
|
|
28
|
+
* ordinary work does not lock themselves out. */
|
|
29
|
+
export async function clearAttempts(db: D1Database, scope: string): Promise<void> {
|
|
30
|
+
await db.prepare('DELETE FROM auth_attempts WHERE scope = ?').bind(scope).run()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function checkAndRecordAttempt(
|
|
34
|
+
db: D1Database,
|
|
35
|
+
scope: string,
|
|
36
|
+
nowMs: number,
|
|
37
|
+
): Promise<{ allowed: boolean; count: number }> {
|
|
38
|
+
const windowStart = Math.floor(nowMs / RATE_WINDOW_MS) * RATE_WINDOW_MS
|
|
39
|
+
|
|
40
|
+
await db
|
|
41
|
+
.prepare(
|
|
42
|
+
`INSERT INTO auth_attempts (scope, windowStart, count) VALUES (?, ?, 1)
|
|
43
|
+
ON CONFLICT (scope, windowStart) DO UPDATE SET count = count + 1`,
|
|
44
|
+
)
|
|
45
|
+
.bind(scope, windowStart)
|
|
46
|
+
.run()
|
|
47
|
+
|
|
48
|
+
const row = await db
|
|
49
|
+
.prepare('SELECT count FROM auth_attempts WHERE scope = ? AND windowStart = ?')
|
|
50
|
+
.bind(scope, windowStart)
|
|
51
|
+
.first<{ count: number }>()
|
|
52
|
+
|
|
53
|
+
const count = typeof row?.count === 'number' ? row.count : 1
|
|
54
|
+
return { allowed: count <= RATE_MAX_ATTEMPTS, count }
|
|
55
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { D1Database } from './types'
|
|
2
|
+
|
|
3
|
+
// Server-side sessions in D1 rather than a signed stateless token: a signed
|
|
4
|
+
// token needs a signing secret provisioned into the Pages env, a secret
|
|
5
|
+
// lifecycle this plugin does not have. D1 needs no secret and is revocable.
|
|
6
|
+
// The cost is one D1 read per request.
|
|
7
|
+
//
|
|
8
|
+
// "Revocable" is a claim this module has to actually honour, so revocation is
|
|
9
|
+
// structural rather than procedural. Each session records `pcCheck`, a short
|
|
10
|
+
// digest of the person's passcode hash at mint time, and resolveSession joins
|
|
11
|
+
// `people` and requires it to still match. Rotating a passcode necessarily
|
|
12
|
+
// changes the hash, so every session minted against the old one stops
|
|
13
|
+
// resolving with no cleanup step to remember and no window to wait out.
|
|
14
|
+
// Unenrolling a person kills their sessions by the same join.
|
|
15
|
+
|
|
16
|
+
export const SESSION_TTL_MS = 43_200_000 // 12 hours
|
|
17
|
+
|
|
18
|
+
// The __Host- prefix is the only thing a browser enforces against a cookie set
|
|
19
|
+
// on a sibling subdomain with Domain=.example.com shadowing this one — which
|
|
20
|
+
// would let an attacker fix a victim's session and receive their uploads. It
|
|
21
|
+
// requires Secure, Path=/, and no Domain; the Set-Cookie in auth.ts meets all
|
|
22
|
+
// three.
|
|
23
|
+
export const SESSION_COOKIE = '__Host-__portal_session'
|
|
24
|
+
|
|
25
|
+
/** The stored session-vs-passcode check. A prefix of an already-irreversible
|
|
26
|
+
* hash, kept next to it in the same database — it exposes nothing new. */
|
|
27
|
+
export function passcodeCheck(hash: string): string {
|
|
28
|
+
return hash.slice(0, 16)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function mintSession(
|
|
32
|
+
db: D1Database,
|
|
33
|
+
ownerId: string,
|
|
34
|
+
sessionId: string,
|
|
35
|
+
nowMs: number,
|
|
36
|
+
pcCheck: string,
|
|
37
|
+
): Promise<string> {
|
|
38
|
+
await db
|
|
39
|
+
.prepare(
|
|
40
|
+
'INSERT INTO sessions (sessionId, ownerId, pcCheck, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?)',
|
|
41
|
+
)
|
|
42
|
+
.bind(sessionId, ownerId, pcCheck, nowMs + SESSION_TTL_MS, new Date(nowMs).toISOString())
|
|
43
|
+
.run()
|
|
44
|
+
// Opportunistic GC: expired rows are dead weight and nothing else prunes
|
|
45
|
+
// them. Scoped to this owner so it stays a small, indexed delete.
|
|
46
|
+
await db
|
|
47
|
+
.prepare('DELETE FROM sessions WHERE ownerId = ? AND expiresAt <= ?')
|
|
48
|
+
.bind(ownerId, nowMs)
|
|
49
|
+
.run()
|
|
50
|
+
return sessionId
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function resolveSession(
|
|
54
|
+
db: D1Database,
|
|
55
|
+
sessionId: string,
|
|
56
|
+
nowMs: number,
|
|
57
|
+
): Promise<{ ownerId: string } | null> {
|
|
58
|
+
if (!sessionId) return null
|
|
59
|
+
const row = await db
|
|
60
|
+
.prepare(
|
|
61
|
+
`SELECT s.ownerId AS ownerId
|
|
62
|
+
FROM sessions s
|
|
63
|
+
JOIN people p ON p.ownerId = s.ownerId
|
|
64
|
+
WHERE s.sessionId = ?
|
|
65
|
+
AND s.expiresAt > ?
|
|
66
|
+
AND s.pcCheck = substr(p.hash, 1, 16)`,
|
|
67
|
+
)
|
|
68
|
+
.bind(sessionId, nowMs)
|
|
69
|
+
.first<{ ownerId: string }>()
|
|
70
|
+
return row && typeof row.ownerId === 'string' ? { ownerId: row.ownerId } : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readSessionCookie(header: string | null): string | null {
|
|
74
|
+
if (!header) return null
|
|
75
|
+
for (const part of header.split(';')) {
|
|
76
|
+
const [name, ...rest] = part.trim().split('=')
|
|
77
|
+
if (name !== SESSION_COOKIE) continue
|
|
78
|
+
const value = rest.join('=')
|
|
79
|
+
// Keep scanning rather than returning on the first match: a planted
|
|
80
|
+
// valueless duplicate sorted first would otherwise blank the real session,
|
|
81
|
+
// which is a persistent logout denial-of-service.
|
|
82
|
+
if (value) return value
|
|
83
|
+
}
|
|
84
|
+
return null
|
|
85
|
+
}
|
package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Structural interfaces for the Pages runtime bindings this portal uses.
|
|
2
|
+
// Declared here rather than imported from @cloudflare/workers-types (not a
|
|
3
|
+
// dependency in this repo) so every function below is testable with a
|
|
4
|
+
// hand-rolled fake — the choice book.ts:12-24 already makes.
|
|
5
|
+
|
|
6
|
+
export interface D1Result<T = Record<string, unknown>> {
|
|
7
|
+
results?: T[]
|
|
8
|
+
meta?: { changes?: number }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface D1PreparedStatement {
|
|
12
|
+
bind: (...values: unknown[]) => D1PreparedStatement
|
|
13
|
+
run: () => Promise<D1Result>
|
|
14
|
+
all: <T = Record<string, unknown>>() => Promise<D1Result<T>>
|
|
15
|
+
first: <T = Record<string, unknown>>() => Promise<T | null>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface D1Database {
|
|
19
|
+
prepare: (query: string) => D1PreparedStatement
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface R2ObjectBody {
|
|
23
|
+
body: ReadableStream | null
|
|
24
|
+
size: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface R2Bucket {
|
|
28
|
+
put: (key: string, value: ArrayBuffer | Uint8Array) => Promise<unknown>
|
|
29
|
+
get: (key: string) => Promise<R2ObjectBody | null>
|
|
30
|
+
delete: (key: string) => Promise<void>
|
|
31
|
+
head: (key: string) => Promise<{ size: number } | null>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PortalEnv {
|
|
35
|
+
DB: D1Database
|
|
36
|
+
BUCKET: R2Bucket
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type Logger = (line: string) => void
|
|
40
|
+
|
|
41
|
+
export interface Handler {
|
|
42
|
+
status: number
|
|
43
|
+
payload: Record<string, unknown>
|
|
44
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Handler, Logger, PortalEnv } from './_lib/types'
|
|
2
|
+
import { verifyPasscode } from './_lib/passcode.mjs'
|
|
3
|
+
import { checkAndRecordAttempt, clearAttempts, rateScope } from './_lib/ratelimit'
|
|
4
|
+
import { SESSION_COOKIE, SESSION_TTL_MS, mintSession, passcodeCheck } from './_lib/session'
|
|
5
|
+
import { q } from './_lib/log'
|
|
6
|
+
|
|
7
|
+
export interface AuthBody {
|
|
8
|
+
ownerId?: unknown
|
|
9
|
+
passcode?: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface AuthResult extends Handler {
|
|
13
|
+
sessionId?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Same denial payload for a wrong passcode and an unknown owner: distinguishing
|
|
17
|
+
// them would enumerate who is enrolled.
|
|
18
|
+
const DENIED: Handler = { status: 401, payload: { ok: false, error: 'denied' } }
|
|
19
|
+
|
|
20
|
+
// A real-shaped salt and hash used only to burn the same hash when the owner
|
|
21
|
+
// does not exist, so the two paths cost the same and the identical DENIED
|
|
22
|
+
// payload above is not merely cosmetic.
|
|
23
|
+
//
|
|
24
|
+
// Task 1708 re-measured this after replacing PBKDF2 with one SHA-256, because
|
|
25
|
+
// the gap it closes shrank by four orders of magnitude and the dummy might have
|
|
26
|
+
// stopped earning its place. It did not. Without it the delta is one hash —
|
|
27
|
+
// measured at 43.8us (dev Mac, off-platform) — and published remote-timing work
|
|
28
|
+
// resolves roughly 15-20us on a LAN and ~100us over the internet, so 43.8us sits
|
|
29
|
+
// inside the demonstrated-resolvable band, and an attacker gets to pick a
|
|
30
|
+
// Cloudflare PoP near the target. Keeping it costs 43.8us against a 10ms CPU
|
|
31
|
+
// budget, which is 0.4% of it. Cheap enough not to think about; measurable
|
|
32
|
+
// enough not to remove.
|
|
33
|
+
const DUMMY_SALT = '00000000000000000000000000000000'
|
|
34
|
+
const DUMMY_HASH = '0'.repeat(64)
|
|
35
|
+
|
|
36
|
+
export async function processAuth(
|
|
37
|
+
body: AuthBody,
|
|
38
|
+
env: PortalEnv,
|
|
39
|
+
log: Logger,
|
|
40
|
+
newId: () => string,
|
|
41
|
+
nowMs: number,
|
|
42
|
+
clientIp: string,
|
|
43
|
+
): Promise<AuthResult> {
|
|
44
|
+
const ownerId = typeof body?.ownerId === 'string' ? body.ownerId : ''
|
|
45
|
+
const passcode = typeof body?.passcode === 'string' ? body.passcode : ''
|
|
46
|
+
if (!ownerId || !passcode) {
|
|
47
|
+
log('[data-portal] op=auth owner=none result=malformed')
|
|
48
|
+
return { status: 400, payload: { ok: false, error: 'ownerId and passcode required' } }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Rate-limit BEFORE any passcode comparison, so a guessing loop is throttled
|
|
52
|
+
// rather than merely logged. The scope carries the client IP as well as the
|
|
53
|
+
// owner: keyed on the owner alone, the counter is a weapon — six requests
|
|
54
|
+
// naming a person lock that person out of their own portal for the window,
|
|
55
|
+
// from anywhere, unauthenticated.
|
|
56
|
+
const scope = rateScope(ownerId, clientIp)
|
|
57
|
+
const rate = await checkAndRecordAttempt(env.DB, scope, nowMs)
|
|
58
|
+
if (!rate.allowed) {
|
|
59
|
+
log(`[data-portal] op=auth owner=${q(ownerId)} result=rate-limited attempts=${rate.count}`)
|
|
60
|
+
return { status: 429, payload: { ok: false, error: 'too many attempts' } }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const person = await env.DB.prepare('SELECT ownerId, salt, hash FROM people WHERE ownerId = ?')
|
|
64
|
+
.bind(ownerId)
|
|
65
|
+
.first<{ ownerId: string; salt: string; hash: string }>()
|
|
66
|
+
|
|
67
|
+
if (!person) {
|
|
68
|
+
// Burn the same work an enrolled owner would cost, then deny identically.
|
|
69
|
+
await verifyPasscode(passcode, DUMMY_SALT, DUMMY_HASH)
|
|
70
|
+
log(`[data-portal] op=auth owner=${q(ownerId)} result=denied reason=no-such-owner`)
|
|
71
|
+
return DENIED
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ok = await verifyPasscode(passcode, person.salt, person.hash)
|
|
75
|
+
if (!ok) {
|
|
76
|
+
log(`[data-portal] op=auth owner=${q(ownerId)} result=denied reason=bad-passcode`)
|
|
77
|
+
return DENIED
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A successful sign-in clears the counter. Without this, six legitimate
|
|
81
|
+
// sign-ins inside one window lock the person out of their own files.
|
|
82
|
+
await clearAttempts(env.DB, scope)
|
|
83
|
+
|
|
84
|
+
const sessionId = newId()
|
|
85
|
+
await mintSession(env.DB, ownerId, sessionId, nowMs, passcodeCheck(person.hash))
|
|
86
|
+
log(`[data-portal] op=auth owner=${q(ownerId)} result=ok`)
|
|
87
|
+
return { status: 200, payload: { ok: true }, sessionId }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface PagesContext {
|
|
91
|
+
request: Request
|
|
92
|
+
env: PortalEnv
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function onRequestPost(context: PagesContext): Promise<Response> {
|
|
96
|
+
let body: AuthBody
|
|
97
|
+
try {
|
|
98
|
+
body = (await context.request.json()) as AuthBody
|
|
99
|
+
} catch {
|
|
100
|
+
return Response.json({ ok: false, error: 'invalid JSON' }, { status: 400 })
|
|
101
|
+
}
|
|
102
|
+
const { status, payload, sessionId } = await processAuth(
|
|
103
|
+
body,
|
|
104
|
+
context.env,
|
|
105
|
+
(line) => console.log(line),
|
|
106
|
+
() => crypto.randomUUID(),
|
|
107
|
+
Date.now(),
|
|
108
|
+
context.request.headers.get('cf-connecting-ip') ?? 'unknown',
|
|
109
|
+
)
|
|
110
|
+
const res = Response.json(payload, { status })
|
|
111
|
+
if (sessionId) {
|
|
112
|
+
// Secure + Path=/ + no Domain are the __Host- prefix's preconditions.
|
|
113
|
+
res.headers.append(
|
|
114
|
+
'Set-Cookie',
|
|
115
|
+
`${SESSION_COOKIE}=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_MS / 1000}`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
return res
|
|
119
|
+
}
|
package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Handler, Logger, PortalEnv } from './_lib/types'
|
|
2
|
+
import { readSessionCookie, resolveSession } from './_lib/session'
|
|
3
|
+
import { q } from './_lib/log'
|
|
4
|
+
import { authorizeKey } from './_lib/authorize'
|
|
5
|
+
|
|
6
|
+
export async function processDelete(
|
|
7
|
+
sessionId: string,
|
|
8
|
+
key: string,
|
|
9
|
+
env: PortalEnv,
|
|
10
|
+
log: Logger,
|
|
11
|
+
now: () => number,
|
|
12
|
+
): Promise<Handler> {
|
|
13
|
+
const startedMs = now()
|
|
14
|
+
const session = await resolveSession(env.DB, sessionId, startedMs)
|
|
15
|
+
if (!session) {
|
|
16
|
+
log('[data-portal] op=delete owner=none result=denied')
|
|
17
|
+
return { status: 401, payload: { ok: false, error: 'denied' } }
|
|
18
|
+
}
|
|
19
|
+
if (!authorizeKey(session.ownerId, key)) {
|
|
20
|
+
log(`[data-portal] op=delete owner=${q(session.ownerId)} result=denied reason=not-owner`)
|
|
21
|
+
return { status: 403, payload: { ok: false, error: 'denied' } }
|
|
22
|
+
}
|
|
23
|
+
const owner = q(session.ownerId)
|
|
24
|
+
|
|
25
|
+
// Both writes are wrapped and each emits its own line, mirroring upload's
|
|
26
|
+
// steps 3-5. Unwrapped, a throw here escapes the handler, Pages returns a
|
|
27
|
+
// bare 500, and NOTHING is logged — and if the R2 delete succeeded before the
|
|
28
|
+
// D1 delete threw, that silently manufactures a phantom row, which is exactly
|
|
29
|
+
// the failure class the deferred reconcile audit exists to catch. The
|
|
30
|
+
// lifeline that should have caught it must not be the thing that goes quiet.
|
|
31
|
+
try {
|
|
32
|
+
await env.BUCKET.delete(key)
|
|
33
|
+
log(`[data-portal] op=r2-delete owner=${owner} key=${q(key)} result=ok`)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
log(
|
|
36
|
+
`[data-portal] op=r2-delete owner=${owner} key=${q(key)} result=failed err="${(err as Error).message}"`,
|
|
37
|
+
)
|
|
38
|
+
return { status: 502, payload: { ok: false, error: 'delete failed' } }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await env.DB.prepare('DELETE FROM manifest WHERE objectKey = ?').bind(key).run()
|
|
43
|
+
log(`[data-portal] op=manifest-delete owner=${owner} key=${q(key)} result=ok`)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
log(
|
|
46
|
+
`[data-portal] op=manifest-delete owner=${owner} key=${q(key)} result=failed err="${(err as Error).message}"`,
|
|
47
|
+
)
|
|
48
|
+
return { status: 502, payload: { ok: false, error: 'manifest delete failed' } }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The verified post-condition: both sides re-read, not "we called delete".
|
|
52
|
+
const objectPresent = (await env.BUCKET.head(key)) !== null
|
|
53
|
+
const rowPresent =
|
|
54
|
+
(await env.DB.prepare('SELECT fileId FROM manifest WHERE objectKey = ?').bind(key).first()) !==
|
|
55
|
+
null
|
|
56
|
+
// Derived, never hardcoded: a failed delete printing result=ok next to
|
|
57
|
+
// objectPresent=true is a self-contradicting line.
|
|
58
|
+
const result = !objectPresent && !rowPresent ? 'ok' : 'incomplete'
|
|
59
|
+
log(
|
|
60
|
+
`[data-portal] op=complete owner=${owner} key=${q(key)} op2=delete ms=${now() - startedMs} result=${result} objectPresent=${objectPresent} rowPresent=${rowPresent}`,
|
|
61
|
+
)
|
|
62
|
+
return {
|
|
63
|
+
status: result === 'ok' ? 200 : 500,
|
|
64
|
+
payload: { ok: result === 'ok', objectPresent, rowPresent },
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PagesContext {
|
|
69
|
+
request: Request
|
|
70
|
+
env: PortalEnv
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function onRequestPost(context: PagesContext): Promise<Response> {
|
|
74
|
+
const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
|
|
75
|
+
let key = ''
|
|
76
|
+
try {
|
|
77
|
+
key = String(((await context.request.json()) as { key?: unknown }).key ?? '')
|
|
78
|
+
} catch {
|
|
79
|
+
return Response.json({ ok: false, error: 'invalid JSON' }, { status: 400 })
|
|
80
|
+
}
|
|
81
|
+
const { status, payload } = await processDelete(
|
|
82
|
+
sessionId,
|
|
83
|
+
key,
|
|
84
|
+
context.env,
|
|
85
|
+
(line) => console.log(line),
|
|
86
|
+
() => Date.now(),
|
|
87
|
+
)
|
|
88
|
+
return Response.json(payload, { status })
|
|
89
|
+
}
|
package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Handler, Logger, PortalEnv, R2ObjectBody } from './_lib/types'
|
|
2
|
+
import { readSessionCookie, resolveSession } from './_lib/session'
|
|
3
|
+
import { q } from './_lib/log'
|
|
4
|
+
import { authorizeKey } from './_lib/authorize'
|
|
5
|
+
|
|
6
|
+
interface DownloadResult extends Handler {
|
|
7
|
+
body?: R2ObjectBody | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function processDownload(
|
|
11
|
+
sessionId: string,
|
|
12
|
+
key: string,
|
|
13
|
+
env: PortalEnv,
|
|
14
|
+
log: Logger,
|
|
15
|
+
nowMs: number,
|
|
16
|
+
): Promise<DownloadResult> {
|
|
17
|
+
const session = await resolveSession(env.DB, sessionId, nowMs)
|
|
18
|
+
if (!session) {
|
|
19
|
+
log('[data-portal] op=download owner=none result=denied')
|
|
20
|
+
return { status: 401, payload: { ok: false, error: 'denied' } }
|
|
21
|
+
}
|
|
22
|
+
// Authorize BEFORE any R2 call, so a denied caller cannot cause a fetch.
|
|
23
|
+
if (!authorizeKey(session.ownerId, key)) {
|
|
24
|
+
log(`[data-portal] op=download owner=${q(session.ownerId)} result=denied reason=not-owner`)
|
|
25
|
+
return { status: 403, payload: { ok: false, error: 'denied' } }
|
|
26
|
+
}
|
|
27
|
+
const obj = await env.BUCKET.get(key)
|
|
28
|
+
if (!obj) {
|
|
29
|
+
log(`[data-portal] op=download owner=${q(session.ownerId)} result=absent`)
|
|
30
|
+
return { status: 404, payload: { ok: false, error: 'not found' } }
|
|
31
|
+
}
|
|
32
|
+
log(`[data-portal] op=download owner=${q(session.ownerId)} result=ok bytes=${obj.size ?? 'na'}`)
|
|
33
|
+
return { status: 200, payload: { ok: true }, body: obj }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PagesContext {
|
|
37
|
+
request: Request
|
|
38
|
+
env: PortalEnv
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function onRequestGet(context: PagesContext): Promise<Response> {
|
|
42
|
+
const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
|
|
43
|
+
const key = new URL(context.request.url).searchParams.get('key') ?? ''
|
|
44
|
+
const { status, payload, body } = await processDownload(
|
|
45
|
+
sessionId,
|
|
46
|
+
key,
|
|
47
|
+
context.env,
|
|
48
|
+
(line) => console.log(line),
|
|
49
|
+
Date.now(),
|
|
50
|
+
)
|
|
51
|
+
if (status === 200 && body) {
|
|
52
|
+
return new Response(body.body, {
|
|
53
|
+
status: 200,
|
|
54
|
+
headers: {
|
|
55
|
+
// RFC 6266 filename*: percent-encoded, so a name containing a quote
|
|
56
|
+
// cannot break out of the quoted form, and CR/LF cannot reach the
|
|
57
|
+
// Headers constructor (which would throw and 500 the download).
|
|
58
|
+
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(key.split('/').pop() ?? 'file')}`,
|
|
59
|
+
// `attachment` already stops inline rendering; nosniff is the belt to
|
|
60
|
+
// that braces, so an uploaded .html can never be sniffed and executed
|
|
61
|
+
// on the portal's own origin.
|
|
62
|
+
'x-content-type-options': 'nosniff',
|
|
63
|
+
// These are private client documents. Without this a browser or an
|
|
64
|
+
// intermediary proxy may cache one person's invoice.
|
|
65
|
+
'cache-control': 'private, no-store',
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
return Response.json(payload, { status })
|
|
70
|
+
}
|