chore: starter template

This commit is contained in:
e2e
2026-07-10 22:25:02 +02:00
commit 33da14f99e
157 changed files with 6462 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
import type { CoreServices, CoreSingletonServices, CoreConfig, CoreUserSession } from '@pikku/core'
import type { AuditLog, EmailService } from '@pikku/core/services'
import type { Kysely } from 'kysely'
import type { DB } from '#pikku/db/schema.gen.js'
import type { TypedSecretService } from '../.pikku/secrets/pikku-secrets.gen.js'
import type { TypedVariablesService } from '../.pikku/variables/pikku-variables.gen.js'
import type { auth } from './auth.js'
export interface UserSession extends CoreUserSession {
userId: string
}
export interface Config extends CoreConfig {
port: number
hostname: string
}
export interface SingletonServices extends CoreSingletonServices<Config> {
variables: TypedVariablesService
secrets: TypedSecretService
kysely: Kysely<DB>
// Resolved Better Auth instance, injected once by the generated pikkuServices
// wrapper from the `auth` factory. Typed as the factory's return so functions
// get better-auth's full server `api`/`handler` surface via DI.
auth: Awaited<ReturnType<typeof auth>>
// Always constructed in services.ts, so declare it REQUIRED here — it is
// optional in CoreSingletonServices, which otherwise makes every emailService
// use read as possibly-undefined and forces needless `!`/guards in functions.
emailService: EmailService
// Per-invocation audit log, ALWAYS returned from createWireServices (see
// services.ts) so general activity logging is available in every function —
// `await auditLog.write({ type, source: 'explicit', metadata })`. Declared
// REQUIRED (like emailService above) so a plain `auditLog.write(...)` doesn't
// read as possibly-undefined and force needless `?.`/guards. A function with
// `audit: true` ADDITIONALLY gets a kysely wrapped to capture every table write.
auditLog: AuditLog
}
export interface Services extends CoreServices<SingletonServices> {}

View File

@@ -0,0 +1,72 @@
import { betterAuth } from 'better-auth'
import { admin } from 'better-auth/plugins'
import { actor, fabric } from '@pikku/better-auth'
import { pikkuBetterAuth } from '#pikku'
/**
* Better Auth configuration — email + password sign-in.
*
* `pikkuBetterAuth` has no side effects: the pikku CLI statically inspects this single
* exported `auth` const and generates the catch-all `/api/auth/**` HTTP wiring,
* the session-bridge middleware, and a `wireSecret` for `BETTER_AUTH_SECRET` (and
* one per social provider, if you add any) — so the auth routes and secret
* requirements flow through normal inspection into the deploy manifest.
*
* The factory runs once when singleton services are built, pulling the secret
* (and the database) off the injected `services`; the resolved instance is then
* available to every function as `services.auth`. Better Auth is given the app's
* own kysely: the CamelCasePlugin maps Better Auth's camelCase field names onto
* the snake_case columns created in db/sqlite/0001-init.sql, keeping the whole DB
* on one naming convention. To offer Google / GitHub / ... add a `socialProviders`
* entry (and a button on the login page) — the CLI will wire its secret too.
*/
// The factory receives the FULL singleton services (emailService, logger, …) —
// destructure whatever you need, e.g. `{ kysely, secrets, emailService }` to wire
// sendResetPassword/verification emails. It runs lazily after all services exist,
// so never re-construct a service here or reach for a dynamic import.
export const auth = pikkuBetterAuth(async ({ kysely, secrets, variables }) => {
const BETTER_AUTH_SECRET = await secrets.getSecret('BETTER_AUTH_SECRET')
// Genuinely optional: unset simply disables /api/auth/sign-in/actor (scenarios
// off for this deployment) — the actor plugin refuses all sign-ins
// without it.
const SCENARIO_ACTOR_SECRET = await secrets
.getSecret('SCENARIO_ACTOR_SECRET')
.catch(() => undefined)
// Fabric operator admin: the RSA public key the control plane's token is
// verified against. The Fabric deployer pushes FABRIC_AUTH_PUBLIC_KEY onto
// every stage; locally it's simply absent, which disables /sign-in/fabric.
// Asymmetric — the app verifies, it can never forge an operator login.
const FABRIC_AUTH_PUBLIC_KEY = await variables.get('FABRIC_AUTH_PUBLIC_KEY')
return betterAuth({
secret: BETTER_AUTH_SECRET,
database: { db: kysely, type: 'sqlite' },
emailAndPassword: { enabled: true },
// Stateless session: CLI splits out betterAuthStatelessSession so non-auth
// units verify the signed cookie instead of bundling better-auth. pikku #737.
session: { cookieCache: { enabled: true } },
advanced: { database: { generateId: 'uuid' } },
// Scenario actors: synthetic users (user.actor = true, see
// db/sqlite/0003-user-actor.sql) signed in by pikkuScenario via
// POST /api/auth/sign-in/actor { email, secret }. Never signs in real users.
//
// admin(): exposes /api/auth/admin/* (listUsers, setRole, impersonateUser,
// …) so an app admin can list and "view as" their end-users — this is what
// the Fabric console's Users tab drives. Adds role/banned/impersonatedBy
// columns (see db/sqlite/0004-admin.sql). No user is an admin by default:
// grant it by setting a user's `role` to 'admin' (or pass
// `adminUserIds: [...]` here) — the admin API refuses non-admins.
//
// fabric(): exposes /api/auth/sign-in/fabric — the Fabric control plane
// mints a short-lived RS256 token and signs in as a synthetic `fabric: true`
// admin operator (db/sqlite/0005-fabric.sql), so the console Users tab can
// list/impersonate real users without the operator being one of them. It
// pairs with admin() and verifies against FABRIC_AUTH_PUBLIC_KEY; missing
// key disables the endpoint.
plugins: [
actor({ secret: SCENARIO_ACTOR_SECRET }),
admin(),
fabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY }),
],
})
})

