@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,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 --allowJs --checkJs --target es2022 --module esnext --moduleResolution bundler --lib es2022,dom ../skills/*/template/functions/api/*.ts ../skills/*/template/functions/api/_lib/*.ts ../skills/*/template/functions/api/_lib/*.mjs"
|
|
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
|
}
|
|
@@ -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.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: data-portal
|
|
3
|
+
description: Stand up a per-account gated file-drop portal on its own custom domain, where named people each sign in with their own passcode and can upload, list, download and delete only their own files. Files land in one R2 bucket under a per-person prefix and every upload writes a D1 manifest row the agent later ingests. Use when an account needs somewhere for named people to drop photos, invoices or spreadsheets — "set up a file drop for <client>", "somewhere for <person> to send us their invoices", "a place clients can upload documents".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Data portal
|
|
7
|
+
|
|
8
|
+
Assemble and deploy one account's file-drop portal. This is an outcome contract, not a copy of the
|
|
9
|
+
Pages mechanics. The deploy, custom-domain attach, token discipline, and live done-gate are owned by
|
|
10
|
+
`site-deploy`; the R2 store rules are owned by `references/r2-object-storage.md`. Read those
|
|
11
|
+
references with `plugin-read` before issuing their commands.
|
|
12
|
+
|
|
13
|
+
## What this skill produces, and what it does not
|
|
14
|
+
|
|
15
|
+
It produces a Pages project on a custom domain, an R2 bucket holding one prefix per enrolled person,
|
|
16
|
+
and a D1 database holding the manifest, the enrolment records, and the sessions. It does not ingest
|
|
17
|
+
the dropped files: a separate device-side sweep reads un-ingested manifest rows and pulls them into
|
|
18
|
+
the graph. This skill delivers the manifest that sweep consumes.
|
|
19
|
+
|
|
20
|
+
## Why the portal is on Pages and not the install
|
|
21
|
+
|
|
22
|
+
Uploads must keep succeeding while the device is offline, which is the same reason the booking page
|
|
23
|
+
went to Pages. The install's magic-link gate cannot be reused: it gates `/api/chat` on the install
|
|
24
|
+
and its sessions live in memory, so it does not cross origins.
|
|
25
|
+
|
|
26
|
+
## Provisioning is two sessions, and the order matters
|
|
27
|
+
|
|
28
|
+
The storage broker registers a bucket to **whoever creates it**, and that ownership is set on create
|
|
29
|
+
and never reassigned. There is no delegation.
|
|
30
|
+
|
|
31
|
+
1. **In the client account's own session**, create the bucket with `storage-r2-bucket-create`, named
|
|
32
|
+
from `bin/cf-store-name.sh <accountId> <purpose>`. It registers to that account.
|
|
33
|
+
2. **In the admin session**, do everything else: the D1 database, the assembly, and the deploy.
|
|
34
|
+
|
|
35
|
+
If the admin creates the bucket instead, it registers to the house, and the client account's own
|
|
36
|
+
ingestion sweep is denied `cross-account` against its own files — permanently, because ownership is
|
|
37
|
+
never reassigned. The portal would appear to work while nothing on the client side could read what
|
|
38
|
+
it stored.
|
|
39
|
+
|
|
40
|
+
## Enrol the people first
|
|
41
|
+
|
|
42
|
+
Collect from the operator the portal's custom domain and the list of people, then write
|
|
43
|
+
`<accountDir>/data-portal.json` carrying `portalDomain`, `portalDbName`, `bucketName`, and a
|
|
44
|
+
`people` array of `{ name, ownerId, prefix }`.
|
|
45
|
+
|
|
46
|
+
For each person:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
node platform/plugins/cloudflare/bin/portal-enrol.mjs --owner <ownerId> --name <name> \
|
|
50
|
+
| npx wrangler d1 execute <portalDbName> --remote --file=-
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The script mints the passcode and prints it **once to stderr**, so it reaches the operator's terminal
|
|
54
|
+
and never the pipe; stdout carries the `INSERT` alone. Do not hand-roll this: the passcode's length
|
|
55
|
+
and alphabet are pinned in `template/functions/api/_lib/passcode.mjs`, the script is what applies
|
|
56
|
+
them, and a passcode minted any other way is unpinned by definition. The script refuses an `ownerId`
|
|
57
|
+
the isolation gate would reject, so a bad name fails here rather than after the row lands.
|
|
58
|
+
|
|
59
|
+
The statement is an upsert on `ownerId`, which is what makes rotation a re-run rather than a
|
|
60
|
+
constraint error.
|
|
61
|
+
|
|
62
|
+
**A passcode is never stored in plaintext** — not in `data-portal.json`, not in a log line, nowhere
|
|
63
|
+
on the device. The operator conveys it to the person out of band. Rotating a passcode is a re-run of
|
|
64
|
+
the script for that person; there is no separate editor.
|
|
65
|
+
|
|
66
|
+
Rotation also revokes: a session records a digest of the person's passcode hash at sign-in, and the
|
|
67
|
+
session lookup requires it to still match, so re-running enrolment kills every live session for that
|
|
68
|
+
person immediately. There is no cleanup step to remember and no window to wait out — which is the
|
|
69
|
+
point, because a leaked passcode is exactly the case where a procedure you have to remember fails.
|
|
70
|
+
Deleting the person's row revokes their sessions the same way.
|
|
71
|
+
|
|
72
|
+
The `ownerId` must match `[a-z0-9-]+`. This is not cosmetic: the whole isolation rule is that a key
|
|
73
|
+
starts with `<ownerId>/`, so an `ownerId` containing a separator would let one person read another's
|
|
74
|
+
files. The gate enforces it too, but do not enrol a name it will reject.
|
|
75
|
+
|
|
76
|
+
## Assemble the canonical tree
|
|
77
|
+
|
|
78
|
+
Copy `template/` to `<accountDir>/pages/<project>/` and fill `wrangler.toml`'s `__PROJECT_NAME__`,
|
|
79
|
+
`__D1_DATABASE_NAME__`, `__D1_DATABASE_ID__`, and `__R2_BUCKET_NAME__`. **Leave no `__...__`
|
|
80
|
+
placeholder in the assembled tree.**
|
|
81
|
+
|
|
82
|
+
Name the D1 database with the same `bin/cf-store-name.sh` scheme as the bucket, and apply
|
|
83
|
+
`schema.sql` to the remote database. It is `IF NOT EXISTS` throughout, so re-applying is safe.
|
|
84
|
+
|
|
85
|
+
## Deploy through site-deploy
|
|
86
|
+
|
|
87
|
+
Hand the assembled tree to `cloudflare:site-deploy`. Both bindings — D1 and R2 — are declarative
|
|
88
|
+
config in `wrangler.toml`, carried in the deploy payload under the Pages scope; the deploy needs no
|
|
89
|
+
R2 token. Only the bucket *creation* in step 1 needs R2 write, and the broker holds that.
|
|
90
|
+
|
|
91
|
+
## Done-gate
|
|
92
|
+
|
|
93
|
+
The portal is not done until all five hold on the live domain:
|
|
94
|
+
|
|
95
|
+
1. A cache-busted request to the portal domain returns `HTTP/2 200`.
|
|
96
|
+
2. Signing in with the right passcode returns `200`.
|
|
97
|
+
3. A wrong passcode returns `401`; the **sixth** rapid wrong passcode returns `429` (five are
|
|
98
|
+
allowed per fifteen minutes, per person, per IP).
|
|
99
|
+
4. An upload as one person returns `200`, and the object is present in R2 under that person's
|
|
100
|
+
prefix with exactly one manifest row at `ingested=0`. Re-uploading the same filename also
|
|
101
|
+
returns `200`, replaces the object, and leaves exactly one row, now back at `ingested=0`.
|
|
102
|
+
5. That person's browser cannot list, download, or delete another person's file.
|
|
103
|
+
|
|
104
|
+
A `502` on `/api/upload` with `op=r2-put result=failed` on every attempt is the expected signature of
|
|
105
|
+
an R2 binding that did not deploy — check `wrangler.toml` reached the tree with its placeholder
|
|
106
|
+
filled.
|
|
107
|
+
|
|
108
|
+
Uploads are capped at 25 MiB and a larger one returns `413` with `result=too-large`.
|
|
109
|
+
|
|
110
|
+
## Observability
|
|
111
|
+
|
|
112
|
+
Every step of an upload emits one `[data-portal]` line correlated by `uploadId`, ending in
|
|
113
|
+
`op=complete` with `objectPresent` and `rowPresent` **re-read** rather than assumed. Passcodes,
|
|
114
|
+
hashes, and object bodies are never logged.
|
|
115
|
+
|
|
116
|
+
Read one upload's full lifeline by filtering the brand journal on its `uploadId`.
|
|
117
|
+
|
|
118
|
+
The standing audit that would reconcile R2 objects against manifest rows is not built yet. Until it
|
|
119
|
+
is, an object with no manifest row and a row with no object are both invisible: neither emits an
|
|
120
|
+
event at request time.
|
|
121
|
+
|
|
122
|
+
## Tool discipline
|
|
123
|
+
|
|
124
|
+
Never write a token into the tree, never commit one, never echo one. The bucket name and database
|
|
125
|
+
name come from `bin/cf-store-name.sh`, never hand-built.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The isolation rule.
|
|
2
|
+
//
|
|
3
|
+
// Every file op authorizes owner-vs-key here BEFORE any R2 call. The
|
|
4
|
+
// per-person key prefix is a consequence of this check, never the check
|
|
5
|
+
// itself: a caller could otherwise reach another person's files by naming
|
|
6
|
+
// them, and prefix convention alone would not stop it.
|
|
7
|
+
|
|
8
|
+
const SEP = '/'
|
|
9
|
+
|
|
10
|
+
// The whole gate rests on an ownerId that cannot contain the separator. If two
|
|
11
|
+
// people were ever enrolled as 'alice' and 'alice/2024', then
|
|
12
|
+
// 'alice/2024/invoice.pdf' starts with 'alice/' and alice would read the other
|
|
13
|
+
// person's files. Enrolment is an agent writing rows, so this invariant cannot
|
|
14
|
+
// live only in the skill's prose — it is enforced here, where the decision is
|
|
15
|
+
// actually made, and it fails closed.
|
|
16
|
+
const OWNER_ID = /^[a-z0-9-]+$/
|
|
17
|
+
|
|
18
|
+
export function ownerPrefix(ownerId: string): string {
|
|
19
|
+
return `${ownerId}${SEP}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function authorizeKey(ownerId: string, key: string): boolean {
|
|
23
|
+
if (!ownerId || !key) return false
|
|
24
|
+
if (!OWNER_ID.test(ownerId)) return false
|
|
25
|
+
// Reject traversal and absolute forms outright rather than normalising them:
|
|
26
|
+
// a normaliser is one more thing that can be wrong, and no legitimate key
|
|
27
|
+
// contains these.
|
|
28
|
+
if (key.includes('..') || key.startsWith(SEP)) return false
|
|
29
|
+
// startsWith(ownerId) alone would authorize 'alice-evil/...' for 'alice'.
|
|
30
|
+
// The separator is part of the rule.
|
|
31
|
+
return key.startsWith(ownerPrefix(ownerId))
|
|
32
|
+
}
|
package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/log.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The log grammar is `[tag] op=<verb> key=value …`, space-separated. Any field
|
|
2
|
+
// carrying free text has to be quoted, because the values here are not ours:
|
|
3
|
+
// `ownerId` is unvalidated JSON off the wire at auth time, and `filename` comes
|
|
4
|
+
// straight from a multipart part name. Unquoted, a newline forges whole log
|
|
5
|
+
// lines and an embedded `result=ok` corrupts the line's parse — in a portal
|
|
6
|
+
// where the lifeline IS the diagnostic contract, and whose deferred reconcile
|
|
7
|
+
// audit is specified to read these lines back.
|
|
8
|
+
|
|
9
|
+
/** Render a free-text value as a quoted, single-line log field. */
|
|
10
|
+
export function q(value: string): string {
|
|
11
|
+
return `"${value.replace(/[\\"]/g, '\\$&').replace(/[\r\n\t]/g, ' ')}"`
|
|
12
|
+
}
|