acrylblends 0.7.7 → 0.8.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/dist/index.d.ts +14 -6
- package/dist/index.js +130 -31
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +3 -3
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/agents/index.md +18 -3
- package/docs/framework/getting-started/basic.md +1 -2
- package/docs/framework/getting-started/desktop.md +3 -3
- package/docs/framework/guides/bring-your-own-tool.md +216 -0
- package/docs/framework/guides/client-modules.md +4 -3
- package/docs/framework/guides/create-acrylblends.md +5 -4
- package/docs/framework/guides/data.md +7 -6
- package/docs/framework/helpers/streaming.md +12 -27
- package/docs/framework/upstream.json +3 -3
- package/lib/browser/read-sse.js +84 -0
- package/lib/db/sqlite.d.ts +1 -1
- package/lib/db.d.ts +1 -84
- package/lib/index-BLTYsyc1.d.ts +93 -0
- package/lib/index-D4FAsBZu.d.ts +85 -0
- package/lib/kv/db.d.ts +2 -2
- package/lib/kv/json.d.ts +1 -1
- package/lib/kv/json.js +87 -24
- package/lib/kv.d.ts +1 -92
- package/lib/node-server.d.ts +3 -3
- package/lib/node-server.js +14 -8
- package/lib/read-sse.d.ts +33 -0
- package/lib/read-sse.js +84 -0
- package/package.json +5 -1
- package/plugins/acryl-db/plugin.json +7 -2
- package/plugins/acryl-db/src/conformance.ts +224 -0
- package/plugins/acryl-db/upstream.json +2 -2
- package/plugins/acryl-db-sqlite/plugin.json +4 -1
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-door/LICENSE +47 -0
- package/plugins/acryl-door/plugin.json +14 -0
- package/plugins/acryl-door/src/cli/acryl.ts +207 -0
- package/plugins/acryl-door/src/host/api.ts +9 -0
- package/plugins/acryl-door/src/host/audit.ts +64 -0
- package/plugins/acryl-door/src/host/channel.ts +142 -0
- package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
- package/plugins/acryl-door/src/host/door-file.ts +94 -0
- package/plugins/acryl-door/src/host/door.ts +195 -0
- package/plugins/acryl-door/src/host/http.ts +80 -0
- package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
- package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
- package/plugins/acryl-door/src/host/page-routes.ts +65 -0
- package/plugins/acryl-door/src/host/shim.ts +65 -0
- package/plugins/acryl-door/src/host/verbs.ts +109 -0
- package/plugins/acryl-door/src/index.ts +133 -0
- package/plugins/acryl-door/src/page/api.ts +9 -0
- package/plugins/acryl-door/src/page/channel.ts +135 -0
- package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
- package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
- package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
- package/plugins/acryl-door/src/page/index.ts +40 -0
- package/plugins/acryl-door/src/page/indicator.ts +72 -0
- package/plugins/acryl-door/src/page/main.ts +7 -0
- package/plugins/acryl-door/src/page/protocol.ts +213 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
- package/plugins/acryl-door/src/page/user-input.ts +24 -0
- package/plugins/acryl-door/upstream.json +28 -0
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/plugin.json +7 -2
- package/plugins/acryl-kv/src/conformance.ts +226 -0
- package/plugins/acryl-kv/upstream.json +1 -1
- package/plugins/acryl-kv-db/plugin.json +4 -1
- package/plugins/acryl-kv-db/upstream.json +1 -1
- package/plugins/acryl-kv-json/plugin.json +4 -1
- package/plugins/acryl-kv-json/src/open.ts +86 -22
- package/plugins/acryl-kv-json/upstream.json +2 -2
- package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
- package/plugins/acryl-node-server/src/client-modules.ts +10 -9
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
- package/plugins/acryl-router/src/service.ts +3 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/agent-panel/client/main.ts +159 -0
- package/plugins/agent-panel/index.ts +232 -0
- package/plugins/agent-panel/plugin.json +21 -0
- package/plugins/builder/index.ts +2 -2
- package/plugins/builder/plugin.json +1 -1
- package/plugins/desktop/main.ts +3 -3
- package/plugins/desktop/plugin.json +2 -2
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/main.ts +2 -2
- package/plugins/desktop-web/plugin.json +2 -2
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/index.ts +19 -1
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +17 -3
- package/plugins/manifest/lock.ts +7 -3
- package/plugins/manifest/upgrade-cli.ts +1 -1
- package/plugins/save/cli.ts +1 -1
- package/plugins/save/plugin.json +1 -1
- package/plugins/tsconfig.json +6 -0
- package/shared/AGENTS.core.md +10 -10
- package/shared/DEFAULTS.md +45 -0
- package/shared/SKILL.md +14 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/README.md +6 -6
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +8 -8
- package/templates/agent/src/app.ts +46 -17
- package/templates/agent/src/main.ts +2 -2
- package/templates/agent/src/routes.ts +19 -6
- package/templates/agent/template.json +2 -1
- package/templates/agent/test/agent.test.ts +11 -5
- package/templates/agent/test/app.test.ts +30 -9
- package/templates/agent/test/builder.test.ts +35 -24
- package/templates/agent/test/model.test.ts +25 -20
- package/templates/agent/tsconfig.json +48 -50
- package/templates/blank/AGENTS.md +1 -1
- package/templates/blank/README.md +2 -2
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +8 -8
- package/templates/blank/src/about.tsx +2 -2
- package/templates/blank/src/app.ts +29 -9
- package/templates/blank/src/main.ts +1 -1
- package/templates/blank/src/routes.ts +1 -1
- package/templates/blank/test/app.test.ts +29 -10
- package/templates/blank/tsconfig.json +1 -1
- package/templates/chat/AGENTS.md +16 -0
- package/templates/chat/blend.yaml +29 -0
- package/templates/chat/gitignore +6 -0
- package/templates/chat/migrations/001-chat.sql +12 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/src/app.ts +106 -0
- package/templates/chat/src/chat-app.ts +101 -0
- package/templates/chat/src/client/chat.tsx +115 -0
- package/templates/chat/src/client/tsconfig.json +26 -0
- package/templates/chat/src/db.ts +43 -0
- package/templates/chat/src/door.ts +7 -0
- package/templates/chat/src/llm.ts +42 -0
- package/templates/chat/src/main.ts +28 -0
- package/templates/chat/src/page.tsx +40 -0
- package/templates/chat/src/routes.ts +15 -0
- package/templates/chat/src/runtime.ts +17 -0
- package/templates/chat/template.json +11 -0
- package/templates/chat/test/chat.test.ts +198 -0
- package/templates/chat/test/server.test.ts +78 -0
- package/templates/chat/tsconfig.json +20 -0
- package/templates/nodejs/AGENTS.md +9 -0
- package/templates/nodejs/blend.yaml +26 -0
- package/templates/nodejs/gitignore +3 -0
- package/templates/nodejs/package.json +30 -0
- package/templates/nodejs/src/app.ts +92 -0
- package/templates/nodejs/src/client/counter.tsx +12 -0
- package/templates/nodejs/src/client/tsconfig.json +26 -0
- package/templates/nodejs/src/door.ts +7 -0
- package/templates/nodejs/src/main.ts +25 -0
- package/templates/nodejs/src/page.tsx +29 -0
- package/templates/nodejs/src/routes.ts +40 -0
- package/templates/nodejs/template.json +3 -0
- package/templates/nodejs/test/app.test.ts +78 -0
- package/templates/nodejs/test/server.test.ts +83 -0
- package/templates/nodejs/tsconfig.json +20 -0
- package/templates/notes/AGENTS.md +26 -0
- package/templates/notes/blend.yaml +29 -0
- package/templates/notes/gitignore +6 -0
- package/templates/notes/migrations/001-notes.sql +18 -0
- package/templates/notes/package.json +33 -0
- package/templates/notes/src/app.ts +106 -0
- package/templates/notes/src/auth.ts +57 -0
- package/templates/notes/src/client/counter.tsx +21 -0
- package/templates/notes/src/client/tsconfig.json +26 -0
- package/templates/notes/src/db.ts +50 -0
- package/templates/notes/src/door.ts +7 -0
- package/templates/notes/src/main.ts +28 -0
- package/templates/notes/src/notes-app.ts +205 -0
- package/templates/notes/src/routes.ts +14 -0
- package/templates/notes/src/runtime.ts +17 -0
- package/templates/notes/src/schema.ts +38 -0
- package/templates/notes/src/views.tsx +136 -0
- package/templates/notes/template.json +11 -0
- package/templates/notes/test/notes.test.ts +280 -0
- package/templates/notes/test/server.test.ts +78 -0
- package/templates/notes/tsconfig.json +20 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +8 -8
- package/templates/todo/src/app.ts +32 -12
- package/templates/todo/src/main.ts +1 -1
- package/templates/todo/src/todo.ts +4 -4
- package/templates/todo/test/todo.test.ts +48 -23
- package/templates/todo/tsconfig.json +1 -1
- package/templates/todo/upstream.json +2 -2
- package/lib/database-D_UN6We8.d.ts +0 -44
- package/lib/kv-DWDm0Lja.d.ts +0 -39
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { after, test } from 'node:test'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import type {} from '../plugins/acryl-db/src/cordis.ts'
|
|
8
|
+
import type { Db } from '../plugins/acryl-db/src/index.ts'
|
|
9
|
+
import { testClient } from '../plugins/acryl-router/src/helpers/index.ts'
|
|
10
|
+
import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
11
|
+
import { createApp as boot } from '../src/app.ts'
|
|
12
|
+
import { createApi, createApp } from '../src/notes-app.ts'
|
|
13
|
+
|
|
14
|
+
/** Throwaway homes and databases live inside the project (`.acrylblends/` is ignored by git), because a sandboxed agent may not write to the system temp folder. */
|
|
15
|
+
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
16
|
+
mkdirSync(scratch, { recursive: true })
|
|
17
|
+
|
|
18
|
+
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A fresh database file in a throwaway home with the real migrations, opened by the app's own `db` row (the whole Cordis app boots, so the test exercises the same
|
|
22
|
+
* composition as production). Every app is stopped, and its home removed, when the tests end.
|
|
23
|
+
*/
|
|
24
|
+
const stops: (() => Promise<void>)[] = []
|
|
25
|
+
after(() => Promise.all(stops.map((stop) => stop())))
|
|
26
|
+
async function booted() {
|
|
27
|
+
const home = await mkdtemp(join(scratch, 'notes-test-'))
|
|
28
|
+
const app = await boot(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
|
|
29
|
+
database: join(home, 'test.sqlite'),
|
|
30
|
+
listen: false,
|
|
31
|
+
})
|
|
32
|
+
stops.push(async () => {
|
|
33
|
+
await app.stop()
|
|
34
|
+
await rm(home, { recursive: true, force: true })
|
|
35
|
+
})
|
|
36
|
+
return app
|
|
37
|
+
}
|
|
38
|
+
async function memoryDb(): Promise<Db> {
|
|
39
|
+
return (await booted()).root.db
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const form = (data: Record<string, string>, cookie = ''): RequestInit => ({
|
|
43
|
+
method: 'POST',
|
|
44
|
+
body: new URLSearchParams(data),
|
|
45
|
+
headers: { origin: 'http://localhost', ...(cookie ? { cookie } : {}) },
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
async function signedUp(app: ReturnType<typeof createApp>, email = 'a@example.com') {
|
|
49
|
+
const res = await app.request('/signup', form({ email, password: 'correct horse' }))
|
|
50
|
+
assert.equal(res.status, 303)
|
|
51
|
+
return (res.headers.get('set-cookie') ?? '').split(';')[0] ?? ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('the home page sends a visitor to the notes, which send them to log in', async () => {
|
|
55
|
+
const app = createApp(await memoryDb())
|
|
56
|
+
assert.equal((await app.request('/')).headers.get('location'), '/notes')
|
|
57
|
+
const res = await app.request('/notes')
|
|
58
|
+
assert.equal(res.status, 302)
|
|
59
|
+
assert.equal(res.headers.get('location'), '/login')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('sign up validates, refuses a duplicate, and starts a session', async () => {
|
|
63
|
+
const app = createApp(await memoryDb())
|
|
64
|
+
assert.match(await (await app.request('/signup')).text(), /Create account/)
|
|
65
|
+
const bad = await app.request('/signup', form({ email: 'nope', password: 'short' }))
|
|
66
|
+
assert.equal(bad.status, 400)
|
|
67
|
+
const text = await bad.text()
|
|
68
|
+
assert.match(text, /valid email/)
|
|
69
|
+
assert.match(text, /at least 8 characters/)
|
|
70
|
+
const cookie = await signedUp(app)
|
|
71
|
+
assert.match(cookie, /^sid=/)
|
|
72
|
+
const again = await app.request(
|
|
73
|
+
'/signup',
|
|
74
|
+
form({ email: 'a@example.com', password: 'correct horse' }),
|
|
75
|
+
)
|
|
76
|
+
assert.equal(again.status, 400)
|
|
77
|
+
assert.match(await again.text(), /already has an account/)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('log in checks the password; log out ends the session', async () => {
|
|
81
|
+
const app = createApp(await memoryDb())
|
|
82
|
+
const first = await signedUp(app)
|
|
83
|
+
assert.match(await (await app.request('/login')).text(), /Log in/)
|
|
84
|
+
const wrong = await app.request(
|
|
85
|
+
'/login',
|
|
86
|
+
form({ email: 'a@example.com', password: 'nope nope nope' }),
|
|
87
|
+
)
|
|
88
|
+
assert.equal(wrong.status, 401)
|
|
89
|
+
assert.match(await wrong.text(), /Wrong email or password/)
|
|
90
|
+
const empty = await app.request('/login', form({ email: '', password: '' }))
|
|
91
|
+
assert.equal(empty.status, 400)
|
|
92
|
+
const ok = await app.request(
|
|
93
|
+
'/login',
|
|
94
|
+
form({ email: 'a@example.com', password: 'correct horse' }),
|
|
95
|
+
)
|
|
96
|
+
assert.equal(ok.status, 303)
|
|
97
|
+
const cookie = (ok.headers.get('set-cookie') ?? '').split(';')[0] ?? ''
|
|
98
|
+
assert.equal((await app.request('/notes', { headers: { cookie } })).status, 200)
|
|
99
|
+
const out = await app.request('/logout', form({}, cookie))
|
|
100
|
+
assert.equal(out.headers.get('location'), '/login')
|
|
101
|
+
assert.equal((await app.request('/notes', { headers: { cookie } })).status, 302)
|
|
102
|
+
assert.equal((await app.request('/notes', { headers: { cookie: first } })).status, 200)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('create, search, edit and delete a note through the forms', async () => {
|
|
106
|
+
const app = createApp(await memoryDb())
|
|
107
|
+
const cookie = await signedUp(app)
|
|
108
|
+
assert.match(
|
|
109
|
+
await (await app.request('/notes/new', { headers: { cookie } })).text(),
|
|
110
|
+
/data-island="counter"/,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const invalid = await app.request('/notes', form({ title: ' ', body: 'x' }, cookie))
|
|
114
|
+
assert.equal(invalid.status, 400)
|
|
115
|
+
assert.match(await invalid.text(), /Give the note a title/)
|
|
116
|
+
|
|
117
|
+
assert.equal(
|
|
118
|
+
(await app.request('/notes', form({ title: 'Groceries', body: 'milk and eggs' }, cookie)))
|
|
119
|
+
.status,
|
|
120
|
+
303,
|
|
121
|
+
)
|
|
122
|
+
assert.equal(
|
|
123
|
+
(await app.request('/notes', form({ title: 'Ideas', body: 'a framework' }, cookie))).status,
|
|
124
|
+
303,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const list = await (await app.request('/notes', { headers: { cookie } })).text()
|
|
128
|
+
assert.match(list, /Groceries/)
|
|
129
|
+
assert.match(list, /Ideas/)
|
|
130
|
+
const found = await (await app.request('/notes?q=eggs', { headers: { cookie } })).text()
|
|
131
|
+
assert.match(found, /Groceries/)
|
|
132
|
+
assert.doesNotMatch(found, /Ideas/)
|
|
133
|
+
assert.match(
|
|
134
|
+
await (await app.request('/notes?q=zzz', { headers: { cookie } })).text(),
|
|
135
|
+
/No note matches/,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const edit = await app.request('/notes/1/edit', { headers: { cookie } })
|
|
139
|
+
assert.match(await edit.text(), /milk and eggs/)
|
|
140
|
+
const toolong = await app.request('/notes/1', form({ title: 'x'.repeat(81), body: '' }, cookie))
|
|
141
|
+
assert.equal(toolong.status, 400)
|
|
142
|
+
assert.match(await toolong.text(), /at most 80/)
|
|
143
|
+
assert.equal(
|
|
144
|
+
(await app.request('/notes/1', form({ title: 'Shopping', body: 'oat milk' }, cookie))).status,
|
|
145
|
+
303,
|
|
146
|
+
)
|
|
147
|
+
assert.match(await (await app.request('/notes', { headers: { cookie } })).text(), /Shopping/)
|
|
148
|
+
assert.equal((await app.request('/notes/99', form({ title: 'x', body: '' }, cookie))).status, 404)
|
|
149
|
+
assert.equal((await app.request('/notes/99/edit', { headers: { cookie } })).status, 404)
|
|
150
|
+
|
|
151
|
+
assert.equal((await app.request('/notes/1/delete', form({}, cookie))).status, 303)
|
|
152
|
+
assert.equal((await app.request('/notes/1/delete', form({}, cookie))).status, 404)
|
|
153
|
+
assert.doesNotMatch(
|
|
154
|
+
await (await app.request('/notes', { headers: { cookie } })).text(),
|
|
155
|
+
/Shopping/,
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test("one user cannot read or change another user's notes", async () => {
|
|
160
|
+
const app = createApp(await memoryDb())
|
|
161
|
+
const alice = await signedUp(app, 'alice@example.com')
|
|
162
|
+
const bob = await signedUp(app, 'bob@example.com')
|
|
163
|
+
await app.request('/notes', form({ title: 'Secret', body: 'alice only' }, alice))
|
|
164
|
+
assert.equal((await app.request('/notes/1/edit', { headers: { cookie: bob } })).status, 404)
|
|
165
|
+
assert.equal((await app.request('/notes/1/delete', form({}, bob))).status, 404)
|
|
166
|
+
assert.match(await (await app.request('/notes', { headers: { cookie: alice } })).text(), /Secret/)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('the JSON API needs a session and validates input', async () => {
|
|
170
|
+
const db = await memoryDb()
|
|
171
|
+
const app = createApp(db)
|
|
172
|
+
const cookie = await signedUp(app)
|
|
173
|
+
const res = await app.request('/api/notes')
|
|
174
|
+
assert.equal(res.status, 401)
|
|
175
|
+
const bad = await app.request('/api/notes', {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { cookie, 'content-type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({ title: '' }),
|
|
179
|
+
})
|
|
180
|
+
assert.equal(bad.status, 400)
|
|
181
|
+
const body = (await bad.json()) as { issues: { message: string }[] }
|
|
182
|
+
assert.match(body.issues[0]?.message ?? '', /title/)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('the typed client covers every API route', async () => {
|
|
186
|
+
const db = await memoryDb()
|
|
187
|
+
const app = createApp(db)
|
|
188
|
+
const cookie = await signedUp(app)
|
|
189
|
+
const client = testClient(createApi(db))
|
|
190
|
+
const as = { headers: { cookie } }
|
|
191
|
+
|
|
192
|
+
const created = await client.index.$post({ json: { title: 'First', body: 'hello' } }, as)
|
|
193
|
+
assert.equal(created.status, 201)
|
|
194
|
+
const { note } = await created.json()
|
|
195
|
+
assert.equal(note.title, 'First')
|
|
196
|
+
|
|
197
|
+
const listed = await client.index.$get({ query: { q: 'hel' } }, as)
|
|
198
|
+
assert.equal((await listed.json()).notes.length, 1)
|
|
199
|
+
|
|
200
|
+
const got = await client[':id'].$get({ param: { id: String(note.id) } }, as)
|
|
201
|
+
assert.equal(got.status, 200)
|
|
202
|
+
const missing = await client[':id'].$get({ param: { id: '99' } }, as)
|
|
203
|
+
assert.equal(missing.status, 404)
|
|
204
|
+
|
|
205
|
+
const updated = await client[':id'].$put(
|
|
206
|
+
{ param: { id: String(note.id) }, json: { title: 'Renamed', body: '' } },
|
|
207
|
+
as,
|
|
208
|
+
)
|
|
209
|
+
assert.equal(updated.status, 200)
|
|
210
|
+
|
|
211
|
+
const gone = await client[':id'].$delete({ param: { id: String(note.id) } }, as)
|
|
212
|
+
assert.equal(gone.status, 200)
|
|
213
|
+
assert.equal((await client[':id'].$delete({ param: { id: String(note.id) } }, as)).status, 404)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('csrf refuses a form POST from another site', async () => {
|
|
217
|
+
const app = createApp(await memoryDb())
|
|
218
|
+
const res = await app.request('/signup', {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
body: new URLSearchParams({ email: 'a@example.com', password: 'correct horse' }),
|
|
221
|
+
headers: { origin: 'https://evil.example' },
|
|
222
|
+
})
|
|
223
|
+
assert.equal(res.status, 403)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('the session cookie is Secure over HTTPS, and behind a proxy only when the proxy is trusted', async () => {
|
|
227
|
+
const body = new URLSearchParams({ email: 'a@example.com', password: 'correct horse' })
|
|
228
|
+
const flags = async (url: string, headers: Record<string, string>, trustProxy: boolean) => {
|
|
229
|
+
const app = createApp(await memoryDb(), { trustProxy })
|
|
230
|
+
const origin = new URL(url).origin
|
|
231
|
+
const res = await app.request(url, { method: 'POST', body, headers: { origin, ...headers } })
|
|
232
|
+
assert.equal(res.status, 303)
|
|
233
|
+
return res.headers.get('set-cookie') ?? ''
|
|
234
|
+
}
|
|
235
|
+
assert.doesNotMatch(await flags('http://localhost/signup', {}, false), /Secure/i)
|
|
236
|
+
assert.match(await flags('https://localhost/signup', {}, false), /Secure/i)
|
|
237
|
+
const forwarded = { 'x-forwarded-proto': 'https' }
|
|
238
|
+
assert.doesNotMatch(await flags('http://localhost/signup', forwarded, false), /Secure/i)
|
|
239
|
+
assert.match(await flags('http://localhost/signup', forwarded, true), /Secure/i)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('behind a proxy csrf accepts the public origin and still refuses others', async () => {
|
|
243
|
+
const app = createApp(await memoryDb(), { publicOrigin: 'https://notes.example.com' })
|
|
244
|
+
const post = (origin: string) =>
|
|
245
|
+
app.request('/signup', {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
body: new URLSearchParams({ email: 'a@example.com', password: 'correct horse' }),
|
|
248
|
+
headers: { origin },
|
|
249
|
+
})
|
|
250
|
+
assert.equal((await post('https://notes.example.com')).status, 303)
|
|
251
|
+
assert.equal((await post('https://evil.example')).status, 403)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('an error answers a plain 500 with no stack and no detail', async () => {
|
|
255
|
+
const fail = async (): Promise<never> => {
|
|
256
|
+
throw new Error('secret detail at /srv/app/db.ts:1')
|
|
257
|
+
}
|
|
258
|
+
const broken: Db = { exec: fail, run: fail, get: fail, all: fail, transaction: fail }
|
|
259
|
+
const app = createApp(broken)
|
|
260
|
+
const res = await app.request('/notes', { headers: { cookie: 'sid=anything' } })
|
|
261
|
+
assert.equal(res.status, 500)
|
|
262
|
+
assert.equal(await res.text(), 'Internal Server Error')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('the note form loads the character counter, a client component compiled for the browser and served by the running app', async () => {
|
|
266
|
+
const running = await booted()
|
|
267
|
+
const app = createApp(running.root.db)
|
|
268
|
+
const cookie = await signedUp(app)
|
|
269
|
+
assert.match(
|
|
270
|
+
await (await app.request('/notes/new', { headers: { cookie } })).text(),
|
|
271
|
+
/src="\/client\/counter\.tsx"/,
|
|
272
|
+
)
|
|
273
|
+
// Through the router and the server, as a browser asks: the client folder is mounted by src/routes.ts.
|
|
274
|
+
const module = await running.root.router.request('/client/counter.tsx')
|
|
275
|
+
assert.equal(module.status, 200)
|
|
276
|
+
assert.match(module.headers.get('content-type') ?? '', /javascript/)
|
|
277
|
+
assert.doesNotMatch(await module.text(), /<Count/)
|
|
278
|
+
assert.equal((await running.root.router.request('/client/@acrylblends/jsx-dom.js')).status, 200)
|
|
279
|
+
assert.equal((await running.root.router.request('/client/../src/db.ts')).status, 404)
|
|
280
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { once } from 'node:events'
|
|
4
|
+
import { mkdirSync, mkdtempSync } from 'node:fs'
|
|
5
|
+
import { createServer } from 'node:net'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
/** Throwaway homes and databases live inside the project (`.acrylblends/` is ignored by git), because a sandboxed agent may not write to the system temp folder. */
|
|
11
|
+
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
12
|
+
mkdirSync(scratch, { recursive: true })
|
|
13
|
+
|
|
14
|
+
/** A sandbox may forbid listening on a port (Codex's workspace-write does); the tests that start the real server are skipped there, and every other test runs in process. */
|
|
15
|
+
const canListen = await new Promise<boolean>((done) => {
|
|
16
|
+
const probe = createServer()
|
|
17
|
+
probe.once('error', () => done(false))
|
|
18
|
+
probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
|
|
19
|
+
})
|
|
20
|
+
const skip = canListen ? false : 'this environment does not allow listening on a port'
|
|
21
|
+
if (!canListen)
|
|
22
|
+
console.log(
|
|
23
|
+
`skipped: the tests that start the real server (${skip}); every other test ran in process`,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
/** Start `src/main.ts` the way `npm start` does, on a port the system chooses, and read the address it prints. */
|
|
27
|
+
async function start(env: Record<string, string> = {}) {
|
|
28
|
+
const child = spawn(
|
|
29
|
+
process.execPath,
|
|
30
|
+
['--import', './plugins/acryl-node-server/src/register.ts', 'src/main.ts'],
|
|
31
|
+
{
|
|
32
|
+
env: {
|
|
33
|
+
...process.env,
|
|
34
|
+
PORT: '0',
|
|
35
|
+
ACRYL_DOOR: 'off',
|
|
36
|
+
ACRYLBLENDS_HOME: mkdtempSync(join(scratch, 'notes-home-')),
|
|
37
|
+
NOTES_DB: join(mkdtempSync(join(scratch, 'notes-')), 'notes.sqlite'),
|
|
38
|
+
...env,
|
|
39
|
+
},
|
|
40
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
let out = ''
|
|
44
|
+
const address = await new Promise<string>((resolve, reject) => {
|
|
45
|
+
child.once('exit', (code) =>
|
|
46
|
+
reject(new Error(`the server exited with ${code} before it listened`)),
|
|
47
|
+
)
|
|
48
|
+
child.stdout.on('data', (chunk) => {
|
|
49
|
+
out += chunk
|
|
50
|
+
const match = /listening on (http:\/\/\S+)/.exec(out)
|
|
51
|
+
if (match?.[1]) resolve(match[1])
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
return { child, address }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('the server listens on 127.0.0.1 unless HOST says otherwise', { skip }, async () => {
|
|
58
|
+
const { child, address } = await start()
|
|
59
|
+
try {
|
|
60
|
+
assert.match(address, /^http:\/\/127\.0\.0\.1:\d+$/)
|
|
61
|
+
} finally {
|
|
62
|
+
child.kill('SIGTERM')
|
|
63
|
+
await once(child, 'exit')
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
|
68
|
+
test(`${signal} stops the server cleanly and frees the port`, { skip }, async () => {
|
|
69
|
+
const { child, address } = await start()
|
|
70
|
+
assert.equal((await fetch(`${address}/signup`)).status, 200)
|
|
71
|
+
const started = Date.now()
|
|
72
|
+
child.kill(signal)
|
|
73
|
+
const [code] = await once(child, 'exit')
|
|
74
|
+
assert.equal(code, 0)
|
|
75
|
+
assert.ok(Date.now() - started < 5000, 'the server took too long to stop')
|
|
76
|
+
await assert.rejects(fetch(address), 'the port should be free')
|
|
77
|
+
})
|
|
78
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noImplicitOverride": true,
|
|
9
|
+
"verbatimModuleSyntax": true,
|
|
10
|
+
"erasableSyntaxOnly": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"types": ["node"],
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
"jsxImportSource": "#acryl-jsx"
|
|
17
|
+
},
|
|
18
|
+
"include": ["src", "test", "plugins"],
|
|
19
|
+
"exclude": ["src/client"]
|
|
20
|
+
}
|
package/templates/todo/README.md
CHANGED
|
@@ -24,4 +24,4 @@ The routes only answer a caller on this machine: a request from another site's p
|
|
|
24
24
|
Open `$URL/todo` for the page. Stop the app and start it again: the list is still there (`<home>/todo.json`, `.acrylblends/` unless `ACRYLBLENDS_HOME` says otherwise).
|
|
25
25
|
|
|
26
26
|
- `blend.yaml` is the app as data. `npm run lock` records what it is built from; `npm run save` commits it, refusing secrets and public remotes for a private app.
|
|
27
|
-
- The framework plugins are in `
|
|
27
|
+
- The framework plugins are in `plugins/acryl-*/src`. Do not edit them: they are ACRYL's, vendored; the text that explains them is in the ACRYLBLENDS docs.
|
package/templates/todo/gitignore
CHANGED
|
@@ -4,20 +4,20 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"imports": {
|
|
7
|
-
"#acryl-jsx/jsx-runtime": "./
|
|
8
|
-
"#acryl-jsx/jsx-dev-runtime": "./
|
|
7
|
+
"#acryl-jsx/jsx-runtime": "./plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
|
|
8
|
+
"#acryl-jsx/jsx-dev-runtime": "./plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
|
|
9
9
|
},
|
|
10
10
|
"engines": {
|
|
11
11
|
"node": ">=22.19.0"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"start": "node --import ./
|
|
15
|
-
"dev": "node --watch --import ./
|
|
16
|
-
"test": "node --import ./
|
|
14
|
+
"start": "node --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
|
|
15
|
+
"dev": "node --watch --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
|
|
16
|
+
"test": "node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
|
|
17
17
|
"check": "tsc --noEmit",
|
|
18
|
-
"lock": "node
|
|
19
|
-
"save": "node
|
|
20
|
-
"upgrade": "node
|
|
18
|
+
"lock": "node plugins/manifest/lock.ts",
|
|
19
|
+
"save": "node plugins/save/cli.ts",
|
|
20
|
+
"upgrade": "node plugins/manifest/upgrade-cli.ts"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"typescript": "6.0.3"
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
4
|
-
import * as nodeServer from '
|
|
5
|
-
import * as router from '
|
|
6
|
-
import * as state from '
|
|
7
|
-
import { type AppInstance, instance } from '
|
|
4
|
+
import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
|
|
5
|
+
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
6
|
+
import * as state from '../plugins/acryl-state/src/index.ts'
|
|
7
|
+
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
8
8
|
import {
|
|
9
9
|
blueprintsIn,
|
|
10
10
|
compose,
|
|
11
11
|
loadManifest,
|
|
12
12
|
type ParameterValue,
|
|
13
|
-
} from '
|
|
13
|
+
} from '../plugins/manifest/index.ts'
|
|
14
14
|
import * as todo from './todo.ts'
|
|
15
15
|
|
|
16
16
|
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
@@ -38,8 +38,19 @@ export function readManifest(values?: Readonly<Record<string, ParameterValue>>)
|
|
|
38
38
|
})
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** The manifest without its server row: the app is then reached in process, through `root.router.request`. */
|
|
42
|
+
function withoutServer<M extends { plugins: readonly { use: string }[] }>(manifest: M): M {
|
|
43
|
+
return {
|
|
44
|
+
...manifest,
|
|
45
|
+
plugins: manifest.plugins.map((row) =>
|
|
46
|
+
row.use === 'acryl-node-server' ? { ...row, disabled: true } : row,
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
export interface App {
|
|
42
52
|
readonly root: Context
|
|
53
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
43
54
|
readonly url: string
|
|
44
55
|
stop(): Promise<void>
|
|
45
56
|
}
|
|
@@ -47,7 +58,11 @@ export interface App {
|
|
|
47
58
|
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
48
59
|
export async function createApp(
|
|
49
60
|
chosen: AppInstance,
|
|
50
|
-
options: {
|
|
61
|
+
options: {
|
|
62
|
+
values?: Readonly<Record<string, ParameterValue>>
|
|
63
|
+
/** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
|
|
64
|
+
listen?: boolean
|
|
65
|
+
} = {},
|
|
51
66
|
): Promise<App> {
|
|
52
67
|
const manifest = readManifest(options.values)
|
|
53
68
|
const root = new Context()
|
|
@@ -55,15 +70,20 @@ export async function createApp(
|
|
|
55
70
|
await first.await()
|
|
56
71
|
const fibers: Fiber[] = [
|
|
57
72
|
first,
|
|
58
|
-
...(await compose(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
...(await compose(
|
|
74
|
+
root,
|
|
75
|
+
options.listen === false ? withoutServer(manifest) : manifest,
|
|
76
|
+
registry,
|
|
77
|
+
{
|
|
78
|
+
// Runtime values the manifest cannot hold: where the list is kept (in the app's home) and where the server listens.
|
|
79
|
+
todo: { file: join(chosen.home, 'todo.json') },
|
|
80
|
+
server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
|
|
81
|
+
},
|
|
82
|
+
)),
|
|
63
83
|
]
|
|
64
84
|
return {
|
|
65
85
|
root,
|
|
66
|
-
url: root.nodeServer.url,
|
|
86
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
67
87
|
async stop() {
|
|
68
88
|
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
69
89
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url'
|
|
2
|
+
import { selectInstance } from '../plugins/instance/index.ts'
|
|
2
3
|
import { APP_NAME, createApp } from './app.ts'
|
|
3
|
-
import { selectInstance } from './plugins/instance/index.ts'
|
|
4
4
|
|
|
5
5
|
// The composition root: the one place that reads the environment (through selectInstance) and chooses where the app lives.
|
|
6
6
|
const chosen = selectInstance({
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
7
7
|
import { dirname } from 'node:path'
|
|
8
8
|
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
-
import { Acryl, HTTPException } from '
|
|
10
|
-
import { bodyLimit, loopbackOnly, trimTrailingSlash } from '
|
|
11
|
-
import { schemaValidator, streamSSE, type StandardSchema } from '
|
|
12
|
-
import type {} from '
|
|
9
|
+
import { Acryl, HTTPException } from '../plugins/acryl-router/src/index.ts'
|
|
10
|
+
import { bodyLimit, loopbackOnly, trimTrailingSlash } from '../plugins/acryl-router/src/middleware/index.ts'
|
|
11
|
+
import { schemaValidator, streamSSE, type StandardSchema } from '../plugins/acryl-router/src/helpers/index.ts'
|
|
12
|
+
import type {} from '../plugins/acryl-state/src/index.ts'
|
|
13
13
|
import { PAGE } from './page.ts'
|
|
14
14
|
|
|
15
15
|
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
2
3
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
-
import {
|
|
4
|
+
import { createServer } from 'node:net'
|
|
4
5
|
import { join } from 'node:path'
|
|
5
6
|
import { test } from 'node:test'
|
|
6
7
|
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
7
9
|
import { type App, createApp, readManifest } from '../src/app.ts'
|
|
8
|
-
|
|
10
|
+
|
|
11
|
+
/** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
|
|
12
|
+
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
13
|
+
mkdirSync(scratch, { recursive: true })
|
|
9
14
|
|
|
10
15
|
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
11
16
|
|
|
12
|
-
/** Boot the app
|
|
13
|
-
async function withApp(home: string, run: (app: App) => Promise<void
|
|
14
|
-
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
|
|
17
|
+
/** Boot the app with its data in `home`, run the test, always stop it. In process by default (`app.root.router.request`); `listen` starts the real server on any free port. */
|
|
18
|
+
async function withApp(home: string, run: (app: App) => Promise<void>, listen = false) {
|
|
19
|
+
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), { listen })
|
|
15
20
|
try {
|
|
16
21
|
await run(app)
|
|
17
22
|
} finally {
|
|
@@ -20,7 +25,7 @@ async function withApp(home: string, run: (app: App) => Promise<void>) {
|
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
async function withHome(run: (home: string) => Promise<void>) {
|
|
23
|
-
const home = await mkdtemp(join(
|
|
28
|
+
const home = await mkdtemp(join(scratch, 'todo-test-'))
|
|
24
29
|
try {
|
|
25
30
|
await run(home)
|
|
26
31
|
} finally {
|
|
@@ -29,7 +34,7 @@ async function withHome(run: (home: string) => Promise<void>) {
|
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
const send = (app: App, method: string, path: string, body?: unknown) =>
|
|
32
|
-
|
|
37
|
+
app.root.router.request(`${path}`, {
|
|
33
38
|
method,
|
|
34
39
|
headers: { 'content-type': 'application/json' },
|
|
35
40
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
@@ -53,21 +58,41 @@ test('creates, completes, lists and deletes', () =>
|
|
|
53
58
|
}),
|
|
54
59
|
))
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
61
|
+
/** A sandbox may forbid listening on a port (Codex's workspace-write does). The one test that needs a real connection (the origin check looks at who connected) is skipped there, and says so. */
|
|
62
|
+
const canListen = await new Promise<boolean>((done) => {
|
|
63
|
+
const probe = createServer()
|
|
64
|
+
probe.once('error', () => done(false))
|
|
65
|
+
probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
|
|
66
|
+
})
|
|
67
|
+
const skip = canListen ? false : 'this environment does not allow listening on a port'
|
|
68
|
+
if (!canListen)
|
|
69
|
+
console.log(
|
|
70
|
+
`skipped: the test that starts the real server (${skip}); every other test ran in process`,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
test(
|
|
74
|
+
'a change from a foreign origin is refused; a plain request from this machine works',
|
|
75
|
+
{ skip },
|
|
76
|
+
() =>
|
|
77
|
+
withHome((home) =>
|
|
78
|
+
withApp(
|
|
79
|
+
home,
|
|
80
|
+
async (app) => {
|
|
81
|
+
const post = (headers: Record<string, string>) =>
|
|
82
|
+
fetch(`${app.url}/todo/api`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
85
|
+
body: JSON.stringify({ title: 'x' }),
|
|
86
|
+
})
|
|
87
|
+
assert.equal((await post({ origin: 'http://evil.example' })).status, 403)
|
|
88
|
+
assert.equal((await post({ origin: 'null' })).status, 403)
|
|
89
|
+
assert.equal((await post({})).status, 201)
|
|
90
|
+
assert.equal((await post({ origin: new URL(app.url).origin })).status, 201)
|
|
91
|
+
},
|
|
92
|
+
true,
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
71
96
|
|
|
72
97
|
test('rejects bad input and unknown ids', () =>
|
|
73
98
|
withHome((home) =>
|
|
@@ -114,7 +139,7 @@ test('serves the page, and streams the list: now, and again on every change', ()
|
|
|
114
139
|
assert.match(await page.text(), /<title>Todo<\/title>/)
|
|
115
140
|
|
|
116
141
|
const controller = new AbortController()
|
|
117
|
-
const stream = await
|
|
142
|
+
const stream = await app.root.router.request(`/todo/events`, { signal: controller.signal })
|
|
118
143
|
const reader = stream.body!.getReader()
|
|
119
144
|
const decoder = new TextDecoder()
|
|
120
145
|
let text = ''
|