View File

@@ -0,0 +1,6 @@
import { pikkuConfig } from '../.pikku/pikku-types.gen.js'
export const createConfig = pikkuConfig(async () => ({
port: parseInt(process.env.API_PORT || '4003', 10),
hostname: process.env.HOST || '0.0.0.0',
}))

View File

@@ -0,0 +1,32 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
export const GetSessionInput = z.object({})
export const GetSessionOutput = z.object({
userId: z.string(),
email: z.string().email(),
name: z.string().nullable(),
})
export const getSession = pikkuFunc({
expose: true,
readonly: true,
auth: true,
description: 'Returns the current signed-in user.',
input: GetSessionInput,
output: GetSessionOutput,
func: async ({ kysely }, _input, { session }) => {
const user = await kysely
.selectFrom('user')
.select(['id', 'email', 'name'])
.where('id', '=', session!.userId)
.executeTakeFirstOrThrow()
return {
userId: user.id,
email: user.email,
name: user.name ?? null,
}
},
})

View File

@@ -0,0 +1,33 @@
import { pikkuScenario } from '#pikku/workflow/pikku-workflow-types.gen.js'
/**
* A scenario is a story of RPC calls told through a synthetic persona (an
* "actor" — see scenarios.actors in pikku.config.json). Every step runs over
* the REAL transport with the actor's session cookie, so a passing scenario
* proves the deployed API works exactly as a signed-in user would experience it.
*
* Run it with `pikku scenario run local` (needs SCENARIO_ACTOR_SECRET in the
* environment — the actor plugin in src/auth.ts refuses sign-ins without it).
* This one signs the visitor in and reads their own session: actor sign-in,
* session cookie, authed RPC, and session mapping in one pass — a ready-made
* health check for staging or production.
*/
export const sessionHealthScenario = pikkuScenario<void, { email: string; userId: string }>({
title: 'Session health (scenario)',
tags: ['scenario'],
func: async ({ logger }, _input, { workflow, actors }) => {
if (!actors?.visitor) {
throw new Error(
'sessionHealthScenario needs run actors (visitor) — run via `pikku scenario run <environment>`',
)
}
logger.debug('session-health scenario starting')
const session = await workflow.do(
'visitor signs in and reads their session',
'getSession',
{},
{ actor: actors.visitor },
)
return { email: session.email, userId: session.userId }
},
})

View File

@@ -0,0 +1,52 @@
import {
LocalEmailService,
type EmailService,
type SendEmailInput,
type SendEmailResult,
} from '@pikku/core/services'
import { renderEmailTemplate, type EmailTemplateName } from '../../.pikku/email/pikku-emails.gen.js'
type GeneratedTemplateEmailServiceOptions = {
delegate?: EmailService
defaultLocale?: string
}
export class GeneratedTemplateEmailService implements EmailService {
private readonly delegate: EmailService
private readonly defaultLocale: string
constructor(options: GeneratedTemplateEmailServiceOptions = {}) {
this.delegate = options.delegate ?? new LocalEmailService()
this.defaultLocale = options.defaultLocale ?? 'en'
}
async send(input: SendEmailInput): Promise<SendEmailResult> {
if (!('template' in input) || !input.template) {
return this.delegate.send(input)
}
const rendered = renderEmailTemplate({
name: input.template.name as EmailTemplateName,
locale: (input.template.locale ?? this.defaultLocale) as Parameters<
typeof renderEmailTemplate
>[0]['locale'],
data: input.template.data ?? {},
})
return this.delegate.send({
to: input.to,
from: input.from,
cc: input.cc,
bcc: input.bcc,
replyTo: input.replyTo,
headers: {
...(input.headers ?? {}),
'x-pikku-email-template': String(input.template.name),
'x-pikku-email-hash': rendered.hash,
},
subject: rendered.subject,
html: rendered.html,
...(rendered.text ? { text: rendered.text } : {}),
})
}
}

