@rubytech/create-maxy-code 0.1.459 → 0.1.460

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +116 -6
  3. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  4. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +20 -0
  5. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -1
  6. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts +2 -0
  7. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts.map +1 -0
  8. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js +41 -0
  9. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js.map +1 -0
  10. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +1 -0
  11. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  12. package/payload/platform/lib/storage-broker/dist/cf-exec.js +116 -48
  13. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  14. package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
  15. package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
  16. package/payload/platform/lib/storage-broker/dist/index.js +1 -0
  17. package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
  18. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +27 -0
  19. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/object-limits.js +27 -7
  21. package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts +102 -0
  23. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts.map +1 -0
  24. package/payload/platform/lib/storage-broker/dist/resource-limits.js +105 -0
  25. package/payload/platform/lib/storage-broker/dist/resource-limits.js.map +1 -0
  26. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +130 -7
  27. package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +30 -0
  28. package/payload/platform/lib/storage-broker/src/__tests__/resource-limits.test.ts +49 -0
  29. package/payload/platform/lib/storage-broker/src/cf-exec.ts +129 -56
  30. package/payload/platform/lib/storage-broker/src/index.ts +1 -0
  31. package/payload/platform/lib/storage-broker/src/object-limits.ts +26 -6
  32. package/payload/platform/lib/storage-broker/src/resource-limits.ts +105 -0
  33. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -4
  34. package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -1
  35. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +206 -0
  36. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +201 -0
  37. package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +178 -0
  38. package/payload/platform/plugins/cloudflare/mcp/__tests__/harness.test.ts +32 -0
  39. package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +74 -0
  40. package/payload/platform/plugins/cloudflare/mcp/__tests__/ratelimit.test.ts +77 -0
  41. package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +46 -0
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +55 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +255 -0
  44. package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
  45. package/payload/platform/plugins/cloudflare/references/r2-object-storage.md +46 -0
  46. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +123 -0
  47. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +32 -0
  48. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/log.ts +12 -0
  49. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.ts +73 -0
  50. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +55 -0
  51. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +85 -0
  52. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +44 -0
  53. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +113 -0
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +89 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +70 -0
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +41 -0
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +151 -0
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/index.html +42 -0
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.css +113 -0
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.js +155 -0
  61. package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +64 -0
  62. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +21 -0
  63. package/payload/platform/plugins/cloudflare/skills/site-deploy/SKILL.md +8 -0
  64. package/payload/platform/plugins/docs/references/outlook-guide.md +3 -3
  65. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +1 -0
  66. package/payload/platform/plugins/filesystem/PLUGIN.md +0 -1
  67. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts +14 -10
  68. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts.map +1 -1
  69. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js +14 -10
  70. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js.map +1 -1
  71. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  72. package/payload/platform/plugins/outlook/PLUGIN.md +3 -5
  73. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts +2 -0
  74. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts.map +1 -0
  75. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js +75 -0
  76. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js.map +1 -0
  77. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts +2 -0
  78. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts.map +1 -0
  79. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js +187 -0
  80. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js.map +1 -0
  81. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +1 -1
  82. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
  83. package/payload/platform/plugins/outlook/mcp/dist/index.js +3 -2
  84. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  85. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts +43 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js +65 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts +65 -0
  90. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts.map +1 -0
  91. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js +99 -0
  92. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js.map +1 -0
  93. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +6 -1
  94. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
  95. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
  96. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +42 -22
  97. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
  98. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +59 -23
  99. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
  100. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts +15 -16
  101. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
  102. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +8 -41
  103. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
  104. package/payload/platform/plugins/outlook/references/auth.md +1 -1
  105. package/payload/platform/plugins/outlook/references/graph-surfaces.md +7 -3
  106. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +1 -1
  107. package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -1
  108. package/payload/platform/plugins/voice-mirror/PLUGIN.md +19 -7
  109. package/payload/platform/plugins/voice-mirror/mcp/dist/index.d.ts.map +1 -1
  110. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +35 -16
  111. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
  112. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts +31 -0
  113. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts.map +1 -0
  114. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js +87 -0
  115. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js.map +1 -0
  116. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  117. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +13 -3
  118. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  119. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
  120. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js +2 -1
  121. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
  122. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  123. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js +2 -1
  124. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  125. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts +2 -2
  126. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  127. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js +12 -3
  128. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js.map +1 -1
  129. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +334 -0
  130. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +13 -0
  131. package/payload/platform/scripts/voice-author-key-audit.sh +155 -0
  132. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts +17 -0
  133. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts.map +1 -0
  134. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js +88 -0
  135. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js.map +1 -0
  136. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +0 -1
  137. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  138. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +34 -23
  139. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  140. package/payload/server/{chunk-Q6W4U6HL.js → chunk-JXWFVE5X.js} +83 -39
  141. package/payload/server/server.js +213 -38
  142. package/payload/server/{src-3I2RYZFB.js → src-4F37OHLK.js} +11 -1
  143. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.d.ts +0 -2
  144. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js +0 -215
  145. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js.map +0 -1
