Skip to content

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

Open
ItamarZand88 wants to merge 11 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 11 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

Adds a deployable Next.js AI-chatbot example backed by private Postgres.

  • Defines linked AI, Postgres, and public container resources across AWS, GCP, and Azure.
  • Adds fixed, parameterized database questions, transactional demo seeding, model discovery, streaming chat, and data-preview routes.
  • Adds the chatbot UI, standalone container build, documentation, template registration, and workspace dependencies.

Confidence Score: 4/5

The 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

Security Review

The public application still permits unauthenticated ambient-model invocation, and its independent /api/tables route exposes private-table previews without access control. How this was verified: The public container routes requests directly to handlers that invoke streamText or return database rows without an intervening authorization check.

Important Files Changed

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
Loading

Fix All in Codex

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

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 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
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
ItamarZand88 added a commit that referenced this pull request Aug 2, 2026
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
@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.

ItamarZand88 and others added 10 commits August 12, 2026 10:51
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.
Biome 2 parses CSS, and @plugin / @theme are Tailwind extensions it
rejects unless told to expect them.
@ItamarZand88
ItamarZand88 force-pushed the itamar/alien-41-example-project-ai-chatbot branch from 9b3ad76 to 11615e0 Compare August 12, 2026 07:56
Comment thread examples/ai-chatbot-ts/app/seed.ts
Comment thread examples/ai-chatbot-ts/app/api/tables/route.ts
@ItamarZand88

Copy link
Copy Markdown
Contributor Author

@greptile-apps Both new threads answered in place. Summarising, since the re-review re-derived from scratch after the rebase onto main.

seed.ts:77 — partial seeds. The precondition is unreachable. begin is at app/seed.ts:73, commit at 81, rollback at 83, and both inserts sit between them — if ORDERS throws, CUSTOMERS rolls back with it. Your summary already grants this ("the seeding transaction prevents new mid-seed partial commits"), so the finding rests on a pre-existing partial database. ensureSeeded() is the only writer of these tables, so that state could only come from the non-atomic version replaced in f4a56da9 — code that has never been in a release. This PR is what adds the example; there is no prior deployment to inherit from.

api/tables/route.ts:8 — private rows. The dataset is app/seed.ts:16-35: eight fixture customers and ten orders, checked into this diff. The stack provisions its own Postgres at alien.ts:9 and ensureSeeded() is the only writer, so the tables never hold anything else. The route previews eight rows and a count of literals a reader can already see. Your point that it is a second public surface is fair, and the annotation and README caveat currently name only /api/chat — I'll extend both to cover this route.

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 alien deploy prints and immediately asking a question is what makes it a demo rather than a tutorial. Accepted deliberately, documented at README.md:42, and now stated in the code at app/api/chat/route.ts:49 and alien.ts:16.

Two rules worth remembering for examples/, including the one you offered earlier and I never answered:

  1. Don't flag missing lockfiles in examples/ — none of the examples commit one, by convention.
  2. examples/** are alien init starter templates, not deployed services. A deliberately public demo endpoint whose trade-off is documented in the example's README and stated at the call site is an accepted trade-off, not a blocking finding.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown

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:

  1. No lockfiles in examples/ — won't flag missing lockfiles in examples/ going forward.
  2. examples/ public endpoint trade-offs — documented public endpoints in starter templates are an accepted trade-off, not a blocking finding.

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.

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