View File

@@ -0,0 +1,18 @@
import { addHTTPMiddleware } from '@pikku/core/http'
import { cors } from '@pikku/core/middleware'
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
: [process.env.FRONTEND_URL, 'http://localhost:7104', 'http://127.0.0.1:7104'].filter(
(origin): origin is string => Boolean(origin),
)
addHTTPMiddleware('*', [
cors({
origin: corsOrigins,
credentials: true,
headers: ['Content-Type', 'Authorization', 'X-Auth-Return-Redirect'],
}),
])

View File

@@ -0,0 +1,98 @@
import {
JsonConsoleLogger,
LocalEmailService,
LocalSecretService,
LocalVariablesService,
NoopAuditService,
createInvocationAudit,
} from '@pikku/core/services'
import { createAuditedKysely } from '@pikku/kysely'
import { pikkuServices, pikkuWireServices } from '../.pikku/pikku-types.gen.js'
import { TypedSecretService } from '../.pikku/secrets/pikku-secrets.gen.js'
import { TypedVariablesService } from '../.pikku/variables/pikku-variables.gen.js'
import { CFWorkerSchemaService } from '@pikku/schema-cfworker'
import type { Kysely } from 'kysely'
import type { VercelAIAgentRunner } from '@pikku/ai-vercel'
import { GeneratedTemplateEmailService } from './lib/email-service.js'
import type { DB } from '#pikku/db/schema.gen.js'
export const createSingletonServices = pikkuServices(async (config, existingServices) => {
const variables =
existingServices?.variables ?? new TypedVariablesService(new LocalVariablesService())
const secrets =
existingServices?.secrets ?? new TypedSecretService(new LocalSecretService(variables))
const logger = existingServices?.logger ?? new JsonConsoleLogger()
const schema = existingServices?.schema ?? new CFWorkerSchemaService(logger)
const emailService =
existingServices?.emailService ??
new GeneratedTemplateEmailService({
delegate: new LocalEmailService(),
})
// The durable audit sink. In a deployed stage fabric injects the platform's
// audit service; locally it falls back to a no-op so nothing is persisted.
const audit = existingServices?.audit ?? new NoopAuditService()
// kysely is injected by pikku dev (node:sqlite) or the CF Worker workflow (libsql).
// The template never constructs its own dialect — dialects are fabric/runtime
// concerns — so it must always be provided by the runtime.
if (!existingServices?.kysely) {
throw new Error('kysely service was not injected by the runtime (pikku dev / fabric)')
}
const kysely: Kysely<DB> = existingServices.kysely
const litellmProxyUrl = process.env.LITELLM_PROXY_URL ?? null
const litellmApiKey = process.env.LITELLM_API_KEY ?? null
let aiAgentRunner: VercelAIAgentRunner | undefined
if (litellmProxyUrl && litellmApiKey) {
// The AI SDKs (~3MB) are stubbed out of non-agent units at bundle time —
// only units with the `ai-model` capability keep them. So import them
// dynamically and guard on the module resolving to a real export; in a
// stubbed unit the import yields `{}` and the runner is simply not built.
const aiVercel = await import('@pikku/ai-vercel')
const aiSdk = await import('@ai-sdk/openai-compatible')
if (aiVercel.VercelAIAgentRunner && aiSdk.createOpenAICompatible) {
const litellmProvider = aiSdk.createOpenAICompatible({
name: 'litellm',
baseURL: litellmProxyUrl,
apiKey: litellmApiKey,
})
aiAgentRunner = new aiVercel.VercelAIAgentRunner({
openai: (modelId: string) => litellmProvider.chatModel(modelId),
anthropic: (modelId: string) => litellmProvider.chatModel(modelId),
google: (modelId: string) => litellmProvider.chatModel(modelId),
deepseek: (modelId: string) => litellmProvider.chatModel(modelId),
})
}
}
return {
...(existingServices ?? {}),
config,
variables,
secrets,
schema,
logger,
emailService,
audit,
kysely,
...(aiAgentRunner ? { aiAgentRunner } : {}),
}
})
export const createWireServices = pikkuWireServices(async (singletonServices, wire) => {
if (!singletonServices.audit) {
return {}
}
const auditLog = createInvocationAudit(singletonServices.audit, wire)
// auditLog is ALWAYS returned so `auditLog.write(...)` works in every function
// for general activity logging. `auditLog.config` is set ONLY when this
// invocation opts into row-level auditing (`audit: true`); when it is, ALSO wrap
// kysely so every query is captured and the runner flushes the buffer on close.
// Without audit: true, leave the plain kysely untouched — no per-query overhead.
if (!auditLog.config) {
return { auditLog }
}
return {
auditLog,
kysely: createAuditedKysely(singletonServices.kysely, { audit: auditLog }),
}
})