Skip to content

feat(examples): add an AI chatbot example over a private Postgres - #271

Open
ItamarZand88 wants to merge 8 commits into
mainfrom
itamar/alien-41-example-project-ai-chatbot
Open

feat(examples): add an AI chatbot example over a private Postgres#271
ItamarZand88 wants to merge 8 commits into
mainfrom
itamar/alien-41-example-project-ai-chatbot

Conversation

@ItamarZand88

@ItamarZand88 ItamarZand88 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

An example app showing the ai and postgres resources 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:

  1. The chat route resolves the AI binding at request time and streams a completion — a BYO key goes straight to the provider, an ambient cloud model routes through the gateway.
  2. The model calls the queryDatabase tool 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 example
  3. The rows come back to the model, which summarizes them, and the UI renders both the call and the row preview.

This PR adds examples/ai-chatbot-ts.

What I did

  • Declared a model-less alien.AI("llm") and a private alien.Postgres("db"), both linked to a single container, with the workload granted ai/invoke and postgres/data-access.
  • Wrote the app against the public SDK surface: getAiConnection for the model endpoint, postgres("db").connection() for the database, ai("llm").getAvailableModels() for the model picker.
  • Put the model on a fixed set of seven questions instead of free-form SQL. app/queries.ts owns each statement and binds the model's arguments as parameters; the tool's input schema is a closed enum of question names plus plan / status enums and a clamped limit.
  • Kept both alien packages out of the bundle (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

  • Ran the app end to end against a real container build, with a throwaway Postgres and an ambient Bedrock binding. All four of the UI's suggested questions return answers that match the seeded rows, on claude-sonnet-4.6 (Anthropic wire format) and on gpt-oss-120b (OpenAI wire format).
  • Started from an empty database: the tables are created and filled on the first question. Then dropped them again without restarting the container, to exercise the re-seed path that catches Postgres' undefined_table.
  • Asked something the data can't answer ("average order value per country by month") and got a clear explanation of what the questions do and don't cover, rather than a wrong number.
  • Checked that a filter the chosen question ignores can't be read back as applied — the tool returns an error naming the filters that question does take, and the model retries with one that fits.
  • Confirmed the image ships what it needs: both native prebuilds land in .next/standalone, and the app runs from node server.js with no node_modules beside it.

On the tool surface specifically:

  • The model cannot change the session it runs on — 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.
  • The model cannot reach a table outside the demo schema — the statement it runs comes from an exhaustive switch over a closed enum of question names, so a system catalog like pg_authid is not expressible.
  • The model cannot pin a connection or blow up memory — limit is an integer clamped to 50 and bound as a parameter, and the pool carries a statement timeout.
  • The seed connection carries its own statement timeout, so a container that dies holding the seed advisory lock can't park every other container's seed on it.
  • Nothing turned up.

@ItamarZand88
ItamarZand88 force-pushed the itamar/alien-41-example-project-ai-chatbot branch 6 times, most recently from 22fc913 to 21922ae Compare August 1, 2026 21:18
@ItamarZand88
ItamarZand88 marked this pull request as ready for review August 2, 2026 07:20
@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds a deployable Next.js chatbot example that uses an Alien AI binding and a private Postgres resource.

  • Registers the example with the CLI and examples workspace.
  • Adds fixed, parameterized database questions, transactional demo-data seeding, model discovery, and streaming tool calls.
  • Adds the chat interface, table preview, standalone container build, and deployment documentation.

Confidence Score: 4/5

The 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

Important Files Changed

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
Loading

Reviews (9): Last reviewed commit: "docs(examples): list the AI templates in..." | Re-trigger Greptile

Comment thread examples/ai-chatbot-ts/app/api/chat/route.ts
Comment thread examples/ai-chatbot-ts/app/seed.ts Outdated
Comment thread examples/ai-chatbot-ts/Dockerfile
Comment thread examples/ai-chatbot-ts/Dockerfile
ItamarZand88 added a commit that referenced this pull request Aug 2, 2026
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
ItamarZand88 and others added 5 commits August 2, 2026 11:00
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
@ItamarZand88
ItamarZand88 force-pushed the itamar/alien-41-example-project-ai-chatbot branch from 9479220 to f4a56da Compare August 2, 2026 08:03
@ItamarZand88

Copy link
Copy Markdown
Contributor Author

@greptile-apps

Comment thread examples/ai-chatbot-ts/app/db.ts
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.
@ItamarZand88

Copy link
Copy Markdown
Contributor Author

@greptile-apps

Thanks, this is useful. Answering the confidence score and then the individual threads.

On the unauthenticated /api/chat route. This PR doesn't deploy anything. It adds examples/ai-chatbot-ts and registers it as a template in crates/alien-cli/src/commands/init.rs, so what merges is starter code someone runs alien init on. There's no deployment behind it and no quota to drain at merge time.

The open endpoint is deliberate. Being able to click the URL that alien deploy prints and immediately ask a question is the point of the example. Putting auth in front of it would mean shipping a login flow in 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 here, since it resets on redeploy and doesn't hold across instances, and it's the first code a real app would delete.

So the gap is named in the README instead, at README.md:42:

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.

Leaving this as is.

Three threads are already fixed at 4c07f490.

  • Non-atomic database seeding (app/seed.ts): that comment was written against 21922ae3. Seeding now takes pg_advisory_lock and runs inside an explicit transaction, app/seed.ts:72-83, so a partial insert rolls back and concurrent containers can't both seed.
  • Root runtime container (Dockerfile): USER node is at Dockerfile:20, and all three COPY --from=build lines carry --chown=node:node.
  • Mutable read-only session guard (app/db.ts): that one no longer applies, because the model can't write SQL any more. The tool takes a question name from a closed enum plus a couple of enum filters (app/queries.ts), the statements are written in the app, and the model's arguments are bound as parameters. Nothing it sends reaches Postgres as SQL, so there's no session for it to mutate.

Lockfile in the Dockerfile. Intentional, and consistent with the other examples in this directory, which also build with npm install and no committed per-example lockfile. Worth changing across all of them at once rather than only here.

@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown

The fixes are all confirmed. A quick summary of what I verified:

Seeding (app/seed.ts:72-83): Advisory lock + explicit transaction is in place. The statement_timeout=30000 on the seed client directly addresses the dead-lock concern. The pg_advisory_lock is released automatically on client.end() regardless of outcome, which is correct.

Dockerfile: USER node at line 20, all three COPY --from=build lines carry --chown=node:node. Clean.

app/db.ts: The pool carries default_transaction_read_only=on and statement_timeout=10000. More importantly, your point stands — since the tool schema is a closed enum with no free-text SQL field, the session guard is defense-in-depth rather than the primary boundary. The primary boundary is that the model's output never becomes SQL.

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.

@ItamarZand88

Copy link
Copy Markdown
Contributor Author

@greptile-apps

@ItamarZand88

Copy link
Copy Markdown
Contributor Author

@greptile-apps Two things: one correction, and one explicit acceptance.

The db.ts read-only guard is a false positive at current HEAD

First, a correction on the summary: it says "the prior reply states that each statement now uses BEGIN READ ONLY." It doesn't. My reply said the opposite, that the guard no longer applies. You're right that there is no transaction wrapper, and that's deliberate. It was removed in 4c07f49 when the free-form SQL tool was replaced.

The default_transaction_read_only session setting is only defeatable if an attacker can get a string of their choosing into a SQL statement. After the refactor there is no such string anywhere in the app. Every path that reaches Postgres:

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.

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

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 question constrained to seven named literals, plan and status to their own enums, and limit clamped before it ever reaches the pool, set_config and friends have no surface to land on. default_transaction_read_only becomes a label with no referent, and carrying BEGIN READ ONLY would imply a threat the design has already structurally removed. The reasoning is sound, and adding it back would be noise.

On the open /api/chat endpoint — understood, accepted, and the README callout at line 42 is exactly the right place for it. A demo that requires a login flow to reach the point of the demo isn't a demo.

`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant