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.
Files changed (45) hide show
  1. package/dist/index.js +56 -2
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/LICENSE +23 -0
  4. package/docs/framework/README.md +63 -0
  5. package/docs/framework/UPSTREAM.md +3 -0
  6. package/docs/framework/api-summary.md +138 -0
  7. package/docs/framework/concepts.md +105 -0
  8. package/docs/framework/context.md +129 -0
  9. package/docs/framework/getting-started.md +146 -0
  10. package/docs/framework/helpers.md +118 -0
  11. package/docs/framework/middleware.md +154 -0
  12. package/docs/framework/migration-from-0-3.md +108 -0
  13. package/docs/framework/migration-from-dsh-webserver.md +103 -0
  14. package/docs/framework/node-server.md +75 -0
  15. package/docs/framework/plugin-lifecycle.md +111 -0
  16. package/docs/framework/request.md +106 -0
  17. package/docs/framework/routing.md +188 -0
  18. package/docs/framework/surfaces.md +71 -0
  19. package/docs/framework/upstream.json +9 -0
  20. package/docs/framework/validation.md +135 -0
  21. package/package.json +4 -2
  22. package/plugins/acryl-inspect/src/report.ts +15 -10
  23. package/plugins/acryl-inspect/tests/report.spec.ts +9 -3
  24. package/plugins/acryl-inspect/upstream.json +2 -2
  25. package/plugins/acryl-node-server/upstream.json +1 -1
  26. package/plugins/acryl-router/src/middleware/index.ts +1 -1
  27. package/plugins/acryl-router/src/middleware/loopback-only.ts +34 -1
  28. package/plugins/acryl-router/src/service.ts +9 -3
  29. package/plugins/acryl-router/tests/loopback-only.spec.ts +80 -0
  30. package/plugins/acryl-router/tests/service.spec.ts +17 -0
  31. package/plugins/acryl-router/upstream.json +2 -2
  32. package/plugins/acryl-router-http/upstream.json +1 -1
  33. package/plugins/acryl-state/upstream.json +1 -1
  34. package/plugins/builder/script.ts +1 -1
  35. package/plugins/extensions/index.ts +4 -3
  36. package/plugins/openai/prompt.ts +2 -2
  37. package/shared/AGENTS.core.md +54 -0
  38. package/templates/agent/AGENTS.md +6 -0
  39. package/templates/agent/README.md +2 -2
  40. package/templates/agent/blend.yaml +1 -1
  41. package/templates/blank/AGENTS.md +5 -0
  42. package/templates/blank/README.md +1 -1
  43. package/templates/todo/AGENTS.md +5 -0
  44. package/templates/todo/src/todo.ts +4 -2
  45. 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.0",
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
- "plugins"
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 SERVED = /^([A-Z]+) (\S+)$/
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
- mounts.push(mount[1])
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: labels.length,
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 (ALL /api/*, GET /api/things/:id)', children: [] },
16
- { label: 'acryl-router: route /api/things (POST /api/things)', children: [{ label: 'inner timer', children: [] }] },
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 / (GET /a)', children: [] }],
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": "df79b188398b6d4f8cceda35a72b88300a10a9ce"
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:e9887df0d2b5274d4fc5de4c730975528a84fd1d270994ee85a2789bec101db7",
13
+ "digest": "sha256:434ed3cf6b9028da90ad6b395209affbc917a62aa994f2b3c8f1d98a26398ea7",
14
14
  "testsNotRun": {},
15
15
  "notice": [
16
16
  "LICENSE"
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-node-server",
5
- "commit": "df79b188398b6d4f8cceda35a72b88300a10a9ce"
5
+ "commit": "64e7c9d2e787f74b2da82492644fbbb46a601074"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -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
- /** Only requests from this machine to this app: loopback peer, the real port, an allowed `Host`, and for mutations a matching `Origin`. Everything else gets a 403. */
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
- // The label lists what the mount serves, so the inspector can report routes without asking the router (`ALL` is middleware or `all`).
148
- const served = app.routes.map(route => `${route.method} ${mergePath(path, route.path)}`).join(', ')
149
- this.ctx.effect(() => remove, `acryl-router: route ${path} (${served})`)
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": "df79b188398b6d4f8cceda35a72b88300a10a9ce"
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:6e15f8ce0499b94c95649342f6fe984bc9b2aec0f5a142e8853a5badcc3b85f5",
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
  },
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-router-http",
5
- "commit": "df79b188398b6d4f8cceda35a72b88300a10a9ce"
5
+ "commit": "64e7c9d2e787f74b2da82492644fbbb46a601074"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-state",
5
- "commit": "df79b188398b6d4f8cceda35a72b88300a10a9ce"
5
+ "commit": "64e7c9d2e787f74b2da82492644fbbb46a601074"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -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.get('router')!.request(${JSON.stringify(path)})
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(this.ctx)), limit])
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)
@@ -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.get('router')!.request('/hello')
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.get('router')!.request('/path', { method: 'POST', body: ... }) calls a route in process (use it in
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: routes added inside `ctx.effect`.
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