amorphous-cli 0.1.13 → 0.3.0
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/README.md +4 -0
- package/lib/agent-contract.mjs +1 -1
- package/lib/agent-contract.test.mjs +15 -0
- package/lib/agent-guide-commands.test.mjs +429 -0
- package/lib/agent-guides-files.mjs +541 -0
- package/lib/agent-guides-files.test.mjs +367 -0
- package/lib/api.mjs +12 -7
- package/lib/app-origins.mjs +52 -0
- package/lib/app-origins.test.mjs +40 -0
- package/lib/commands.mjs +306 -29
- package/lib/files.mjs +10 -2
- package/lib/help.mjs +17 -5
- package/lib/participant-client.mjs +82 -0
- package/lib/participant-client.test.mjs +34 -0
- package/lib/pull.test.mjs +42 -0
- package/lib/recipe-create.test.mjs +37 -0
- package/lib/serve.mjs +152 -111
- package/lib/serve.test.mjs +180 -22
- package/package.json +5 -2
- package/participant-mcp.mjs +55 -0
- package/skill/amorphous/SKILL.md +6 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import { writePulledProject } from './commands.mjs'
|
|
7
|
+
|
|
8
|
+
const detail = { abstract: {
|
|
9
|
+
draft_html: '<main>Editable</main>', draft_css: 'main{color:black}', draft_js: 'window.ready=true',
|
|
10
|
+
draft_recovery_contract: { version: 1, status: 'declared', privateReferences: [] },
|
|
11
|
+
} }
|
|
12
|
+
|
|
13
|
+
test('explicit pull writes editable source, empty data files, metadata, and the current contract', () => {
|
|
14
|
+
const cwd = mkdtempSync(join(tmpdir(), 'amorphous-pull-'))
|
|
15
|
+
writePulledProject(cwd, '00000000-0000-4000-8000-000000000001', detail, { records: [] }, { fresh: true })
|
|
16
|
+
assert.equal(readFileSync(join(cwd, 'index.html'), 'utf8'), detail.abstract.draft_html)
|
|
17
|
+
assert.equal(readFileSync(join(cwd, 'styles.css'), 'utf8'), detail.abstract.draft_css)
|
|
18
|
+
assert.equal(readFileSync(join(cwd, 'app.js'), 'utf8'), detail.abstract.draft_js)
|
|
19
|
+
assert.equal(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), '')
|
|
20
|
+
assert.equal(readFileSync(join(cwd, 'private.ndjson'), 'utf8'), '')
|
|
21
|
+
const metadata = JSON.parse(readFileSync(join(cwd, '.amorphous.json'), 'utf8'))
|
|
22
|
+
assert.equal(metadata.abstractId, '00000000-0000-4000-8000-000000000001')
|
|
23
|
+
assert.deepEqual(metadata.recoveryContract, detail.abstract.draft_recovery_contract)
|
|
24
|
+
assert.ok(readdirSync(cwd).includes('AGENTS.md'))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('explicit pull refuses a populated unrelated directory without changing it', () => {
|
|
28
|
+
const cwd = mkdtempSync(join(tmpdir(), 'amorphous-pull-'))
|
|
29
|
+
writeFileSync(join(cwd, 'notes.txt'), 'keep')
|
|
30
|
+
assert.throws(() => writePulledProject(cwd, '00000000-0000-4000-8000-000000000001', detail,
|
|
31
|
+
{ records: [] }, { fresh: true }), /empty directory/)
|
|
32
|
+
assert.deepEqual(readdirSync(cwd), ['notes.txt'])
|
|
33
|
+
assert.equal(readFileSync(join(cwd, 'notes.txt'), 'utf8'), 'keep')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('ordinary pull into an existing connected folder remains supported', () => {
|
|
37
|
+
const cwd = mkdtempSync(join(tmpdir(), 'amorphous-pull-'))
|
|
38
|
+
mkdirSync(join(cwd, 'keep'))
|
|
39
|
+
writePulledProject(cwd, '00000000-0000-4000-8000-000000000001', detail, { records: [] })
|
|
40
|
+
assert.equal(readFileSync(join(cwd, 'index.html'), 'utf8'), detail.abstract.draft_html)
|
|
41
|
+
assert.equal(statSync(join(cwd, 'keep')).isDirectory(), true)
|
|
42
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import { writeRecipeFiles } from './commands.mjs'
|
|
7
|
+
import { printHelp } from './help.mjs'
|
|
8
|
+
|
|
9
|
+
test('recipe source writer saves the exact returned source bundle', () => {
|
|
10
|
+
const cwd = mkdtempSync(join(tmpdir(), 'amorphous-recipe-'))
|
|
11
|
+
const source = { html: '<h1>My app</h1>\n', css: 'body{color:black}\n', js: 'console.log("ok")\n' }
|
|
12
|
+
try {
|
|
13
|
+
writeRecipeFiles(cwd, source)
|
|
14
|
+
assert.equal(readFileSync(join(cwd, 'index.html'), 'utf8'), source.html)
|
|
15
|
+
assert.equal(readFileSync(join(cwd, 'styles.css'), 'utf8'), source.css)
|
|
16
|
+
assert.equal(readFileSync(join(cwd, 'app.js'), 'utf8'), source.js)
|
|
17
|
+
assert.equal(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), '')
|
|
18
|
+
assert.equal(readFileSync(join(cwd, 'private.ndjson'), 'utf8'), '')
|
|
19
|
+
} finally { rmSync(cwd, { recursive: true, force: true }) }
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('recipe source writer refuses to overwrite an existing project file', () => {
|
|
23
|
+
const cwd = mkdtempSync(join(tmpdir(), 'amorphous-recipe-'))
|
|
24
|
+
try {
|
|
25
|
+
writeFileSync(join(cwd, 'app.js'), 'existing')
|
|
26
|
+
assert.throws(() => writeRecipeFiles(cwd, { html: '', css: '', js: 'new' }), /empty source folder/)
|
|
27
|
+
assert.equal(readFileSync(join(cwd, 'app.js'), 'utf8'), 'existing')
|
|
28
|
+
} finally { rmSync(cwd, { recursive: true, force: true }) }
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('create help documents recipe config mode', () => {
|
|
32
|
+
const previous = console.log
|
|
33
|
+
let output = ''
|
|
34
|
+
console.log = value => { output = String(value) }
|
|
35
|
+
try { printHelp('create') } finally { console.log = previous }
|
|
36
|
+
assert.match(output, /--recipe-config <file>/)
|
|
37
|
+
})
|
package/lib/serve.mjs
CHANGED
|
@@ -2,12 +2,13 @@ import { createServer } from 'node:http'
|
|
|
2
2
|
import { existsSync, readFileSync, watch } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { apiFetch } from './api.mjs'
|
|
5
|
-
import {
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
6
|
import { insertLocalRecord, readProjectFiles, recordsFromLocal, replaceLocalRecord } from './files.mjs'
|
|
7
7
|
|
|
8
8
|
const BAR_CSS = `#amorphous-serve{position:fixed;z-index:2147483646;left:12px;bottom:12px;display:flex;gap:6px;align-items:center;padding:4px 6px 4px 10px;border-radius:999px;background:#18181b;color:#fff;font:500 12px/1 system-ui,-apple-system,sans-serif}
|
|
9
9
|
#amorphous-serve button{border:0;border-radius:999px;padding:4px 8px;font:inherit;color:#a1a1aa;background:transparent;cursor:pointer}
|
|
10
10
|
#amorphous-serve button[aria-pressed="true"]{background:#fff;color:#18181b}`
|
|
11
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
11
12
|
|
|
12
13
|
function stripScriptTags(html) {
|
|
13
14
|
return String(html || '').replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
@@ -28,7 +29,16 @@ function recordsMode(req, url) {
|
|
|
28
29
|
|
|
29
30
|
async function readJson(req) {
|
|
30
31
|
const chunks = []
|
|
31
|
-
|
|
32
|
+
let length = 0
|
|
33
|
+
for await (const chunk of req) {
|
|
34
|
+
length += chunk.length
|
|
35
|
+
if (length > 64 * 1024) {
|
|
36
|
+
const error = new Error('Request too large')
|
|
37
|
+
error.status = 413
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
chunks.push(chunk)
|
|
41
|
+
}
|
|
32
42
|
const text = Buffer.concat(chunks).toString('utf8')
|
|
33
43
|
if (!text) return {}
|
|
34
44
|
return JSON.parse(text)
|
|
@@ -44,7 +54,7 @@ function send(res, status, body, headers = {}) {
|
|
|
44
54
|
res.end(payload)
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
function pageHtml(html, mode) {
|
|
57
|
+
function pageHtml(html, mode, environment) {
|
|
48
58
|
const pressed = (value) => (mode === value ? ' aria-pressed="true"' : '')
|
|
49
59
|
return `<!DOCTYPE html>
|
|
50
60
|
<html>
|
|
@@ -58,9 +68,15 @@ function pageHtml(html, mode) {
|
|
|
58
68
|
<div id="amorphous-serve" data-mode="${mode}">
|
|
59
69
|
<span>Records</span>
|
|
60
70
|
<button type="button" data-set="local"${pressed('local')}>Local</button>
|
|
61
|
-
<button type="button" data-set="prod"${pressed('prod')}
|
|
71
|
+
<button type="button" data-set="prod"${pressed('prod')}>${environment === 'preview' ? 'Preview' : 'Live'}</button>
|
|
62
72
|
</div>
|
|
63
73
|
<script>
|
|
74
|
+
function recordFromResponse(payload) {
|
|
75
|
+
var body = payload && payload.body && typeof payload.body === 'object' ? payload.body : payload
|
|
76
|
+
return Object.assign({}, body, { id: payload.id, revision: payload.revision,
|
|
77
|
+
visibility: payload.visibility, collection: payload.collection,
|
|
78
|
+
created_by: payload.created_by == null ? null : payload.created_by })
|
|
79
|
+
}
|
|
64
80
|
window.amorphous = {
|
|
65
81
|
login() {},
|
|
66
82
|
async me() {
|
|
@@ -95,7 +111,20 @@ window.amorphous = {
|
|
|
95
111
|
body: JSON.stringify(body)
|
|
96
112
|
})
|
|
97
113
|
if (!res.ok) throw new Error('insert failed: ' + res.status)
|
|
98
|
-
return
|
|
114
|
+
return recordFromResponse(await res.json())
|
|
115
|
+
},
|
|
116
|
+
async history(recordId) {
|
|
117
|
+
const res = await fetch('/__amorphous/recovery/' + encodeURIComponent(recordId))
|
|
118
|
+
if (!res.ok) throw new Error('recovery history unavailable: ' + res.status)
|
|
119
|
+
return res.json()
|
|
120
|
+
},
|
|
121
|
+
async undo(recordId, historyId) {
|
|
122
|
+
const res = await fetch('/__amorphous/recovery/' + encodeURIComponent(recordId), {
|
|
123
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({ historyId, operationKey: crypto.randomUUID() })
|
|
125
|
+
})
|
|
126
|
+
if (!res.ok) throw new Error('undo failed: ' + res.status)
|
|
127
|
+
return res.json()
|
|
99
128
|
},
|
|
100
129
|
async replace(id, row) {
|
|
101
130
|
const res = await fetch('/__amorphous/data/' + encodeURIComponent(id), {
|
|
@@ -104,7 +133,7 @@ window.amorphous = {
|
|
|
104
133
|
body: JSON.stringify(row)
|
|
105
134
|
})
|
|
106
135
|
if (!res.ok) throw new Error('replace failed: ' + res.status)
|
|
107
|
-
return
|
|
136
|
+
return recordFromResponse(await res.json())
|
|
108
137
|
}
|
|
109
138
|
}
|
|
110
139
|
}
|
|
@@ -126,66 +155,13 @@ new EventSource('/__amorphous/events').addEventListener('reload', function () {
|
|
|
126
155
|
</html>`
|
|
127
156
|
}
|
|
128
157
|
|
|
129
|
-
function passFromResponse(res) {
|
|
130
|
-
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
|
|
131
|
-
const lines = list.length ? list : [res.headers.get('set-cookie') || '']
|
|
132
|
-
for (const line of lines) {
|
|
133
|
-
const match = /(?:^|,\s*)am_pass=([^;]+)/.exec(line)
|
|
134
|
-
if (match) return `am_pass=${match[1]}`
|
|
135
|
-
}
|
|
136
|
-
return null
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export async function mintAbstractPass(abstractId) {
|
|
140
|
-
const data = await apiFetch(`/api/abstracts/${abstractId}/code`, { method: 'POST', body: '{}' })
|
|
141
|
-
const code = data?.code
|
|
142
|
-
if (!code) throw new Error('Could not open the abstract records.')
|
|
143
|
-
const res = await fetch(`${cliAbstractsFetchOrigin()}/${abstractId}?code=${encodeURIComponent(code)}`, {
|
|
144
|
-
redirect: 'manual',
|
|
145
|
-
headers: { ...cliAbstractsHostHeader() },
|
|
146
|
-
})
|
|
147
|
-
const cookie = passFromResponse(res)
|
|
148
|
-
if (!cookie) throw new Error('Could not open the abstract records.')
|
|
149
|
-
return cookie
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function proxyAbstract(abstractId, path, init, session) {
|
|
153
|
-
const headers = {
|
|
154
|
-
...cliAbstractsHostHeader(),
|
|
155
|
-
...(init.headers || {}),
|
|
156
|
-
}
|
|
157
|
-
if (session.cookie) headers.Cookie = session.cookie
|
|
158
|
-
let res
|
|
159
|
-
try {
|
|
160
|
-
res = await fetch(`${cliAbstractsFetchOrigin()}/${abstractId}${path}`, {
|
|
161
|
-
method: init.method || 'GET',
|
|
162
|
-
headers,
|
|
163
|
-
body: init.body,
|
|
164
|
-
redirect: 'manual',
|
|
165
|
-
})
|
|
166
|
-
} catch {
|
|
167
|
-
return new Response(JSON.stringify({ message: 'Could not reach the abstract.' }), {
|
|
168
|
-
status: 502,
|
|
169
|
-
headers: { 'Content-Type': 'application/json' },
|
|
170
|
-
})
|
|
171
|
-
}
|
|
172
|
-
if (res.status === 401 && session.cookie && !init.retried) {
|
|
173
|
-
session.cookie = null
|
|
174
|
-
session.cookie = await session.mint()
|
|
175
|
-
return proxyAbstract(abstractId, path, { ...init, retried: true }, session)
|
|
176
|
-
}
|
|
177
|
-
return res
|
|
178
|
-
}
|
|
179
|
-
|
|
180
158
|
export function startServe(options) {
|
|
181
159
|
const cwd = options.cwd
|
|
182
160
|
const abstractId = options.abstractId
|
|
183
161
|
const host = options.host || '127.0.0.1'
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
mint: options.mintPass || (() => mintAbstractPass(abstractId)),
|
|
188
|
-
}
|
|
162
|
+
const environment = options.environment === 'preview' ? 'preview' : 'production'
|
|
163
|
+
const appPath = `/api/v1/apps/${abstractId}`
|
|
164
|
+
const environmentQuery = `?environment=${environment}`
|
|
189
165
|
const clients = new Set()
|
|
190
166
|
let reloadTimer = null
|
|
191
167
|
|
|
@@ -203,42 +179,85 @@ export function startServe(options) {
|
|
|
203
179
|
watchers.push(watch(path, notifyReload))
|
|
204
180
|
}
|
|
205
181
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
182
|
+
const hosted = (path, init = {}) => options.hostedFetch
|
|
183
|
+
? options.hostedFetch(path, init)
|
|
184
|
+
: apiFetch(path, init)
|
|
185
|
+
const invoke = (name, input) => hosted(`${appPath}/actions/${name}/invoke${environmentQuery}`, {
|
|
186
|
+
method: 'POST', headers: { 'Idempotency-Key': randomUUID() }, body: JSON.stringify(input),
|
|
187
|
+
})
|
|
188
|
+
const recordFromOutput = (row) => ({ ...row.body, id: row.id, revision: row.revision,
|
|
189
|
+
visibility: row.visibility, collection: row.collection, created_by: row.created_by })
|
|
190
|
+
async function hostedRecords(method, url, req) {
|
|
191
|
+
if (method === 'GET' && url.pathname === '/__amorphous/data') {
|
|
192
|
+
const discovery = await hosted(`${appPath}/actions${environmentQuery}`)
|
|
193
|
+
const query = discovery.actions?.find((action) => action.name === 'records.query' && action.collection === 'default')
|
|
194
|
+
const limit = Math.min(100, query?.limits?.maxPage)
|
|
195
|
+
if (!Number.isSafeInteger(limit) || limit < 1) return { status: 503, body: { message: 'Invalid record page policy' } }
|
|
196
|
+
const records = []
|
|
197
|
+
for (const visibility of ['shared', 'private']) {
|
|
198
|
+
let cursor
|
|
199
|
+
const seen = new Set()
|
|
200
|
+
for (let pageNumber = 0; pageNumber < 1000; pageNumber++) {
|
|
201
|
+
const result = await invoke('records.query', { collection: 'default', visibility, limit,
|
|
202
|
+
...(cursor ? { cursor } : {}) })
|
|
203
|
+
if (result.status !== 'ok') return { status: 403, body: { message: result.status } }
|
|
204
|
+
const page = Array.isArray(result.output) ? result.output : []
|
|
205
|
+
records.push(...page.map(recordFromOutput))
|
|
206
|
+
if (page.length < limit) break
|
|
207
|
+
cursor = page.at(-1)?.id
|
|
208
|
+
if (!cursor || seen.has(cursor)) return { status: 502, body: { message: 'Record cursor repeated' } }
|
|
209
|
+
seen.add(cursor)
|
|
210
|
+
if (pageNumber === 999) return { status: 413, body: { message: 'Record page limit reached' } }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { status: 200, body: { records } }
|
|
215
214
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return
|
|
223
|
-
status:
|
|
224
|
-
headers: { 'Content-Type': 'application/json' },
|
|
225
|
-
})
|
|
215
|
+
if (method === 'POST' && url.pathname === '/__amorphous/data') {
|
|
216
|
+
const row = await readJson(req)
|
|
217
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return { status: 400, body: { message: 'Invalid record' } }
|
|
218
|
+
const { id, visibility = 'shared', revision: _revision, collection: _collection,
|
|
219
|
+
created_by: _author, user_id: _subject, ...body } = row
|
|
220
|
+
const result = await invoke('records.create', { collection: 'default', visibility, ...(id ? { id } : {}), body })
|
|
221
|
+
return { status: result.status === 'ok' ? 200 : result.status === 'conflict' ? 409 : 403,
|
|
222
|
+
body: result.status === 'ok' ? { ok: true, ...result.output } : { message: result.status } }
|
|
226
223
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
224
|
+
if (method === 'PUT' && url.pathname.startsWith('/__amorphous/data/')) {
|
|
225
|
+
const id = decodeURIComponent(url.pathname.slice('/__amorphous/data/'.length))
|
|
226
|
+
const row = await readJson(req)
|
|
227
|
+
if (!row || typeof row !== 'object' || !Number.isSafeInteger(row.revision)) {
|
|
228
|
+
return { status: 400, body: { message: 'Current record revision required' } }
|
|
229
|
+
}
|
|
230
|
+
const { revision, id: _id, visibility = 'shared', collection: _collection,
|
|
231
|
+
created_by: _author, user_id: _subject, ...body } = row
|
|
232
|
+
const result = await invoke('records.patch', { collection: 'default', visibility, id,
|
|
233
|
+
expectedRevision: revision, body })
|
|
234
|
+
return { status: result.status === 'ok' ? 200 : result.status === 'conflict' ? 409 : 403,
|
|
235
|
+
body: result.status === 'ok' ? { ok: true, ...result.output } : { message: result.status } }
|
|
236
|
+
}
|
|
237
|
+
return { status: 405, body: { message: 'Method not allowed' } }
|
|
230
238
|
}
|
|
231
239
|
|
|
232
240
|
const server = createServer(async (req, res) => {
|
|
233
241
|
try {
|
|
234
242
|
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
243
|
+
const address = server.address()
|
|
244
|
+
const expectedHost = `127.0.0.1:${address.port}`
|
|
245
|
+
if (req.headers.host !== expectedHost) {
|
|
246
|
+
send(res, 403, { message: 'Invalid local host' })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method) &&
|
|
250
|
+
req.headers.origin !== `http://${expectedHost}`) {
|
|
251
|
+
send(res, 403, { message: 'Invalid local origin' })
|
|
252
|
+
return
|
|
253
|
+
}
|
|
235
254
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
236
255
|
const mode = recordsMode(req, url)
|
|
237
256
|
const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }
|
|
238
257
|
if (url.searchParams.get('records') === 'local' || url.searchParams.get('records') === 'prod') {
|
|
239
258
|
headers['Set-Cookie'] = `amorphous_records=${mode}; Path=/; SameSite=Lax`
|
|
240
259
|
}
|
|
241
|
-
send(res, 200, pageHtml(readProjectFiles(cwd).html, mode), headers)
|
|
260
|
+
send(res, 200, pageHtml(readProjectFiles(cwd).html, mode, environment), headers)
|
|
242
261
|
return
|
|
243
262
|
}
|
|
244
263
|
if (req.method === 'GET' && url.pathname === '/styles.css') {
|
|
@@ -267,40 +286,62 @@ export function startServe(options) {
|
|
|
267
286
|
return
|
|
268
287
|
}
|
|
269
288
|
const mode = recordsMode(req, url)
|
|
289
|
+
const recoveryMatch = /^\/__amorphous\/recovery\/([^/]+)$/.exec(url.pathname)
|
|
290
|
+
if (recoveryMatch) {
|
|
291
|
+
let recordId
|
|
292
|
+
try { recordId = decodeURIComponent(recoveryMatch[1]) } catch { recordId = '' }
|
|
293
|
+
if (!UUID.test(recordId)) {
|
|
294
|
+
send(res, 400, { message: 'Invalid record id' })
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
if (mode !== 'prod') {
|
|
298
|
+
if (req.method === 'GET') send(res, 200, { revisions: [] })
|
|
299
|
+
else if (req.method === 'POST') send(res, 409, { message: 'Undo is available for hosted records only.' })
|
|
300
|
+
else send(res, 405, { message: 'Method not allowed' })
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
if (req.method === 'GET') {
|
|
304
|
+
const result = await hosted(`${appPath}/recovery/revisions?recordId=${encodeURIComponent(recordId)}${environmentQuery.replace('?', '&')}`)
|
|
305
|
+
send(res, 200, { revisions: Array.isArray(result?.revisions) ? result.revisions : [] })
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
if (req.method === 'POST') {
|
|
309
|
+
const body = await readJson(req)
|
|
310
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) ||
|
|
311
|
+
Object.keys(body).sort().join(',') !== 'historyId,operationKey' ||
|
|
312
|
+
!UUID.test(String(body.historyId || '')) || !UUID.test(String(body.operationKey || ''))) {
|
|
313
|
+
send(res, 400, { message: 'Invalid recovery request' })
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
const revisions = await hosted(`${appPath}/recovery/revisions?recordId=${encodeURIComponent(recordId)}&environment=${environment}`)
|
|
317
|
+
if (!Array.isArray(revisions?.revisions) || !revisions.revisions.some((item) => item?.historyId === body.historyId)) {
|
|
318
|
+
send(res, 404, { message: 'Recovery revision unavailable' })
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
const result = await hosted(`${appPath}/recovery/revision?environment=${environment}`, {
|
|
322
|
+
method: 'POST', body: JSON.stringify({ historyId: body.historyId, operationKey: body.operationKey }),
|
|
323
|
+
})
|
|
324
|
+
send(res, 200, result)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
send(res, 405, { message: 'Method not allowed' })
|
|
328
|
+
return
|
|
329
|
+
}
|
|
270
330
|
if (req.method === 'GET' && url.pathname === '/__amorphous/me') {
|
|
271
|
-
|
|
272
|
-
const text = await upstream.text()
|
|
273
|
-
send(res, upstream.ok ? 200 : upstream.status, text, {
|
|
274
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
275
|
-
})
|
|
331
|
+
send(res, 200, await hosted(`${appPath}/me${environmentQuery}`))
|
|
276
332
|
return
|
|
277
333
|
}
|
|
278
334
|
if (req.method === 'POST' && url.pathname === '/__amorphous/names') {
|
|
279
335
|
const body = await readJson(req)
|
|
280
|
-
|
|
281
|
-
method: 'POST',
|
|
282
|
-
|
|
283
|
-
headers: { 'Content-Type': 'application/json' },
|
|
284
|
-
body: JSON.stringify({ ids: Array.isArray(body.ids) ? body.ids : [] }),
|
|
285
|
-
})
|
|
286
|
-
const text = await upstream.text()
|
|
287
|
-
send(res, upstream.ok ? 200 : upstream.status, text, {
|
|
288
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
289
|
-
})
|
|
336
|
+
send(res, 200, await hosted(`${appPath}/names${environmentQuery}`, {
|
|
337
|
+
method: 'POST', body: JSON.stringify({ ids: Array.isArray(body.ids) ? body.ids : [] }),
|
|
338
|
+
}))
|
|
290
339
|
return
|
|
291
340
|
}
|
|
292
341
|
if (url.pathname === '/__amorphous/data' || url.pathname.startsWith('/__amorphous/data/')) {
|
|
293
342
|
if (mode === 'prod') {
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
const init = { method: req.method, auth: true }
|
|
297
|
-
if (req.method === 'POST' || req.method === 'PUT') {
|
|
298
|
-
init.headers = { 'Content-Type': 'application/json' }
|
|
299
|
-
init.body = JSON.stringify(await readJson(req))
|
|
300
|
-
}
|
|
301
|
-
const upstream = await prod(path, init)
|
|
302
|
-
const text = await upstream.text()
|
|
303
|
-
send(res, upstream.status, text, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
343
|
+
const result = await hostedRecords(req.method, url, req)
|
|
344
|
+
send(res, result.status, result.body)
|
|
304
345
|
return
|
|
305
346
|
}
|
|
306
347
|
if (req.method === 'GET') {
|
|
@@ -325,7 +366,7 @@ export function startServe(options) {
|
|
|
325
366
|
send(res, 400, { message: 'Invalid request' })
|
|
326
367
|
return
|
|
327
368
|
}
|
|
328
|
-
send(res, 500, { message: error instanceof Error ? error.message : 'Request failed' })
|
|
369
|
+
send(res, error?.status || 500, { message: error instanceof Error ? error.message : 'Request failed' })
|
|
329
370
|
}
|
|
330
371
|
})
|
|
331
372
|
|