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 11 commits into
Conversation
22fc913 to
21922ae
Compare
Greptile SummaryAdds a deployable Next.js AI-chatbot example backed by private Postgres.
Confidence Score: 4/5The PR does not appear safe to merge until unauthenticated model and database access are addressed and partial seed state is repaired. The public container still allows arbitrary callers to consume the ambient AI binding, and the separate tables route returns private Postgres rows without authorization. The seeding transaction prevents new mid-seed partial commits, but an existing database containing customers without the complete order seed is accepted as initialized and remains incorrect. Files Needing Attention: examples/ai-chatbot-ts/app/api/chat/route.ts, examples/ai-chatbot-ts/app/api/tables/route.ts, examples/ai-chatbot-ts/app/seed.ts
|
| Filename | Overview |
|---|---|
| examples/ai-chatbot-ts/app/seed.ts | Adds serialized transactional seeding, but its customer-only completeness check leaves previously partial seeds unrepaired. |
| examples/ai-chatbot-ts/app/api/tables/route.ts | Adds an unauthenticated route that returns preview rows and counts from both private database tables. |
| examples/ai-chatbot-ts/app/api/chat/route.ts | Adds fixed-query tool execution and streaming model responses, while retaining the intentionally open model-invocation path. |
| examples/ai-chatbot-ts/app/queries.ts | Restricts model-selected operations to fixed parameterized statements with bounded enum filters and limits. |
| examples/ai-chatbot-ts/Dockerfile | Builds the standalone Next.js image with required native assets and runs it as the unprivileged node user. |
| examples/ai-chatbot-ts/alien.ts | Defines the linked AI, Postgres, and publicly exposed container resources and workload permissions. |
Sequence Diagram
sequenceDiagram
participant Browser
participant Chat as Next.js API
participant AI as Alien AI binding
participant DB as Private Postgres
Browser->>Chat: POST /api/chat
Chat->>AI: Resolve model and stream completion
AI-->>Chat: queryDatabase tool call
Chat->>DB: Seed if needed
Chat->>DB: Execute fixed parameterized query
DB-->>Chat: Rows
Chat->>AI: Tool result
AI-->>Browser: Streamed summary
Browser->>Chat: GET /api/tables
Chat->>DB: Read table previews
DB-->>Browser: Rows and counts
Prompt To Fix All With AI
### Issue 1
examples/ai-chatbot-ts/app/seed.ts:76-77
**Partial seeds remain incomplete**
When an earlier seed attempt inserted customers but failed before inserting all orders, this customer-only count check treats the database as initialized and commits without repairing it, causing chatbot answers and table previews to continue reporting incomplete data after the fix is deployed.
### Issue 2
examples/ai-chatbot-ts/app/api/tables/route.ts:8
**Private rows exposed publicly**
When any Internet client requests `/api/tables`, this unauthenticated handler reads both private Postgres tables and returns customer financial data, order details, and row counts; protecting only `/api/chat` leaves this independent disclosure path open.
**How this was verified:** The public container routes `GET /api/tables` directly to database reads and a JSON row response without an intervening authorization check.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (11): Last reviewed commit: "style(examples): sort the chatbot route'..." | 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
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
|
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 |
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
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.
`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.
The trade-off was only stated in the README, which is not where someone reading the route or the stack definition will look for it.
9b3ad76 to
11615e0
Compare
|
@greptile-apps Both new threads answered in place. Summarising, since the re-review re-derived from scratch after the rebase onto
On the score. The one thing separating this from 5/5 across three reviews now is that the example deploys public endpoints on purpose. That is the example: clicking the URL Two rules worth remembering for
|
|
Both explanations hold up on re-examination — the transactional seed argument is correct, and the public endpoint trade-off being documented at the call site and in the README is the right resolution for the tables route. Both rules saved:
The tables route annotation + README extension you mentioned is a nice-to-have — happy to see that land, but it's not a blocker. This looks good to merge. |
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.