acrylblends 0.4.0 → 0.4.1
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.js +56 -2
- package/dist/index.js.map +1 -1
- package/docs/framework/LICENSE +23 -0
- package/docs/framework/README.md +63 -0
- package/docs/framework/UPSTREAM.md +3 -0
- package/docs/framework/api-summary.md +138 -0
- package/docs/framework/concepts.md +105 -0
- package/docs/framework/context.md +129 -0
- package/docs/framework/getting-started.md +146 -0
- package/docs/framework/helpers.md +118 -0
- package/docs/framework/middleware.md +154 -0
- package/docs/framework/migration-from-0-3.md +108 -0
- package/docs/framework/migration-from-dsh-webserver.md +103 -0
- package/docs/framework/node-server.md +75 -0
- package/docs/framework/plugin-lifecycle.md +111 -0
- package/docs/framework/request.md +106 -0
- package/docs/framework/routing.md +188 -0
- package/docs/framework/surfaces.md +71 -0
- package/docs/framework/upstream.json +9 -0
- package/docs/framework/validation.md +135 -0
- package/package.json +4 -2
- package/plugins/acryl-inspect/src/report.ts +15 -10
- package/plugins/acryl-inspect/tests/report.spec.ts +9 -3
- package/plugins/acryl-inspect/upstream.json +2 -2
- package/plugins/acryl-node-server/upstream.json +1 -1
- package/plugins/acryl-router/src/middleware/index.ts +1 -1
- package/plugins/acryl-router/src/middleware/loopback-only.ts +34 -1
- package/plugins/acryl-router/src/service.ts +9 -3
- package/plugins/acryl-router/tests/loopback-only.spec.ts +80 -0
- package/plugins/acryl-router/tests/service.spec.ts +17 -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/builder/script.ts +1 -1
- package/plugins/extensions/index.ts +4 -3
- package/plugins/openai/prompt.ts +2 -2
- package/shared/AGENTS.core.md +54 -0
- package/templates/agent/AGENTS.md +6 -0
- package/templates/agent/README.md +2 -2
- package/templates/agent/blend.yaml +1 -1
- package/templates/blank/AGENTS.md +5 -0
- package/templates/blank/README.md +1 -1
- package/templates/todo/AGENTS.md +5 -0
- package/templates/todo/src/todo.ts +4 -2
- package/templates/todo/upstream.json +2 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Validation
|
|
2
|
+
|
|
3
|
+
Validate untrusted input at the route boundary. `schemaValidator` takes any [Standard Schema](https://standardschema.dev): Schemastery
|
|
4
|
+
(the schema library Cordis already uses for plugin config), Zod and Valibot all implement it, so a route needs no extra dependency.
|
|
5
|
+
|
|
6
|
+
## Validate JSON
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import z from '@deepseek-ai/schemastery'
|
|
10
|
+
import { App } from 'acryl-router'
|
|
11
|
+
import { schemaValidator } from 'acryl-router/helpers'
|
|
12
|
+
|
|
13
|
+
const NewTodo = z.object({
|
|
14
|
+
title: z.string().required(),
|
|
15
|
+
done: z.boolean().default(false),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const app = new App().post('/todos', schemaValidator('json', NewTodo), c => {
|
|
19
|
+
const todo = c.req.valid('json') // typed by the schema's output
|
|
20
|
+
return c.json({ todo }, 201)
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A validated route has three parts: a schema for one input target, the validator middleware for that target, and a handler that reads
|
|
25
|
+
`c.req.valid(target)` instead of the raw body.
|
|
26
|
+
|
|
27
|
+
## Failures
|
|
28
|
+
|
|
29
|
+
When the schema reports issues the handler does not run and the answer is 400 with `Content-Type: application/json` and exactly this
|
|
30
|
+
shape:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
{ error: `invalid ${target}`, issues: Array<{ message: string, path: string }> }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{ "error": "invalid json", "issues": [{ "message": "title must be 1 to 200 characters", "path": "title" }] }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- `error` names the target: `invalid json`, `invalid query`, `invalid param`, ...
|
|
41
|
+
- There is one entry per issue the schema reported, in its order, with the schema's own `message`.
|
|
42
|
+
- `path` is the issue's path joined with dots: `["items", 0, "title"]` becomes `"items.0.title"`, and a path segment given as
|
|
43
|
+
`{ key }` (as Standard Schema allows) contributes its `key`.
|
|
44
|
+
- An issue without a path, which a hand-written Standard Schema often reports for a whole-value check, gets `"path": ""`.
|
|
45
|
+
`{ issues: [{ message: 'not allowed' }] }` answers `{ "error": "invalid json", "issues": [{ "message": "not allowed", "path": "" }] }`.
|
|
46
|
+
Give issues a path when a client should point at a field.
|
|
47
|
+
|
|
48
|
+
### A JSON body without a JSON content type
|
|
49
|
+
|
|
50
|
+
A `json` target reads the body only when the request's `Content-Type` is JSON (`application/json`, with or without parameters such as
|
|
51
|
+
`; charset=utf-8`, or `application/*+json`). Any other content type, or none, is not an error by itself: the schema sees `{}`. So a
|
|
52
|
+
client that forgets the header gets a misleading field error for a field it did send:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
curl -X POST -d '{"title":"milk"}' http://127.0.0.1:<port>/todo/api # no content type: curl sends a form type
|
|
56
|
+
# 400 {"error":"invalid json","issues":[{"message":"title must be 1 to 200 characters","path":"title"}]}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
If you see a field error for a field the request clearly carries, check the request's `Content-Type` first. A route that wants to say so
|
|
60
|
+
explicitly can refuse other content types before the validator:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import type { MiddlewareHandler } from 'acryl-router'
|
|
64
|
+
|
|
65
|
+
const jsonOnly: MiddlewareHandler = async (c, next) => {
|
|
66
|
+
// The same types the json target reads: application/json and application/*+json, parameters allowed.
|
|
67
|
+
const type = c.req.header('content-type')?.split(';', 1)[0]?.trim() ?? ''
|
|
68
|
+
if (!/^application\/([a-z.-]+\+)?json$/i.test(type)) return c.json({ error: 'content type must be JSON' }, 415)
|
|
69
|
+
await next()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
app.post('/todos', jsonOnly, schemaValidator('json', NewTodo), createTodo)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### A body that is not JSON
|
|
76
|
+
|
|
77
|
+
A JSON content type with a body that does not parse fails before the schema runs: the answer is 400 with the text body
|
|
78
|
+
`Malformed JSON in request body` (a thrown `HTTPException`, so an `onError` handler can reshape it). The `form` target does the same for
|
|
79
|
+
a form body that does not parse, with the text `Malformed FormData request.`
|
|
80
|
+
|
|
81
|
+
Keep the public failure shape consistent across the application and do not leak internals in messages. An application that needs a
|
|
82
|
+
different body writes its own check with the core `validator` (below). Valid input is not proof that the caller may use it:
|
|
83
|
+
authentication and authorization stay separate checks.
|
|
84
|
+
|
|
85
|
+
## Targets
|
|
86
|
+
|
|
87
|
+
| Target | Read from |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `json` | the JSON body |
|
|
90
|
+
| `form` | a `multipart/form-data` or `application/x-www-form-urlencoded` body |
|
|
91
|
+
| `query` | the query string |
|
|
92
|
+
| `param` | route parameters |
|
|
93
|
+
| `header` | request headers (names in lowercase) |
|
|
94
|
+
| `cookie` | cookies |
|
|
95
|
+
|
|
96
|
+
Validate only the targets the route uses, and chain one validator per target:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
app.get('/todos/:id', schemaValidator('param', z.object({ id: z.natural() })), schemaValidator('query', Paging), handler)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## The core validator
|
|
103
|
+
|
|
104
|
+
`validator` (also from `acryl-router/helpers`) takes a function instead of a schema. Return the value the handler should see, or a
|
|
105
|
+
response to refuse:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { validator } from 'acryl-router/helpers'
|
|
109
|
+
|
|
110
|
+
app.post('/posts', validator('form', (value, c) => {
|
|
111
|
+
const body = value['body']
|
|
112
|
+
if (typeof body !== 'string') return c.text('Invalid!', 400)
|
|
113
|
+
return { body }
|
|
114
|
+
}), c => c.json({ body: c.req.valid('form').body }, 201))
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Limit the body first
|
|
118
|
+
|
|
119
|
+
Validation reads the whole body. Put `bodyLimit` in front of a validator on any route open to untrusted callers:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { bodyLimit } from 'acryl-router/middleware'
|
|
123
|
+
|
|
124
|
+
app.post('/todos', bodyLimit({ maxSize: 16 * 1024 }), schemaValidator('json', NewTodo), createTodo)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Typed clients
|
|
128
|
+
|
|
129
|
+
The validated input types flow into the route types, so the typed `testClient` and `acryl-router/client` check the request a caller sends.
|
|
130
|
+
See [Helpers](./helpers.md#testing).
|
|
131
|
+
|
|
132
|
+
## What not to validate twice
|
|
133
|
+
|
|
134
|
+
Validate shape at the HTTP boundary, then enforce domain rules in the domain service. The route checks that `title` is a string; the
|
|
135
|
+
todo service decides whether that title is allowed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acrylblends",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Scaffold Cordis apps: acrylblends init.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
"bin",
|
|
21
21
|
"dist",
|
|
22
22
|
"templates",
|
|
23
|
-
"
|
|
23
|
+
"shared",
|
|
24
|
+
"plugins",
|
|
25
|
+
"docs"
|
|
24
26
|
],
|
|
25
27
|
"scripts": {
|
|
26
28
|
"build": "tsdown",
|
|
@@ -47,13 +47,19 @@ export interface PluginReport {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const PROVIDE = /^ctx\.provide\("(.+)"\)$/
|
|
50
|
-
const MOUNT = /^acryl-router: route (\S+)
|
|
51
|
-
const
|
|
50
|
+
const MOUNT = /^acryl-router: route (\S+)$/
|
|
51
|
+
const SERVES = /^acryl-router: serves ([A-Z]+) (\S+)$/
|
|
52
52
|
|
|
53
53
|
function flatten(effects: readonly EffectLabel[]): string[] {
|
|
54
54
|
return effects.flatMap(effect => [effect.label, ...flatten(effect.children)])
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** A mount's children name the routes it serves; they describe that one registration and are not registrations of their own. */
|
|
58
|
+
function routeOf(label: string): RouteReport | undefined {
|
|
59
|
+
const served = SERVES.exec(label)
|
|
60
|
+
return served?.[1] !== undefined && served[2] !== undefined ? { method: served[1], path: served[2] } : undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
export function stateName(state: number): FiberStateName {
|
|
58
64
|
return STATE_NAMES[state] ?? 'FAILED'
|
|
59
65
|
}
|
|
@@ -78,17 +84,16 @@ export function reportFiber(fiber: FiberView, adopted: readonly FiberView[] = []
|
|
|
78
84
|
const routes: RouteReport[] = []
|
|
79
85
|
const other: string[] = []
|
|
80
86
|
const labels = [fiber, ...adopted].flatMap(source => flatten(source.getEffects()))
|
|
87
|
+
let registrations = 0
|
|
81
88
|
for (const label of labels) {
|
|
82
89
|
const provided = PROVIDE.exec(label)
|
|
83
90
|
const mount = MOUNT.exec(label)
|
|
91
|
+
const route = routeOf(label)
|
|
92
|
+
if (route !== undefined) { routes.push(route); continue }
|
|
93
|
+
registrations += 1
|
|
84
94
|
if (provided?.[1] !== undefined) provides.push(provided[1])
|
|
85
|
-
else if (mount?.[1] !== undefined)
|
|
86
|
-
|
|
87
|
-
for (const entry of (mount[2] ?? '').split(', ')) {
|
|
88
|
-
const served = SERVED.exec(entry)
|
|
89
|
-
if (served?.[1] !== undefined && served[2] !== undefined) routes.push({ method: served[1], path: served[2] })
|
|
90
|
-
}
|
|
91
|
-
} else other.push(label)
|
|
95
|
+
else if (mount?.[1] !== undefined) mounts.push(mount[1])
|
|
96
|
+
else other.push(label)
|
|
92
97
|
}
|
|
93
98
|
return {
|
|
94
99
|
name: isNamed(fiber) ? fiber.runtime?.name ?? 'anonymous' : 'anonymous',
|
|
@@ -98,7 +103,7 @@ export function reportFiber(fiber: FiberView, adopted: readonly FiberView[] = []
|
|
|
98
103
|
mounts,
|
|
99
104
|
routes,
|
|
100
105
|
other,
|
|
101
|
-
removedOnUnload:
|
|
106
|
+
removedOnUnload: registrations,
|
|
102
107
|
}
|
|
103
108
|
}
|
|
104
109
|
|
|
@@ -12,8 +12,14 @@ describe('reportFiber', () => {
|
|
|
12
12
|
inject: { router: null, acrylSettings: null },
|
|
13
13
|
effects: [
|
|
14
14
|
{ label: 'ctx.provide("things")', children: [] },
|
|
15
|
-
{ label: 'acryl-router: route /api
|
|
16
|
-
|
|
15
|
+
{ label: 'acryl-router: route /api', children: [
|
|
16
|
+
{ label: 'acryl-router: serves ALL /api/*', children: [] },
|
|
17
|
+
{ label: 'acryl-router: serves GET /api/things/:id', children: [] },
|
|
18
|
+
] },
|
|
19
|
+
{ label: 'acryl-router: route /api/things', children: [
|
|
20
|
+
{ label: 'acryl-router: serves POST /api/things', children: [] },
|
|
21
|
+
{ label: 'inner timer', children: [] },
|
|
22
|
+
] },
|
|
17
23
|
{ label: 'ctx.on("internal/status")', children: [] },
|
|
18
24
|
],
|
|
19
25
|
}))
|
|
@@ -41,7 +47,7 @@ describe('explain', () => {
|
|
|
41
47
|
it('says what a plugin needs, adds and removes on unload', () => {
|
|
42
48
|
const text = explain(reportFiber(fiber({
|
|
43
49
|
inject: { router: null },
|
|
44
|
-
effects: [{ label: 'acryl-router: route /
|
|
50
|
+
effects: [{ label: 'acryl-router: route /', children: [{ label: 'acryl-router: serves GET /a', children: [] }] }],
|
|
45
51
|
})))
|
|
46
52
|
expect(text).toContain('demo (ACTIVE)')
|
|
47
53
|
expect(text).toContain('needs: router')
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"source": {
|
|
3
3
|
"repo": "acryldev/acryl",
|
|
4
4
|
"path": "plugins/acryl-inspect",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "64e7c9d2e787f74b2da82492644fbbb46a601074"
|
|
6
6
|
},
|
|
7
7
|
"vendored": [
|
|
8
8
|
"src",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"LICENSE"
|
|
11
11
|
],
|
|
12
12
|
"rewrittenSpecifiers": 3,
|
|
13
|
-
"digest": "sha256:
|
|
13
|
+
"digest": "sha256:434ed3cf6b9028da90ad6b395209affbc917a62aa994f2b3c8f1d98a26398ea7",
|
|
14
14
|
"testsNotRun": {},
|
|
15
15
|
"notice": [
|
|
16
16
|
"LICENSE"
|
|
@@ -22,4 +22,4 @@ export { NONCE, secureHeaders, type SecureHeadersVariables } from '../core/middl
|
|
|
22
22
|
export { timeout } from '../core/middleware/timeout/index.ts'
|
|
23
23
|
export { endTime, setMetric, startTime, timing, wrapTime, type TimingVariables } from '../core/middleware/timing/index.ts'
|
|
24
24
|
export { appendTrailingSlash, trimTrailingSlash } from '../core/middleware/trailing-slash/index.ts'
|
|
25
|
-
export { LOOPBACK_HOSTNAMES, loopbackAuthority, loopbackOnly, sameOrigin, type LoopbackOptions } from './loopback-only.ts'
|
|
25
|
+
export { LOOPBACK_HOSTNAMES, fromOwnPage, loopbackAuthority, loopbackOnly, sameOrigin, type LoopbackOptions } from './loopback-only.ts'
|
|
@@ -19,6 +19,13 @@ export interface LoopbackOptions {
|
|
|
19
19
|
* checked in full: only a call with no connection facts at all counts as in process.
|
|
20
20
|
*/
|
|
21
21
|
readonly inProcess?: 'deny' | 'allow'
|
|
22
|
+
/**
|
|
23
|
+
* Who may call. `local` (default): any program on this machine, a browser page only as `mutations` allows. `own-page`: only this app's own page,
|
|
24
|
+
* for private routes a script on the machine must not drive or read either. A request then shows an `Origin` that is exactly this authority (with
|
|
25
|
+
* `Sec-Fetch-Site` absent or `same-origin`), or, for a read (GET, HEAD, OPTIONS), which browsers often send without `Origin`, `Sec-Fetch-Site:
|
|
26
|
+
* same-origin` and a `Referer` from this authority. `mutations` is not consulted: every request is held to that rule.
|
|
27
|
+
*/
|
|
28
|
+
readonly callers?: 'local' | 'own-page'
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
const loopback = new BlockList()
|
|
@@ -68,10 +75,31 @@ export function sameOrigin(c: Context, authority: URL): boolean {
|
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
77
|
|
|
71
|
-
|
|
78
|
+
function originOf(value: string | undefined): string | undefined {
|
|
79
|
+
if (value === undefined) return undefined
|
|
80
|
+
try {
|
|
81
|
+
return new URL(value).origin
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether the request comes from a page served by this authority itself: the `own-page` rule of {@link LoopbackOptions.callers}. */
|
|
88
|
+
export function fromOwnPage(c: Context, authority: URL): boolean {
|
|
89
|
+
const origin = c.req.header('origin')
|
|
90
|
+
const fetchSite = c.req.header('sec-fetch-site')
|
|
91
|
+
if (origin !== undefined && originOf(origin) === origin && origin === authority.origin) return fetchSite === undefined || fetchSite === 'same-origin'
|
|
92
|
+
return SAFE_METHODS.has(c.req.method) && fetchSite === 'same-origin' && originOf(c.req.header('referer')) === authority.origin
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Only requests from this machine to this app: loopback peer, the real port, an allowed `Host`, and for mutations a matching `Origin` (with
|
|
97
|
+
* `callers: 'own-page'`, every request from this app's own page). Everything else gets a 403.
|
|
98
|
+
*/
|
|
72
99
|
export function loopbackOnly(options: LoopbackOptions = {}): MiddlewareHandler {
|
|
73
100
|
const mutations = options.mutations ?? 'same-origin'
|
|
74
101
|
const inProcess = options.inProcess ?? 'deny'
|
|
102
|
+
const ownPage = options.callers === 'own-page'
|
|
75
103
|
return async (c, next) => {
|
|
76
104
|
const connection = connectionOf(c)
|
|
77
105
|
if (inProcess === 'allow' && connection.remoteAddress === undefined && connection.localPort === undefined) {
|
|
@@ -80,6 +108,11 @@ export function loopbackOnly(options: LoopbackOptions = {}): MiddlewareHandler {
|
|
|
80
108
|
}
|
|
81
109
|
const authority = loopbackAuthority(c, options.hostnames)
|
|
82
110
|
if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
|
|
111
|
+
if (ownPage) {
|
|
112
|
+
if (!fromOwnPage(c, authority)) return c.json({ error: 'only this app\'s own page may call this' }, 403)
|
|
113
|
+
await next()
|
|
114
|
+
return
|
|
115
|
+
}
|
|
83
116
|
if (mutations === 'same-origin' && !SAFE_METHODS.has(c.req.method) && !sameOrigin(c, authority)) {
|
|
84
117
|
return c.json({ error: 'a change must come from this app\'s own origin' }, 403)
|
|
85
118
|
}
|
|
@@ -144,9 +144,15 @@ export class Router extends Service {
|
|
|
144
144
|
this.state.composed = undefined
|
|
145
145
|
return this.end([...this.flights].filter(flight => (flight.running.get(mount) ?? 0) > 0 || flight.producer === mount), `removing ${path}`)
|
|
146
146
|
}
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
// Each route the mount serves is a child effect of it, labeled on its own, so the inspector can report routes without asking the
|
|
148
|
+
// router and no label grows with the size of the app (`ALL` is middleware or `all`). The children hold nothing: the mount does.
|
|
149
|
+
// (Cordis adopts an effect as a child only when the parent's body yields it.)
|
|
150
|
+
const served = new Set(app.routes.map(route => `${route.method} ${mergePath(path, route.path)}`))
|
|
151
|
+
const owner = this.ctx
|
|
152
|
+
owner.effect(function* () {
|
|
153
|
+
for (const route of served) yield owner.effect(() => () => {}, `acryl-router: serves ${route}`)
|
|
154
|
+
yield remove
|
|
155
|
+
}, `acryl-router: route ${path}`)
|
|
150
156
|
return () => { void remove() }
|
|
151
157
|
}
|
|
152
158
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loopbackOnly`, the guard of a local app's own routes: the peer and port come from the transport, the `Host` names this app, and the
|
|
3
|
+
* `callers` rule decides whether any local program or only the app's own page may call.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { App, type ConnectionInfo } from '../src/index.ts'
|
|
8
|
+
import { loopbackOnly, type LoopbackOptions } from '../src/middleware/index.ts'
|
|
9
|
+
|
|
10
|
+
const PORT = 43_120
|
|
11
|
+
const SELF = `http://127.0.0.1:${String(PORT)}`
|
|
12
|
+
const LOCAL: ConnectionInfo = { remoteAddress: '127.0.0.1', localPort: PORT }
|
|
13
|
+
|
|
14
|
+
function guarded(options: LoopbackOptions = {}): App {
|
|
15
|
+
return new App().use(loopbackOnly(options)).get('/x', c => c.text('read')).post('/x', c => c.text('changed'))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function status(
|
|
19
|
+
app: App,
|
|
20
|
+
method: string,
|
|
21
|
+
headers: Record<string, string>,
|
|
22
|
+
connection: ConnectionInfo = LOCAL,
|
|
23
|
+
): Promise<number> {
|
|
24
|
+
const response = await app.fetch(new Request(`${SELF}/x`, { method, headers: { host: `127.0.0.1:${String(PORT)}`, ...headers } }), { connection })
|
|
25
|
+
return response.status
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('loopbackOnly, callers: local (the default)', () => {
|
|
29
|
+
const app = guarded()
|
|
30
|
+
|
|
31
|
+
it('lets a local program read and change, and a page of this app change', async () => {
|
|
32
|
+
expect(await status(app, 'GET', {})).toBe(200)
|
|
33
|
+
expect(await status(app, 'POST', {})).toBe(200)
|
|
34
|
+
expect(await status(app, 'POST', { origin: SELF })).toBe(200)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('refuses another origin\'s change, a foreign peer, a wrong port, a foreign Host and an in-process call', async () => {
|
|
38
|
+
expect(await status(app, 'POST', { origin: 'http://evil.test' })).toBe(403)
|
|
39
|
+
expect(await status(app, 'GET', {}, { remoteAddress: '192.168.1.5', localPort: PORT })).toBe(403)
|
|
40
|
+
expect(await status(app, 'GET', {}, { remoteAddress: '127.0.0.1', localPort: PORT + 1 })).toBe(403)
|
|
41
|
+
expect(await status(app, 'GET', { host: 'evil.test' })).toBe(403)
|
|
42
|
+
expect(await status(app, 'GET', {}, {})).toBe(403)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('lets an in-process call through only when the host says so', async () => {
|
|
46
|
+
expect(await status(guarded({ inProcess: 'allow' }), 'GET', {}, {})).toBe(200)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('loopbackOnly, callers: own-page', () => {
|
|
51
|
+
const app = guarded({ callers: 'own-page', hostnames: ['127.0.0.1'] })
|
|
52
|
+
|
|
53
|
+
it('lets this app\'s page read and change with its exact Origin', async () => {
|
|
54
|
+
expect(await status(app, 'GET', { origin: SELF, 'sec-fetch-site': 'same-origin' })).toBe(200)
|
|
55
|
+
expect(await status(app, 'POST', { origin: SELF, 'sec-fetch-site': 'same-origin' })).toBe(200)
|
|
56
|
+
expect(await status(app, 'POST', { origin: SELF })).toBe(200)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('lets a same-origin read without Origin through by its Referer, as browsers send it', async () => {
|
|
60
|
+
expect(await status(app, 'GET', { 'sec-fetch-site': 'same-origin', referer: `${SELF}/page?tab=1` })).toBe(200)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('refuses a local program (no Origin), even for a read', async () => {
|
|
64
|
+
expect(await status(app, 'GET', {})).toBe(403)
|
|
65
|
+
expect(await status(app, 'POST', {})).toBe(403)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('refuses a change that only has the read evidence, and Origin forms that are not exact', async () => {
|
|
69
|
+
expect(await status(app, 'POST', { 'sec-fetch-site': 'same-origin', referer: `${SELF}/` })).toBe(403)
|
|
70
|
+
expect(await status(app, 'POST', { origin: `${SELF}/` })).toBe(403)
|
|
71
|
+
expect(await status(app, 'POST', { origin: 'null' })).toBe(403)
|
|
72
|
+
expect(await status(app, 'POST', { origin: SELF, 'sec-fetch-site': 'same-site' })).toBe(403)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('refuses another page, a Referer from elsewhere and a Host this app does not answer to', async () => {
|
|
76
|
+
expect(await status(app, 'GET', { origin: 'http://evil.test', 'sec-fetch-site': 'cross-site' })).toBe(403)
|
|
77
|
+
expect(await status(app, 'GET', { 'sec-fetch-site': 'same-origin', referer: 'http://evil.test/' })).toBe(403)
|
|
78
|
+
expect(await status(app, 'GET', { host: `localhost:${String(PORT)}`, origin: `http://localhost:${String(PORT)}` })).toBe(403)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -152,6 +152,23 @@ describe('acryl-router lifecycle through Cordis', () => {
|
|
|
152
152
|
expect(router.size).toBe(1)
|
|
153
153
|
})
|
|
154
154
|
|
|
155
|
+
it('labels a mount by its path with one child per route, so no label grows with the app (an inspector caps label length)', async () => {
|
|
156
|
+
const { ctx, router } = await mountRouter()
|
|
157
|
+
const big = new App().use('/api/*', async (_c, next) => { await next() })
|
|
158
|
+
for (let i = 0; i < 60; i += 1) big.get(`/api/a-fairly-long-resource-name-${String(i)}`, c => c.text('ok'))
|
|
159
|
+
const fiber = ctx.plugin({ name: 'big', inject: ['router'], apply: (inner: Context) => { inner.router.route('/', big) } })
|
|
160
|
+
await settle()
|
|
161
|
+
const [mount, ...rest] = fiber.getEffects()
|
|
162
|
+
expect(rest).toEqual([])
|
|
163
|
+
expect(mount?.label).toBe('acryl-router: route /')
|
|
164
|
+
expect(mount?.children).toHaveLength(61)
|
|
165
|
+
expect(mount?.children[0]?.label).toBe('acryl-router: serves ALL /api/*')
|
|
166
|
+
expect(mount?.children.every(child => child.label.length < 100 && child.children.length === 0)).toBe(true)
|
|
167
|
+
expect(router.size).toBe(1)
|
|
168
|
+
await fiber.dispose()
|
|
169
|
+
expect(router.size).toBe(0)
|
|
170
|
+
})
|
|
171
|
+
|
|
155
172
|
it('replaces the provider without leaving doubled registrations: consumers re-register exactly once', async () => {
|
|
156
173
|
const ctx = root()
|
|
157
174
|
const first = ctx.plugin(provider, config)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"source": {
|
|
3
3
|
"repo": "acryldev/acryl",
|
|
4
4
|
"path": "plugins/acryl-router",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "64e7c9d2e787f74b2da82492644fbbb46a601074"
|
|
6
6
|
},
|
|
7
7
|
"vendored": [
|
|
8
8
|
"src",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"LICENSE"
|
|
11
11
|
],
|
|
12
12
|
"rewrittenSpecifiers": 0,
|
|
13
|
-
"digest": "sha256:
|
|
13
|
+
"digest": "sha256:9ad75d259a74b2a07fe1d8a269e7c83c30061acc90f8a1630e6049b68ba10aa3",
|
|
14
14
|
"testsNotRun": {
|
|
15
15
|
"tests/loader.spec.ts": "loads the built lib/ entry through the real Loader; the library ships source and has no build"
|
|
16
16
|
},
|
|
@@ -30,7 +30,7 @@ export function apply(ctx: Context) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export async function check(ctx: Context) {
|
|
33
|
-
const reply = await ctx.
|
|
33
|
+
const reply = await ctx.router.request(${JSON.stringify(path)})
|
|
34
34
|
if (!reply.ok) throw new Error(\`GET ${path} answered \${reply.status}\`)
|
|
35
35
|
}
|
|
36
36
|
`
|
|
@@ -109,7 +109,7 @@ interface PluginModule {
|
|
|
109
109
|
readonly name?: unknown
|
|
110
110
|
readonly inject?: unknown
|
|
111
111
|
readonly apply: (...args: unknown[]) => unknown
|
|
112
|
-
/** Optional self-check, run after the plugin starts: it throws (or rejects) when the plugin does not work. */
|
|
112
|
+
/** Optional self-check, run after the plugin starts with the plugin's own context (so `ctx.router` reads as in `apply`): it throws (or rejects) when the plugin does not work. */
|
|
113
113
|
readonly check?: (ctx: Context) => unknown
|
|
114
114
|
}
|
|
115
115
|
|
|
@@ -507,6 +507,7 @@ export class Extensions extends Service {
|
|
|
507
507
|
/** Run the extension's own `check`, if it has one, with a time limit. Returns the failure, or `undefined` when it passes. */
|
|
508
508
|
private async verify(
|
|
509
509
|
module: PluginModule,
|
|
510
|
+
ctx: Context,
|
|
510
511
|
): Promise<{ stage: 'verify'; error: string } | undefined> {
|
|
511
512
|
if (typeof module.check !== 'function') return undefined
|
|
512
513
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -517,7 +518,7 @@ export class Extensions extends Service {
|
|
|
517
518
|
CHECK_TIMEOUT_MS,
|
|
518
519
|
)
|
|
519
520
|
})
|
|
520
|
-
await Promise.race([Promise.resolve(module.check(
|
|
521
|
+
await Promise.race([Promise.resolve(module.check(ctx)), limit])
|
|
521
522
|
return undefined
|
|
522
523
|
} catch (error) {
|
|
523
524
|
return { stage: 'verify', error: `its check failed: ${message(error)}` }
|
|
@@ -615,7 +616,7 @@ export class Extensions extends Service {
|
|
|
615
616
|
error: message(mounted.error ?? 'the plugin failed to start'),
|
|
616
617
|
}
|
|
617
618
|
: state === 'active'
|
|
618
|
-
? await this.verify(module)
|
|
619
|
+
? await this.verify(module, mounted.fiber.ctx)
|
|
619
620
|
: undefined
|
|
620
621
|
if (failure) {
|
|
621
622
|
await mounted.fiber.dispose().catch(() => undefined)
|
package/plugins/openai/prompt.ts
CHANGED
|
@@ -17,14 +17,14 @@ A plugin's index.ts is TypeScript that Node runs directly (erasable syntax only:
|
|
|
17
17
|
ctx.router.route('/', new App().get('/hello', (c) => c.json({ text: 'Hello' }))) // removed when the plugin is unloaded
|
|
18
18
|
}
|
|
19
19
|
export async function check(ctx: Context) { // optional: must pass, or the install is rolled back
|
|
20
|
-
const reply = await ctx.
|
|
20
|
+
const reply = await ctx.router.request('/hello')
|
|
21
21
|
if (!reply.ok) throw new Error('GET /hello failed')
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
The router: build an App (import { App } from 'acryl-router'), register routes on it with app.get/post/put/patch/delete(path, handler) and mount it with
|
|
25
25
|
ctx.router.route(prefix, app). A path parameter is :id and is read with c.req.param('id'); c.json(body, status) answers JSON, c.text(...) answers text,
|
|
26
26
|
await c.req.json() reads a JSON body, throw new HTTPException(404, { message }) (also from 'acryl-router') answers an error. Routes added to an app
|
|
27
|
-
after route() are not served: build the whole app first. ctx.
|
|
27
|
+
after route() are not served: build the whole app first. ctx.router.request('/path', { method: 'POST', body: ... }) calls a route in process (use it in
|
|
28
28
|
check). The mount goes away when the plugin is unloaded.
|
|
29
29
|
|
|
30
30
|
For a capability that is a pure function of data (parse, convert, compute), write a sandboxed TOOL instead of a plugin. It cannot touch the
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
A Cordis app on the ACRYL framework: TypeScript that Node 22.19 or newer runs directly (no build step; erasable syntax only, so no enums and no
|
|
4
|
+
parameter properties). Every part of it is a Cordis plugin.
|
|
5
|
+
|
|
6
|
+
## The framework's API is in `docs/framework/`
|
|
7
|
+
|
|
8
|
+
Start at `docs/framework/README.md`; `api-summary.md` is the one-page summary. Read before you write routes: `routing.md` (the `App`), `request.md` (`c.req`), `context.md` (`c`),
|
|
9
|
+
`validation.md`, `helpers.md` (`streamSSE`), `middleware.md`, `plugin-lifecycle.md` (what a plugin owns and when it is unloaded) and
|
|
10
|
+
`node-server.md`. The framework's source is vendored in `src/plugins/acryl-*/src`: read it, do not edit it (a change belongs in ACRYL, upstream).
|
|
11
|
+
The docs write `from 'acryl-router'`; in this app that is `./plugins/acryl-router/src/index.ts` (relative to `src/`), `acryl-router/middleware`
|
|
12
|
+
is `./plugins/acryl-router/src/middleware/index.ts` and `acryl-router/helpers` is `./plugins/acryl-router/src/helpers/index.ts`.
|
|
13
|
+
A plugin written into `extensions/` while the app runs can import `acryl-router`, `acryl-router/middleware` and `acryl-router/helpers` by those names.
|
|
14
|
+
|
|
15
|
+
## How a feature is built
|
|
16
|
+
|
|
17
|
+
Routes are an ordinary `App` that a plugin mounts once with `ctx.router.route(prefix, app)`. The mount belongs to the plugin and goes away when it unloads.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
21
|
+
import { App, HTTPException } from './plugins/acryl-router/src/index.ts'
|
|
22
|
+
import { schemaValidator, type StandardSchema } from './plugins/acryl-router/src/helpers/index.ts'
|
|
23
|
+
|
|
24
|
+
export const name = 'notes' // a plugin's name; blend.yaml refers to it
|
|
25
|
+
export const inject = ['router'] // services it needs
|
|
26
|
+
export function apply(ctx: Context) {
|
|
27
|
+
const app = new App()
|
|
28
|
+
.get('/', (c) => c.json({ ok: true }))
|
|
29
|
+
.get('/:id', (c) => c.json({ id: c.req.param('id') }))
|
|
30
|
+
.post('/', schemaValidator('json', NewNote), (c) => c.json(c.req.valid('json'), 201)) // NewNote is a Standard Schema
|
|
31
|
+
ctx.router.route('/notes', app) // build the whole app first: routes added after route() are not served
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- A small route goes on the `App` in `src/routes.ts`. A new feature is a new plugin file: add it to `registry` in `src/app.ts`, add a row
|
|
36
|
+
(`- id: notes` / `name: notes`) to `blend.yaml` before the `acryl-node-server` row, and pass runtime values (a data file) as its config in `createApp`.
|
|
37
|
+
- State, when `src/plugins/acryl-state` exists: `const cell = ctx.state.cell<T>('notes.list', initial)` (add `'state'` to `inject`), then
|
|
38
|
+
`cell.get()`, `cell.set(value)`, `cell.update(fn)` and `cell.subscribe(fn)` (returns the unsubscribe). Every cell is also served as JSON at `/api/state/<key>`
|
|
39
|
+
and streamed at `/api/state/<key>/events`.
|
|
40
|
+
- Test a route without a socket: `await ctx.router.request('/notes')`, or boot the app as `test/app.test.ts` does and use `fetch`.
|
|
41
|
+
|
|
42
|
+
## Things that surprise people
|
|
43
|
+
|
|
44
|
+
- A page mounted at `/x` answers `/x`, not `/x/` (trailing slashes are strict); `trimTrailingSlash()` (middleware) redirects `/x/` to `/x` for GET and HEAD.
|
|
45
|
+
- `schemaValidator('json', ...)` needs a JSON `Content-Type`; without it the schema sees `{}`. A failure answers 400 with
|
|
46
|
+
`{ "error": "invalid json", "issues": [{ "message", "path" }] }`: the human text is `issues[0].message`. A `jsonOnly` recipe in `docs/framework/validation.md` answers 415 instead.
|
|
47
|
+
- Throw `new HTTPException(404, { message })` for an error response; the body is the message as plain text (use `c.json({ error }, status)` when a page expects JSON).
|
|
48
|
+
- Handlers get the request as `c.req`: `c.req.param('id')`, `c.req.query('q')`, `await c.req.json()`, `c.req.raw` for the Web `Request`.
|
|
49
|
+
- A server-sent stream is `streamSSE(c, async (stream) => { await stream.writeSSE({ data: JSON.stringify(x) }) ... })`; call `stream.onAbort(cleanup)`.
|
|
50
|
+
- `npm run dev` restarts the app whenever a file changes.
|
|
51
|
+
|
|
52
|
+
## Before you say you are done
|
|
53
|
+
|
|
54
|
+
`npm test` and `npm run check` (type-check) both pass. Put tests in `test/`, written with `node:test` like `test/app.test.ts`.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
|
|
2
|
+
## This app (agent)
|
|
3
|
+
|
|
4
|
+
Besides `src/routes.ts`, the app runs an agent (`POST /ask`) that can write plugins into `extensions/` while the app runs and install them with
|
|
5
|
+
rollback; `GET /extensions` and `GET /tasks` show what it did. A plugin there is a folder with an `index.ts` exporting `name`, `inject`, `apply(ctx)`
|
|
6
|
+
and an optional `check(ctx)` that receives the plugin's own context (so `ctx.router` reads as in `apply`). It may import `acryl-router`.
|
|
@@ -5,7 +5,7 @@ Cordis plugin.
|
|
|
5
5
|
|
|
6
6
|
```sh
|
|
7
7
|
npm install
|
|
8
|
-
npm run dev # prints its URL; data lives in .acrylblends
|
|
8
|
+
npm run dev # prints its URL; data lives in .acrylblends/; it restarts when a file changes (a new port if ACRYLBLENDS_PORT=0)
|
|
9
9
|
npm test
|
|
10
10
|
npm run check # type-check
|
|
11
11
|
|
|
@@ -41,7 +41,7 @@ curl <url>/tasks # every run as a durable task, with the tool calls
|
|
|
41
41
|
|
|
42
42
|
Other commands: `update route <name> <path> <text>`, `break plugin <name>` (shows the rollback), `remove plugin <name>`,
|
|
43
43
|
`list plugins`. What the agent writes lands in `extensions/`, is part of your app, and starts again at the next start.
|
|
44
|
-
- `src/routes.ts` is your own plugin:
|
|
44
|
+
- `src/routes.ts` is your own plugin: it builds an `App` (`import { App } from './plugins/acryl-router/src/index.ts'`) and mounts it with `ctx.router.route(prefix, app)`. Routes added to the app after it is mounted are not served. A page mounted at `/x` answers `/x`, not `/x/` (trailing slashes are strict). The router API is documented at https://acrylblends.github.io/docs/framework/the-acryl-framework and in `docs/framework` of the acrylblends repository.
|
|
45
45
|
- `src/plugins/` holds the plugins copied from the ACRYLBLENDS library. They are yours now; edit them freely.
|
|
46
46
|
Add more with `acrylblends add <plugin>`.
|
|
47
47
|
|