@@ -0,0 +1,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
+ }
@@ -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,113 @@
1
+ import type { Handler, Logger, PortalEnv } from './_lib/types'
2
+ import { PBKDF2_ITERATIONS, verifyPasscode } from './_lib/passcode'
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 PBKDF2 cost when the
21
+ // owner does not exist. Without it, an unknown owner returns after one D1 read
22
+ // (single-digit ms) while a known one spends ~250ms deriving — a timing oracle
23
+ // three orders of magnitude wide, which enumerates the enrolled people outright
24
+ // and makes the identical DENIED payload above cosmetic.
25
+ const DUMMY_SALT = '00000000000000000000000000000000'
26
+ const DUMMY_HASH = '0'.repeat(64)
27
+
28
+ export async function processAuth(
29
+ body: AuthBody,
30
+ env: PortalEnv,
31
+ log: Logger,
32
+ newId: () => string,
33
+ nowMs: number,
34
+ clientIp: string,
35
+ ): Promise<AuthResult> {
36
+ const ownerId = typeof body?.ownerId === 'string' ? body.ownerId : ''
37
+ const passcode = typeof body?.passcode === 'string' ? body.passcode : ''
38
+ if (!ownerId || !passcode) {
39
+ log('[data-portal] op=auth owner=none result=malformed')
40
+ return { status: 400, payload: { ok: false, error: 'ownerId and passcode required' } }
41
+ }
42
+
43
+ // Rate-limit BEFORE any passcode comparison, so a guessing loop is throttled
44
+ // rather than merely logged. The scope carries the client IP as well as the
45
+ // owner: keyed on the owner alone, the counter is a weapon — six requests
46
+ // naming a person lock that person out of their own portal for the window,
47
+ // from anywhere, unauthenticated.
48
+ const scope = rateScope(ownerId, clientIp)
49
+ const rate = await checkAndRecordAttempt(env.DB, scope, nowMs)
50
+ if (!rate.allowed) {
51
+ log(`[data-portal] op=auth owner=${q(ownerId)} result=rate-limited attempts=${rate.count}`)
52
+ return { status: 429, payload: { ok: false, error: 'too many attempts' } }
53
+ }
54
+
55
+ const person = await env.DB.prepare(
56
+ 'SELECT ownerId, salt, hash, iterations FROM people WHERE ownerId = ?',
57
+ )
58
+ .bind(ownerId)
59
+ .first<{ ownerId: string; salt: string; hash: string; iterations: number }>()
60
+
61
+ if (!person) {
62
+ // Burn the same work an enrolled owner would cost, then deny identically.
63
+ await verifyPasscode(passcode, DUMMY_SALT, DUMMY_HASH, PBKDF2_ITERATIONS)
64
+ log(`[data-portal] op=auth owner=${q(ownerId)} result=denied reason=no-such-owner`)
65
+ return DENIED
66
+ }
67
+
68
+ const ok = await verifyPasscode(passcode, person.salt, person.hash, person.iterations)
69
+ if (!ok) {
70
+ log(`[data-portal] op=auth owner=${q(ownerId)} result=denied reason=bad-passcode`)
71
+ return DENIED
72
+ }
73
+
74
+ // A successful sign-in clears the counter. Without this, six legitimate
75
+ // sign-ins inside one window lock the person out of their own files.
76
+ await clearAttempts(env.DB, scope)
77
+
78
+ const sessionId = newId()
79
+ await mintSession(env.DB, ownerId, sessionId, nowMs, passcodeCheck(person.hash))
80
+ log(`[data-portal] op=auth owner=${q(ownerId)} result=ok`)
81
+ return { status: 200, payload: { ok: true }, sessionId }
82
+ }
83
+
84
+ interface PagesContext {
85
+ request: Request
86
+ env: PortalEnv
87
+ }
88
+
89
+ export async function onRequestPost(context: PagesContext): Promise<Response> {
90
+ let body: AuthBody
91
+ try {
92
+ body = (await context.request.json()) as AuthBody
93
+ } catch {
94
+ return Response.json({ ok: false, error: 'invalid JSON' }, { status: 400 })
95
+ }
96
+ const { status, payload, sessionId } = await processAuth(
97
+ body,
98
+ context.env,
99
+ (line) => console.log(line),
100
+ () => crypto.randomUUID(),
101
+ Date.now(),
102
+ context.request.headers.get('cf-connecting-ip') ?? 'unknown',
103
+ )
104
+ const res = Response.json(payload, { status })
105
+ if (sessionId) {
106
+ // Secure + Path=/ + no Domain are the __Host- prefix's preconditions.
107
+ res.headers.append(
108
+ 'Set-Cookie',
109
+ `${SESSION_COOKIE}=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_MS / 1000}`,
110
+ )
111
+ }
112
+ return res
113
+ }
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,41 @@
1
+ import type { Handler, Logger, PortalEnv } from './_lib/types'
2
+ import { readSessionCookie, resolveSession } from './_lib/session'
3
+ import { q } from './_lib/log'
4
+
5
+ export async function processFiles(
6
+ sessionId: string,
7
+ env: PortalEnv,
8
+ log: Logger,
9
+ nowMs: number,
10
+ ): Promise<Handler> {
11
+ const session = await resolveSession(env.DB, sessionId, nowMs)
12
+ if (!session) {
13
+ log('[data-portal] op=list owner=none result=denied')
14
+ return { status: 401, payload: { ok: false, error: 'denied' } }
15
+ }
16
+ // Scoped by ownerId, not by key prefix: the owner is the authority.
17
+ const rows = await env.DB.prepare(
18
+ 'SELECT fileId, filename, objectKey, size, uploadedAt, ingested FROM manifest WHERE ownerId = ? ORDER BY uploadedAt DESC',
19
+ )
20
+ .bind(session.ownerId)
21
+ .all<Record<string, unknown>>()
22
+ const files = rows.results ?? []
23
+ log(`[data-portal] op=list owner=${q(session.ownerId)} result=ok files=${files.length}`)
24
+ return { status: 200, payload: { ok: true, files } }
25
+ }
26
+
27
+ interface PagesContext {
28
+ request: Request
29
+ env: PortalEnv
30
+ }
31
+
32
+ export async function onRequestGet(context: PagesContext): Promise<Response> {
33
+ const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
34
+ const { status, payload } = await processFiles(
35
+ sessionId,
36
+ context.env,
37
+ (line) => console.log(line),
38
+ Date.now(),
39
+ )
40
+ return Response.json(payload, { status })
41
+ }
@@ -0,0 +1,151 @@
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, ownerPrefix } from './_lib/authorize'
5
+
6
+ const MAX_FILENAME = 200
7
+
8
+ // An upload is read wholly into the Function's memory, so it needs a bound or a
9
+ // large one OOMs the isolate — and an OOM emits no [data-portal] line at all,
10
+ // making it exactly the unobservable failure this portal's lifeline exists to
11
+ // prevent. 25 MiB is chosen for the documents this portal is for (invoices,
12
+ // photos, spreadsheets) and is deliberately NOT the storage broker's 100 MiB
13
+ // (task 1695): that one bounds a house-process spike, this one bounds an edge
14
+ // isolate, and the two must move independently.
15
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024
16
+
17
+ export async function processUpload(
18
+ sessionId: string,
19
+ filename: string,
20
+ bytes: Uint8Array,
21
+ env: PortalEnv,
22
+ log: Logger,
23
+ newId: () => string,
24
+ now: () => number,
25
+ ): Promise<Handler> {
26
+ // `now` is a clock, not a scalar: op=complete reports elapsed time, and a
27
+ // single injected instant cannot measure elapsed anything. It previously
28
+ // printed a hardcoded ms=0, which an operator reads as "took no time" rather
29
+ // than "was never measured".
30
+ const startedMs = now()
31
+ const session = await resolveSession(env.DB, sessionId, startedMs)
32
+ if (!session) {
33
+ log('[data-portal] op=upload owner=none result=denied')
34
+ return { status: 401, payload: { ok: false, error: 'denied' } }
35
+ }
36
+ const owner = session.ownerId
37
+ const uploadId = newId()
38
+
39
+ if (!filename || filename.length > MAX_FILENAME) {
40
+ log(`[data-portal] op=request uploadId=${uploadId} owner=${q(owner)} result=bad-filename`)
41
+ return { status: 400, payload: { ok: false, error: 'filename required' } }
42
+ }
43
+
44
+ const key = `${ownerPrefix(owner)}${filename}`
45
+ // The key is derived from the session owner, then re-checked through the same
46
+ // gate the other routes use — a filename carrying '..' must not escape.
47
+ if (!authorizeKey(owner, key)) {
48
+ log(`[data-portal] op=request uploadId=${uploadId} owner=${q(owner)} result=bad-filename`)
49
+ return { status: 400, payload: { ok: false, error: 'invalid filename' } }
50
+ }
51
+
52
+ if (bytes.byteLength > MAX_UPLOAD_BYTES) {
53
+ log(
54
+ `[data-portal] op=request uploadId=${uploadId} owner=${q(owner)} filename=${q(filename)} bytes=${bytes.byteLength} result=too-large limit=${MAX_UPLOAD_BYTES}`,
55
+ )
56
+ return {
57
+ status: 413,
58
+ payload: {
59
+ ok: false,
60
+ error: `file is ${bytes.byteLength} bytes, over the ${MAX_UPLOAD_BYTES}-byte limit`,
61
+ },
62
+ }
63
+ }
64
+
65
+ log(
66
+ `[data-portal] op=request uploadId=${uploadId} owner=${q(owner)} filename=${q(filename)} bytes=${bytes.byteLength}`,
67
+ )
68
+
69
+ try {
70
+ await env.BUCKET.put(key, bytes)
71
+ log(`[data-portal] op=r2-put uploadId=${uploadId} key=${q(key)} result=ok`)
72
+ } catch (err) {
73
+ log(
74
+ `[data-portal] op=r2-put uploadId=${uploadId} key=${q(key)} result=failed err="${(err as Error).message}"`,
75
+ )
76
+ return { status: 502, payload: { ok: false, error: 'store failed' } }
77
+ }
78
+
79
+ const fileId = newId()
80
+ try {
81
+ // Upsert, not insert. R2 overwrote the object above unconditionally, so a
82
+ // re-upload of the same filename has already replaced the file whatever the
83
+ // manifest does; a plain INSERT would hit `objectKey UNIQUE`, throw, and
84
+ // leave the row describing bytes that no longer exist while telling the
85
+ // person their upload failed. Resetting `ingested` is the load-bearing
86
+ // part: without it a replacement of an already-swept file would never be
87
+ // ingested, and nothing would ever say so.
88
+ await env.DB.prepare(
89
+ `INSERT INTO manifest (fileId, ownerId, filename, objectKey, size, uploadedAt, ingested)
90
+ VALUES (?, ?, ?, ?, ?, ?, ?)
91
+ ON CONFLICT (objectKey) DO UPDATE SET
92
+ fileId = excluded.fileId,
93
+ size = excluded.size,
94
+ uploadedAt = excluded.uploadedAt,
95
+ ingested = 0`,
96
+ )
97
+ .bind(fileId, owner, filename, key, bytes.byteLength, new Date(startedMs).toISOString(), 0)
98
+ .run()
99
+ log(`[data-portal] op=manifest-write uploadId=${uploadId} rowId=${fileId} result=ok`)
100
+ } catch (err) {
101
+ log(
102
+ `[data-portal] op=manifest-write uploadId=${uploadId} rowId=${fileId} result=failed err="${(err as Error).message}"`,
103
+ )
104
+ return { status: 502, payload: { ok: false, error: 'manifest failed' } }
105
+ }
106
+
107
+ // The verified post-condition: re-read both sides rather than assume the
108
+ // writes above landed. An orphan object or a phantom row emits no error at
109
+ // request time, which is why this line exists.
110
+ const objectPresent = (await env.BUCKET.head(key)) !== null
111
+ const rowPresent =
112
+ (await env.DB.prepare('SELECT fileId FROM manifest WHERE objectKey = ?').bind(key).first()) !==
113
+ null
114
+ log(
115
+ `[data-portal] op=complete uploadId=${uploadId} ms=${now() - startedMs} objectPresent=${objectPresent} rowPresent=${rowPresent}`,
116
+ )
117
+
118
+ return { status: 200, payload: { ok: true, fileId, objectPresent, rowPresent } }
119
+ }
120
+
121
+ interface PagesContext {
122
+ request: Request
123
+ env: PortalEnv
124
+ }
125
+
126
+ export async function onRequestPost(context: PagesContext): Promise<Response> {
127
+ const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
128
+ let filename = ''
129
+ let bytes: Uint8Array
130
+ try {
131
+ const form = await context.request.formData()
132
+ const file = form.get('file')
133
+ if (!(file instanceof File)) {
134
+ return Response.json({ ok: false, error: 'file required' }, { status: 400 })
135
+ }
136
+ filename = file.name
137
+ bytes = new Uint8Array(await file.arrayBuffer())
138
+ } catch {
139
+ return Response.json({ ok: false, error: 'invalid form' }, { status: 400 })
140
+ }
141
+ const { status, payload } = await processUpload(
142
+ sessionId,
143
+ filename,
144
+ bytes,
145
+ context.env,
146
+ (line) => console.log(line),
147
+ () => crypto.randomUUID(),
148
+ () => Date.now(),
149
+ )
150
+ return Response.json(payload, { status })
151
+ }
@@ -0,0 +1,42 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>File drop</title>
7
+ <!-- Same-origin: the API is served by this project's own Pages Functions,
8
+ so api-base is deliberately empty (Task 1307's convention). -->
9
+ <meta name="api-base" content="" />
10
+ <link rel="stylesheet" href="/portal.css" />
11
+ </head>
12
+ <body>
13
+ <main class="dp-card">
14
+ <h1 class="dp-title">File drop</h1>
15
+ <p id="dp-status" class="dp-status" role="status"></p>
16
+
17
+ <form id="dp-login" class="dp-login">
18
+ <label class="dp-label" for="dp-owner">Your name</label>
19
+ <input class="dp-input" id="dp-owner" name="owner" autocomplete="username" required />
20
+ <label class="dp-label" for="dp-passcode">Passcode</label>
21
+ <input
22
+ class="dp-input"
23
+ id="dp-passcode"
24
+ name="passcode"
25
+ type="password"
26
+ autocomplete="current-password"
27
+ required
28
+ />
29
+ <button class="dp-btn" type="submit">Sign in</button>
30
+ </form>
31
+
32
+ <section id="dp-panel" class="dp-panel" hidden>
33
+ <form id="dp-upload" class="dp-upload">
34
+ <input class="dp-file" id="dp-file" type="file" required />
35
+ <button class="dp-btn" type="submit">Upload</button>
36
+ </form>
37
+ <ul id="dp-files" class="dp-files"></ul>
38
+ </section>
39
+ </main>
40
+ <script src="/portal.js"></script>
41
+ </body>
42
+ </html>