chore: starter template
This commit is contained in:
39
packages/functions/src/application-types.d.ts
vendored
Normal file
39
packages/functions/src/application-types.d.ts
vendored
Normal 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> {}
|
||||
72
packages/functions/src/auth.ts
Normal file
72
packages/functions/src/auth.ts
Normal 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 }),
|
||||
],
|
||||
})
|
||||
})
|
||||
6
packages/functions/src/config.ts
Normal file
6
packages/functions/src/config.ts
Normal 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',
|
||||
}))
|
||||
32
packages/functions/src/functions/get-session.function.ts
Normal file
32
packages/functions/src/functions/get-session.function.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
33
packages/functions/src/functions/session-health.scenario.ts
Normal file
33
packages/functions/src/functions/session-health.scenario.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
52
packages/functions/src/lib/email-service.ts
Normal file
52
packages/functions/src/lib/email-service.ts
Normal 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 } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
18
packages/functions/src/middleware/cors.middleware.ts
Normal file
18
packages/functions/src/middleware/cors.middleware.ts
Normal 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'],
|
||||
}),
|
||||
])
|
||||
98
packages/functions/src/services.ts
Normal file
98
packages/functions/src/services.ts
Normal 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 }),
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user