acrylblends 1.0.0 → 1.0.2

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.
@@ -0,0 +1,110 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto'
2
+ import { connectionOf, createMiddleware } from '../plugins/acryl-router/src/helpers/index.ts'
3
+ import type { Context } from '../plugins/acryl-router/src/index.ts'
4
+ import { basicAuth } from '../plugins/acryl-router/src/middleware/index.ts'
5
+
6
+ /** Who may use the chat, and what the app prints about it at start. */
7
+ export type Access =
8
+ /** Development, and every test: no gate. */
9
+ | { kind: 'open' }
10
+ /** Production with `ACRYL_PRODUCTION_KEEP=chat`: anyone, with a per-IP rate limit. */
11
+ | { kind: 'public'; perMinute: number; trustProxy: boolean }
12
+ /** Production with `CHAT_ACCESS_TOKEN`: the browser asks for it (any user name, the token as the password). */
13
+ | { kind: 'token'; token: string }
14
+ /** Production with neither: every route but `/health` answers 503. */
15
+ | { kind: 'closed' }
16
+
17
+ export interface Decision {
18
+ readonly access: Access
19
+ /** Lines to print at start (a `WARNING` for a public chat, the reason for a closed one). */
20
+ readonly notices: readonly string[]
21
+ }
22
+
23
+ /**
24
+ * Anyone who can reach the chat spends the model key, so in production it is closed by default. Name the chat in `ACRYL_PRODUCTION_KEEP` to make it public
25
+ * (loudly, with a rate limit), or set `CHAT_ACCESS_TOKEN` to ask for a shared token.
26
+ */
27
+ export function chatAccess(env: Readonly<Record<string, string | undefined>>): Decision {
28
+ if (env.NODE_ENV !== 'production') return { access: { kind: 'open' }, notices: [] }
29
+ const keep = (env.ACRYL_PRODUCTION_KEEP ?? '')
30
+ .split(',')
31
+ .map((name) => name.trim())
32
+ .filter(Boolean)
33
+ if (keep.includes('chat')) {
34
+ const perMinute = Number(env.CHAT_RATE_PER_MINUTE)
35
+ return {
36
+ access: {
37
+ kind: 'public',
38
+ perMinute: Number.isInteger(perMinute) && perMinute > 0 ? perMinute : 20,
39
+ trustProxy: env.TRUST_PROXY === '1',
40
+ },
41
+ notices: [
42
+ 'WARNING: the chat is public in production because ACRYL_PRODUCTION_KEEP names "chat": anyone who can reach it spends your model key (each address is limited to a few messages a minute). Put your own login in front of it, or set CHAT_ACCESS_TOKEN instead.',
43
+ ],
44
+ }
45
+ }
46
+ const token = env.CHAT_ACCESS_TOKEN?.trim()
47
+ if (token) return { access: { kind: 'token', token }, notices: [] }
48
+ return {
49
+ access: { kind: 'closed' },
50
+ notices: [
51
+ 'The chat is closed: with NODE_ENV=production it needs CHAT_ACCESS_TOKEN (the browser asks for it) or ACRYL_PRODUCTION_KEEP=chat to be public. Every route but /health answers 503 until then.',
52
+ ],
53
+ }
54
+ }
55
+
56
+ const digest = (text: string) => createHash('sha256').update(text).digest()
57
+
58
+ /** The middleware for an access mode, mounted before the routes. `/health` is always open. */
59
+ export function accessGate(access: Access) {
60
+ if (access.kind === 'open') return undefined
61
+ if (access.kind === 'closed')
62
+ return createMiddleware(async (c, next) => {
63
+ if (c.req.path === '/health') return next()
64
+ return c.json(
65
+ {
66
+ error:
67
+ 'The chat is closed. Set CHAT_ACCESS_TOKEN, or ACRYL_PRODUCTION_KEEP=chat to make it public.',
68
+ },
69
+ 503,
70
+ )
71
+ })
72
+ if (access.kind === 'token') {
73
+ const expected = digest(access.token)
74
+ const ask = basicAuth({
75
+ realm: 'chat',
76
+ verifyUser: (_user, password) => timingSafeEqual(digest(password), expected),
77
+ })
78
+ return createMiddleware(async (c, next) => (c.req.path === '/health' ? next() : ask(c, next)))
79
+ }
80
+ return rateLimit(access.perMinute, access.trustProxy)
81
+ }
82
+
83
+ /** At most `perMinute` messages a minute from one address (`POST /api/chat`); the table forgets an address a minute after its last message. */
84
+ function rateLimit(perMinute: number, trustProxy: boolean) {
85
+ const seen = new Map<string, number[]>()
86
+ const addressOf = (c: Context) => {
87
+ if (trustProxy) {
88
+ const forwarded = c.req.header('x-forwarded-for')?.split(',').at(-1)?.trim()
89
+ if (forwarded) return forwarded
90
+ }
91
+ return connectionOf(c).remoteAddress ?? 'unknown'
92
+ }
93
+ return createMiddleware(async (c, next) => {
94
+ if (c.req.method !== 'POST' || c.req.path !== '/api/chat') return next()
95
+ const now = Date.now()
96
+ for (const [address, times] of seen)
97
+ if (now - (times.at(-1) ?? 0) > 60_000) seen.delete(address)
98
+ const address = addressOf(c)
99
+ const recent = (seen.get(address) ?? []).filter((time) => now - time < 60_000)
100
+ if (recent.length >= perMinute) {
101
+ seen.set(address, recent)
102
+ const wait = Math.ceil((60_000 - (now - (recent[0] ?? now))) / 1000)
103
+ c.header('retry-after', String(wait))
104
+ return c.json({ error: 'Too many messages. Try again in a minute.' }, 429)
105
+ }
106
+ recent.push(now)
107
+ seen.set(address, recent)
108
+ return next()
109
+ })
110
+ }
@@ -5,6 +5,7 @@ import {
5
5
  streamSSE,
6
6
  } from '../plugins/acryl-router/src/helpers/index.ts'
