Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ai-saas-starter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: AI SaaS starter

on:
pull_request:
paths:
- ".github/workflows/ai-saas-starter.yml"
- "ai_saas_starter/**"
push:
branches: [main]
paths:
- ".github/workflows/ai-saas-starter.yml"
- "ai_saas_starter/**"

permissions:
contents: read

concurrency:
group: ai-saas-starter-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: starter_test_password
POSTGRES_DB: starter_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d starter_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
defaults:
run:
working-directory: ai_saas_starter
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: 24
cache: npm
cache-dependency-path: ai_saas_starter/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test
env:
TEST_DATABASE_URL: postgres://postgres:starter_test_password@127.0.0.1:5432/starter_test
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
10 changes: 10 additions & 0 deletions ai_saas_starter/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Required for persistent production storage. Without it, the app uses an explicitly labelled in-memory demo store.
DATABASE_URL=

# demo | openai-compatible
AI_PROVIDER=demo
AI_BASE_URL=https://api.openai.com/v1
AI_MODEL=replace-with-your-model
AI_API_KEY=
AI_MONTHLY_TOKEN_LIMIT=50000
AI_REQUESTS_PER_MINUTE=20
Binary file added ai_saas_starter/.github/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions ai_saas_starter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.env
.env.local
.env.*.local
.next/
coverage/
node_modules/
playwright-report/
test-results/
*.tsbuildinfo
!.env.example
13 changes: 13 additions & 0 deletions ai_saas_starter/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->

## Runtime boundaries

- The included request limiter is process-local. For a multi-instance production deployment, replace it with a shared rate limiter backed by infrastructure such as Redis or the deployment platform's edge rate-limiting service.
1 change: 1 addition & 0 deletions ai_saas_starter/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
77 changes: 77 additions & 0 deletions ai_saas_starter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Tuurio AI SaaS Starter

A production-oriented, multi-tenant AI workspace with streamed conversations, tenant-isolated persistence, usage controls, role-aware administration, and Tuurio ID already wired in.

![Tuurio Canvas product preview](docs/screenshot.png)

## Product journey

- Public landing and pricing pages that explain the product before sign-in.
- Tuurio ID Authorization Code with PKCE for browser authentication.
- Server-side JWT validation against the tenant issuer and JWKS before any data access.
- Organization-scoped conversations, usage, audit events, and administration.
- A provider-neutral streaming adapter with a safe built-in demo provider.
- Postgres persistence in production and an explicitly labelled, reset-on-restart memory store for local evaluation.

## Quickstart

1. Install, start the app, and explore the browser-only product demo:

```bash
npm ci
cp .env.example .env.local
npm run dev
```

Open `http://localhost:3000/demo`. The demo requires no account, makes no API or model request, and resets on refresh. Open `/setup` to get a pinned provisioning command generated for the exact origin currently in your browser.

2. To enable the real protected workspace for local development, review and run:

```bash
npx manage-tuurio-id@1.1.6 init --framework nextjs --project-dir . --base-url http://localhost:3000 --redirect-uri http://localhost:3000/auth/callback --post-logout-redirect-uri http://localhost:3000/logout/callback --public-config src/tuurio.public.json --auth browser --yes --output json --campaign github_ai_saas --no-open --no-wait
```

Complete the browser handoff yourself. Never give an agent credentials, tokens, authorization codes, session cookies, legal acceptance, or environment-file contents.

3. Restart the app after provisioning:

```bash
npm run dev
```

Without `DATABASE_URL`, authenticated requests use a visibly labelled in-memory demo store. Without an AI key, `AI_PROVIDER=demo` streams deterministic local copy and never pretends to call a production model.

## Production setup

1. Deploy once to obtain the stable HTTPS origin.
2. Re-run the pinned CLI with exact production `--base-url`, `--redirect-uri`, and `--post-logout-redirect-uri` values. Commit only `src/tuurio.public.json`; it contains public OIDC client data and no secret.
3. Set `DATABASE_URL`, run `npm run db:migrate`, and store `AI_API_KEY` only in the deployment's server-side secret store.
4. Set `AI_PROVIDER=openai-compatible`, `AI_BASE_URL`, and `AI_MODEL` for a compatible streaming chat-completions endpoint.
5. Verify one real sign-in, one streamed response, tenant isolation, and sign-out.

Tuurio identity is hosted in the EU. This does not imply that your application host, database, AI provider, prompts, or model outputs are EU-hosted; choose and disclose those services separately.

## Security boundaries

- The browser is a public OIDC client and never receives a client secret.
- The established OIDC library owns state, nonce, PKCE, callback processing, and token expiry.
- APIs reject requests until the access token signature, exact issuer, intended audience, and time claims validate.
- The issuer origin is the tenant boundary; every storage query also requires that tenant key.
- AI provider credentials stay server-only. Prompts, model output, access tokens, and authorization codes are not logged.
- The included in-process request limiter is a safe starter default, not a substitute for a shared production rate limiter across multiple instances.

## Verification

```bash
npm run typecheck
npm test
npm run build
npx playwright install chromium
npm run test:e2e
```

The Postgres adapter has an integration suite as well. Start a disposable database and run `TEST_DATABASE_URL=postgres://... npm test`; the suite verifies persisted tenant isolation and atomic quota enforcement.

## License

Apache-2.0. See `LICENSE` in the generated template repository.
44 changes: 44 additions & 0 deletions ai_saas_starter/db/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
owner_subject TEXT NOT NULL,
title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS conversations_tenant_updated_idx
ON conversations (tenant_id, updated_at DESC);

CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS messages_tenant_conversation_idx
ON messages (tenant_id, conversation_id, created_at ASC);

CREATE TABLE IF NOT EXISTS monthly_usage (
tenant_id TEXT NOT NULL,
period TEXT NOT NULL,
input_units INTEGER NOT NULL DEFAULT 0 CHECK (input_units >= 0),
output_units INTEGER NOT NULL DEFAULT 0 CHECK (output_units >= 0),
request_count INTEGER NOT NULL DEFAULT 0 CHECK (request_count >= 0),
PRIMARY KEY (tenant_id, period)
);

CREATE TABLE IF NOT EXISTS audit_events (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
actor_subject TEXT NOT NULL,
action TEXT NOT NULL,
target_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS audit_events_tenant_created_idx
ON audit_events (tenant_id, created_at DESC);
Binary file added ai_saas_starter/docs/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions ai_saas_starter/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
26 changes: 26 additions & 0 deletions ai_saas_starter/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { NextConfig } from "next";

// Next.js static rendering requires `unsafe-inline` unless the app adopts
// per-request nonces, which make every page dynamic. This starter stays static;
// deployments that require a nonce-based CSP can follow the official Next.js guide.
const scriptSources = process.env.NODE_ENV === "development"
? "'self' 'unsafe-inline' 'unsafe-eval'"
: "'self' 'unsafe-inline'";

const nextConfig: NextConfig = {
poweredByHeader: false,
async headers() {
return [{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Content-Security-Policy", value: `default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src ${scriptSources}; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://id.tuurio.com https://*.id.tuurio.com` },
],
}];
},
};

export default nextConfig;
Loading