feat(examples): add an AI chatbot example over a private Postgres - #271
feat(examples): add an AI chatbot example over a private Postgres#271ItamarZand88 wants to merge 8 commits into
Conversation
22fc913 to
21922ae
Compare
Greptile SummaryThe PR adds a deployable Next.js chatbot example that uses an Alien AI binding and a private Postgres resource.
Confidence Score: 4/5The PR is not safe to merge without accepting the documented exposure of billable model quota through the unauthenticated, unthrottled public chat route. The previously reported model-spend issue remains: the public endpoint still reaches model invocation without caller authentication or throttling. The author explicitly stated this is intentional for the clickable demo and documented the exposure, but that does not remove the concrete quota-consumption path. Files Needing Attention: examples/ai-chatbot-ts/app/api/chat/route.ts, examples/ai-chatbot-ts/alien.ts
|
| Filename | Overview |
|---|---|
| examples/ai-chatbot-ts/app/api/chat/route.ts | Resolves the selected AI model, exposes a closed database-query tool, and streams the resulting response. |
| examples/ai-chatbot-ts/app/queries.ts | Maps a closed question enum and bounded filters to fixed parameterized SQL statements. |
| examples/ai-chatbot-ts/app/seed.ts | Creates and seeds the demo schema inside a serialized, rollback-protected transaction. |
| examples/ai-chatbot-ts/app/db.ts | Creates the shared read-only query pool and retries after rebuilding dropped demo tables. |
| examples/ai-chatbot-ts/Dockerfile | Builds the standalone Next.js output and runs it as the unprivileged Node user. |
| examples/ai-chatbot-ts/alien.ts | Defines and links the public application container, AI resource, and private Postgres resource. |
Sequence Diagram
sequenceDiagram
participant U as Browser
participant C as Chat API
participant A as AI Binding
participant D as Private Postgres
U->>C: POST messages and selected model
C->>A: Resolve model connection
C->>A: Stream completion with queryDatabase tool
A-->>C: Fixed question and bounded filters
C->>D: Execute parameterized statement
D-->>C: Return rows
C->>A: Supply tool result
A-->>C: Stream summarized answer
C-->>U: UI message stream
Reviews (9): Last reviewed commit: "docs(examples): list the AI templates in..." | Re-trigger Greptile
The runtime stage ran as root, so a compromised server held root inside the container; it now drops to the base image's node account. Seeding wrote its two inserts as separate autocommits, so a failure between them left customers with no orders, which the count check then read as already seeded. One transaction makes it all-or-nothing, and an advisory lock keeps concurrent replicas from racing on create-if-not-exists. Reads go through a shared helper that reseeds once on undefined_table, so a database emptied behind a running container recovers on the next request instead of failing until restart. Refs greptile review on #271
A streaming chatbot container that answers questions about a private Postgres through a SQL tool run on read-only sessions. No API keys and no database credentials in the app: the AI binding routes through the gateway's ambient cloud identity, and the Postgres password resolves at runtime from the cloud secret store via postgres("db").connection().
Both alien packages stay serverExternalPackages — their native halves (napi addon, gateway binary) resolve with dynamic requires the bundler cannot see — and the per-platform prebuild packages are traced into the standalone output explicitly for the same reason. The image base is glibc because the bindings addon ships no musl prebuild.
The gateway forwards each model to its own upstream wire format rather than translating, so an OpenAI-compatible client reaches the OpenAI-protocol models only, and picking Claude in the model picker failed. Select the client from the model id, so every model the binding lists is usable through the one connection.
… route The container is publicly reachable, so an unauthenticated route that dropped and recreated the demo tables let anyone who found the URL reset them. Seed on the first question instead, creating and filling only what is missing, so there is no write endpoint to reach and a real table is never dropped. The model writes the SQL the tool runs, and read-only sessions stop writes but not a pg_sleep or a runaway scan holding a pool connection. Add a statement timeout and move the row cap into SQL, where a client-side slice still buffered every row the database returned. Register the template in the init fallback list so alien init ai-chatbot-ts still resolves when GitHub discovery is unavailable.
An answer is more convincing next to the rows it came from, so a drawer over the chat reads the demo tables through the same read-only pool the model's tool uses. Lifting that pool into app/db.ts keeps both readers on one connection with the same bounds. A native modal dialog carries the drawer: the top layer puts it above the background's full-viewport layers, and Escape, the backdrop, and focus containment come with it.
The runtime stage ran as root, so a compromised server held root inside the container; it now drops to the base image's node account. Seeding wrote its two inserts as separate autocommits, so a failure between them left customers with no orders, which the count check then read as already seeded. One transaction makes it all-or-nothing, and an advisory lock keeps concurrent replicas from racing on create-if-not-exists. Reads go through a shared helper that reseeds once on undefined_table, so a database emptied behind a running container recovers on the next request instead of failing until restart. Refs greptile review on #271
9479220 to
f4a56da
Compare
The session default was reversible from inside the statement it was meant to bound: `select set_config('default_transaction_read_only','off',false)` passes the single-SELECT check, and a later `WITH ... INSERT ... RETURNING` on the same pooled connection then writes. Reproduced against a real database, row written; a read-only transaction cannot be reopened for writing, and the same sequence now fails with `cannot execute ... in a read-only transaction`.
Refs greptile review on #271
The tool now takes a question name and a couple of enum filters, and app/queries.ts owns the statement each one runs with the model's arguments bound as parameters. Nothing the model sends reaches the database as SQL, so the session settings, the system catalogs, and the tables outside the demo schema are all out of reach by construction rather than by validation. The seed connection also gets a statement timeout, so a container that dies holding the advisory lock can no longer park every other seed.
|
Thanks, this is useful. Answering the confidence score and then the individual threads. On the unauthenticated The open endpoint is deliberate. Being able to click the URL that So the gap is named in the README instead, at
Leaving this as is. Three threads are already fixed at
Lockfile in the Dockerfile. Intentional, and consistent with the other examples in this directory, which also build with |
|
The fixes are all confirmed. A quick summary of what I verified: Seeding ( Dockerfile:
Unauthenticated route and lockfile: Both acknowledged — the README call-out at line 42 is the right place for the auth caveat in a starter template, and the lockfile consistency argument across examples is reasonable. All four threads are resolved from my side. Nothing left to block merge. |
|
@greptile-apps Two things: one correction, and one explicit acceptance. The
|
| Entry point | SQL text comes from | Untrusted input |
|---|---|---|
app/api/chat/route.ts:44 |
plan() in app/queries.ts |
Bound as $1/$2 parameters, never concatenated |
app/api/tables/route.ts:14-15 |
Template literal interpolating name |
name comes from TABLES at route.ts:4, a module constant ["customers", "orders"] as const |
app/db.ts:9-10 |
Its text argument, passed to pool.query(text, values) |
Values are always bound, never inlined |
app/queries.ts contains seven statements. All seven are literals, all seven pass a values: [...] array, and the file contains zero ${} interpolations. The tool's input schema (queries.ts:15-20) has no free-text field at all: question is a closed enum of seven names, plan and status are closed enums, limit is an integer clamped to 1..50. Anything outside that schema is rejected by Zod before plan() is called, and unknown keys are stripped.
So set_config('default_transaction_read_only','off') has no delivery mechanism. There is no statement an attacker can influence in which to place it. The session setting is now redundant defence in depth rather than the control, which is why the transaction wrapper went away with the SQL tool that made it necessary.
Adding BEGIN READ ONLY back would guard a path that no longer exists, in an example whose value depends on being small enough to read in one sitting. I'd rather not carry code that implies a threat the design has already removed.
The unauthenticated /api/chat route: explicitly accepted
Taking your framing directly, this one is explicitly accepted, not resolved.
The reasoning: this PR ships an alien init template plus its registration in crates/alien-cli/src/commands/init.rs. Merging it deploys nothing and exposes no endpoint. The risk only exists once somebody runs alien deploy on their own cloud account, having chosen to make it public, and it is their quota rather than ours.
The open endpoint is the point of the example. Being able to click the URL that alien deploy prints and immediately ask a question is what makes it a demo rather than a tutorial. Putting auth in front of it means shipping a login flow inside a starter whose job is to show one thing: an AI resource and a private Postgres wired to a container. A per-process rate limiter would be worse than nothing, since it resets on every redeploy and doesn't hold across instances, and it is the first code a real application would delete.
The gap is documented at examples/ai-chatbot-ts/README.md:42, which names both the risk and the fix:
That URL is open, so anyone who has it can ask questions and spend model quota. It is what makes the example something you can click and try, but a real deployment should put authentication and a per-caller rate limit in front of
/api/chat.
Accepted deliberately, documented for whoever deploys it.
|
Correction acknowledged — I misread my own prior summary, and the actual position was the opposite. Thank you for walking through it precisely. The closed-enum argument holds: with On the open |
`alien init` offers ai-quickstart-ts and ai-chatbot-ts, but neither appeared in the table, so the README undersold what the CLI can scaffold.
Summary
An example app showing the
aiandpostgresresources working together: a streaming chatbot that answers questions about a private Postgres through a tool. No API keys and no database credentials live in the app.What happens when you ask it a data question:
queryDatabasetool with a question name and a couple of filters, and the app runs the statement that question owns against the private Postgres. ← the point of the exampleThis PR adds
examples/ai-chatbot-ts.What I did
alien.AI("llm")and a privatealien.Postgres("db"), both linked to a single container, with the workload grantedai/invokeandpostgres/data-access.getAiConnectionfor the model endpoint,postgres("db").connection()for the database,ai("llm").getAvailableModels()for the model picker.app/queries.tsowns each statement and binds the model's arguments as parameters; the tool's input schema is a closed enum of question names plusplan/statusenums and a clampedlimit.serverExternalPackages) and traced their per-platform prebuilds into the standalone output — both locate their native half through requires the bundler can't follow.Files touched
examples/ai-chatbot-ts/**— the example: stack definition, three API routes, chat UI, Dockerfile.examples/pnpm-workspace.yaml+examples/pnpm-lock.yaml— register the example.How I tested
claude-sonnet-4.6(Anthropic wire format) and ongpt-oss-120b(OpenAI wire format).undefined_table..next/standalone, and the app runs fromnode server.jswith nonode_modulesbeside it.On the tool surface specifically:
set_config('statement_timeout','0')and friends have no path in, because the tool's schema has no free-text field for SQL and Zod drops any key that isn't in it.pg_authidis not expressible.limitis an integer clamped to 50 and bound as a parameter, and the pool carries a statement timeout.