7
7
  import { Acryl } from '../plugins/acryl-router/src/index.ts'
8
+ import { type Access, accessGate } from './access.ts'
8
9
  import { chatStore } from './db.ts'
9
10
  import { type Llm, openAiCompatible } from './llm.ts'
10
11
  import { chatPage } from './page.tsx'
@@ -32,9 +33,14 @@ const ChatSchema: StandardSchema<ChatInput> = {
32
33
  },
33
34
  }
34
35
 
35
- export function createApp(db: Db, llm: Llm = openAiCompatible) {
36
+ /** `access` is who may use the chat (default: anyone, as in development and in the tests); see `src/access.ts`. */
37
+ export function createApp(db: Db, llm: Llm = openAiCompatible, access: Access = { kind: 'open' }) {
36
38
  const store = chatStore(db)
37
39
  const app = new Acryl()
40
+ const gate = accessGate(access)
41
+ if (gate) app.use(gate)
42
+
43
+ app.get('/health', (c) => c.json({ ok: true }))
38
44
 
39
45
  app.get('/', async (c) => {
40
46
  const id = Number(c.req.query('c'))
@@ -1,5 +1,6 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
  import type {} from '../plugins/acryl-db/src/cordis.ts'
3
+ import { chatAccess } from './access.ts'
3
4
  import { createApp } from './chat-app.ts'
4
5
  import { openAiCompatible } from './llm.ts'
5
6
  import { createClient } from './runtime.ts'
@@ -9,7 +10,10 @@ export const routes = {
9
10
  name: 'routes',
10
11
  inject: ['router', 'db'],
11
12
  apply(ctx: Context) {
12
- ctx.router.route('/', createApp(ctx.db, openAiCompatible))
13
+ // In production the chat is closed unless it has a token or is made public on purpose: anyone who reaches it spends the model key.
14
+ const { access, notices } = chatAccess(process.env)
15
+ for (const line of notices) console.warn(line)
16
+ ctx.router.route('/', createApp(ctx.db, openAiCompatible, access))
13
17
  ctx.router.route('/client', createClient())
14
18
  },
15
19
  }
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'
7
7
  import type {} from '../plugins/acryl-db/src/cordis.ts'
8
8
  import type { Db } from '../plugins/acryl-db/src/index.ts'
9
9
  import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
10
+ import { chatAccess } from '../src/access.ts'
10
11
  import { createApp as boot } from '../src/app.ts'
11
12
  import { createApp } from '../src/chat-app.ts'
12
13
  import type { Llm } from '../src/llm.ts'
@@ -196,3 +197,67 @@ test('the chat page loads its client component, served by the running app', asyn
196
197
  assert.match(module.headers.get('content-type') ?? '', /javascript/)
197
198
  assert.equal((await running.root.router.request('/client/@acrylblends/jsx-dom.js')).status, 200)
198
199
  })
200
+
201
+ const production = (env: Record<string, string> = {}) =>
202
+ chatAccess({ NODE_ENV: 'production', ...env })
203
+
204
+ test('in development the chat is open and prints nothing', () => {
205
+ const { access, notices } = chatAccess({})
206
+ assert.equal(access.kind, 'open')
207
+ assert.deepEqual(notices, [])
208
+ })
209
+
210
+ test('in production the chat is closed until it has a token or is made public on purpose', async () => {
211
+ const closed = production()
212
+ assert.equal(closed.access.kind, 'closed')
213
+ assert.match(closed.notices.join(), /closed/)
214
+ const { llm, asked } = mock(['x'])
215
+ const app = createApp(await memoryDb(), llm, closed.access)
216
+ for (const path of ['/', '/api/conversations'])
217
+ assert.equal((await app.request(path)).status, 503, path)
218
+ assert.equal((await app.request('/api/chat', post({ message: 'hi' }))).status, 503)
219
+ assert.equal((await app.request('/health')).status, 200)
220
+ assert.equal(asked.length, 0)
221
+ })
222
+
223
+ test('with CHAT_ACCESS_TOKEN the browser must send it, as the password of a basic login', async () => {
224
+ const gated = production({ CHAT_ACCESS_TOKEN: 'a-long-shared-token' })
225
+ assert.equal(gated.access.kind, 'token')
226
+ assert.deepEqual(gated.notices, [])
227
+ const { llm, asked } = mock(['ok'])
228
+ const app = createApp(await memoryDb(), llm, gated.access)
229
+ const login = (password: string) => ({
230
+ ...post({ message: 'hi' }),
231
+ headers: {
232
+ 'content-type': 'application/json',
233
+ authorization: `Basic ${Buffer.from(`me:${password}`).toString('base64')}`,
234
+ },
235
+ })
236
+ assert.equal((await app.request('/api/chat', post({ message: 'hi' }))).status, 401)
237
+ assert.equal((await app.request('/api/chat', login('wrong'))).status, 401)
238
+ assert.equal((await app.request('/health')).status, 200)
239
+ assert.equal(asked.length, 0)
240
+ const ok = await app.request('/api/chat', login('a-long-shared-token'))
241
+ assert.equal(ok.status, 200)
242
+ assert.deepEqual(
243
+ events(await ok.text()).map(([e]) => e),
244
+ ['meta', 'token', 'done'],
245
+ )
246
+ })
247
+
248
+ test('ACRYL_PRODUCTION_KEEP=chat makes the chat public with a loud WARNING and a per-address rate limit', async () => {
249
+ const open = production({ ACRYL_PRODUCTION_KEEP: 'agent, chat', CHAT_RATE_PER_MINUTE: '2' })
250
+ assert.equal(open.access.kind, 'public')
251
+ assert.match(open.notices.join(), /^WARNING: the chat is public in production/)
252
+ const { llm, asked } = mock(['ok'])
253
+ const app = createApp(await memoryDb(), llm, open.access)
254
+ const send = () => app.request('/api/chat', post({ message: 'hi' }))
255
+ assert.equal((await (await send()).text()).includes('event: done'), true)
256
+ assert.equal((await send()).status, 200)
257
+ const limited = await send()
258
+ assert.equal(limited.status, 429)
259
+ assert.ok(Number(limited.headers.get('retry-after')) > 0)
260
+ assert.equal(asked.length, 2)
261
+ // Other routes are not counted.
262
+ assert.equal((await app.request('/api/conversations')).status, 200)
263
+ })
@@ -62,7 +62,7 @@ test('the home page sends a visitor to the notes, which send them to log in', as
62
62
  test('sign up validates, refuses a duplicate, and starts a session', async () => {
63
63
  const app = createApp(await memoryDb())
64
64
  assert.match(await (await app.request('/signup')).text(), /Create account/)
65
- const bad = await app.request('/signup', form({ email: 'nope', password: 'short' }))
65
+ const bad = await app.request('/signup', form({ email: 'nope', password: 'no way' }))
66
66
  assert.equal(bad.status, 400)
67
67
  const text = await bad.text()
68
68
  assert.match(text, /valid email/)
@@ -9,6 +9,8 @@ const chosen = selectInstance({
9
9
  })
10
10
  const app = await createApp(chosen)
11
11
  console.log(`${APP_NAME} listening on ${app.url} (data in ${chosen.home})`)
12
+ // The list is served under /todo, not at the address above: say where to open it.
13
+ console.log(`open ${app.url}/todo`)
12
14
 
13
15
  const stop = async () => {
14
16
  await app.stop()