mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
Three things review caught, and one shape correction. **No credential has a default any more.** The compose file shipped `ADMIN_SHARED_SECRET` falling back to a value written in this repository — and that header bypasses token verification entirely, so anyone reading the file could act as an operator against any deployment that had not overridden it. A default is worth less than it looks here: the deployment that never set the variable is exactly the one where the default is public. Every credential now comes from `.env`, and compose refuses to start naming the variable it wanted. That also takes the last literal password out of a tracked file. **The origin ports are on loopback.** Both services speak plain HTTP and mark no cookie `Secure`, because both expect to sit behind something that terminates TLS. Published on every interface they were a way to reach the issuer around that proxy, with sign-in codes and tokens in clear text. **Mail settings are passed through rather than fixed.** The issuer was pinned to printing sign-in codes to its log, and the three delivery settings never reached it — so the documented way to configure mail could not work, and every code and recipient went to the container log instead. Printing codes is now asked for in `.env` like everything else, and with nothing configured the issuer refuses to send rather than logging. **Sandbox becomes a domain rather than a prefix.** `api.sandbox.nestri.io` and `auth.sandbox.nestri.io`, because sandbox holds whatever is not production and that set grows. One certificate for `*.sandbox.nestri.io` then covers all of it, including unpredictable per-pull-request names, and cannot be presented for production's own domain — which the zone-wide wildcard the previous shape leaned on could. Also drops `STEAM_API_KEY`. It was declared in two type definitions and read by nothing: linking an account makes no outbound call that needs it.
694 lines
27 KiB
Markdown
694 lines
27 KiB
Markdown
# `packages/core` — domain modules
|
|
|
|
## Structure
|
|
|
|
Every domain module lives in `packages/core/src/<parent>/` as either a top-level namespace or a nested sub-module:
|
|
|
|
```
|
|
src/<parent>/
|
|
├── <parent>.sql.ts # (optional) Drizzle table for the parent entity
|
|
├── index.ts # Parent namespace (e.g. User, Game, Team)
|
|
├── <child>.sql.ts # Sub-module table (e.g. fingerprint.sql.ts)
|
|
└── <child>.ts # Sub-module namespace (e.g. export namespace Fingerprint)
|
|
```
|
|
|
|
### Sub-modules nested under parents
|
|
|
|
| File | Namespace | Why |
|
|
| ----------------------- | --------------- | ------------------------------------- |
|
|
| `user/linked-account.*` | `LinkedAccount` | A user's OAuth/gaming identities |
|
|
| `user/fingerprint.*` | `Fingerprint` | SSH key fingerprints |
|
|
| `game/download.*` | `GameDownload` | Per-host game depot downloads |
|
|
| `user/library.*` | `Library` | User's owned games with playtime |
|
|
| `team/member.*` | `Member` | Team membership with role |
|
|
| `game/depot.*` | `Depot` | Platform-specific game content depots |
|
|
|
|
Existing top-level modules: `user/`, `team/`, `game/`, `pairing-code/`, `steam/`, `auth/`, `db/`.
|
|
|
|
Modules that don't own their own table (like `steam/`) only need a single `index.ts` exposing reusable `fn()` functions — no `.sql.ts` file.
|
|
|
|
## Pattern: `.sql.ts` (Drizzle Table)
|
|
|
|
```ts
|
|
// src/<module>/<module>.sql.ts
|
|
import { pgTable, text, boolean, jsonb, uniqueIndex, index, pgEnum } from 'drizzle-orm/pg-core';
|
|
|
|
import { id, timestamps, ulid } from '../db/types.js';
|
|
|
|
// Enum (only if needed — co-located with its table)
|
|
export const SomeEnum = pgEnum('some_enum', ['a', 'b']);
|
|
|
|
// FK imports — use the sql.ts files, never the index.ts (avoids circular deps)
|
|
import { UserTable } from '../user/user.sql.js';
|
|
|
|
export const SomeTable = pgTable(
|
|
'some_table',
|
|
{
|
|
...id, // char(30) PK, prefix: som_
|
|
...timestamps, // time_created, time_updated, time_deleted (all utc)
|
|
|
|
// FK column — always use ulid() + .references()
|
|
userId: ulid('user_id')
|
|
.notNull()
|
|
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
|
|
|
// Scalar columns
|
|
name: text('name').notNull(),
|
|
email: text('email'), // nullable = omit .notNull()
|
|
flag: boolean('flag').notNull().default(false),
|
|
metadata: jsonb('metadata').$type<{}>(), // JSON blob
|
|
|
|
// Enum column
|
|
provider: SomeEnum('provider').notNull()
|
|
},
|
|
(t) => [
|
|
uniqueIndex('some_table_provider_unique').on(t.provider, t.providerAccountId),
|
|
index('some_table_sync_idx')
|
|
.on(t.userId)
|
|
.where(sql`${t.localValue} is distinct from ${t.remoteValue}`),
|
|
index('some_table_user_idx').on(t.userId)
|
|
]
|
|
);
|
|
```
|
|
|
|
### DB types (`src/db/types.ts`)
|
|
|
|
| Helper | Output |
|
|
| ------------ | --------------------------------------------------------------- |
|
|
| `ulid(name)` | `char(30)` — for PKs and FKs |
|
|
| `id` | `{ id: ulid('id').primaryKey().notNull() }` — spread as `...id` |
|
|
| `utc(name)` | `timestamp with time zone` |
|
|
| `timestamps` | `{ timeCreated, timeUpdated (auto), timeDeleted }` |
|
|
|
|
### Naming conventions
|
|
|
|
- Table name: `snake_case` (e.g. `linked_account`, `team_member`)
|
|
- Column name: `snake_case` (e.g. `user_id`, `provider_account_id`, `time_created`)
|
|
- TypeScript field names: `camelCase` matching the column (drizzle maps them)
|
|
- Index names: `{table}_{column(s)}_unique` / `{table}_{column}_idx`
|
|
|
|
---
|
|
|
|
## Pattern: `index.ts` (Domain Namespace)
|
|
|
|
```ts
|
|
// src/<module>/index.ts
|
|
import { eq, and, isNull, sql } from 'drizzle-orm';
|
|
import z from 'zod';
|
|
|
|
import { Database } from '../db/index.js';
|
|
import { Examples } from '../examples.js';
|
|
import { fn } from '../fn.js';
|
|
import { SomeTable, SomeEnum } from './<module>.sql.js';
|
|
|
|
export namespace SomeModule {
|
|
// ── Info schema ─────────────────────────────────────────────────────
|
|
// Single source of truth for the entity shape.
|
|
// Every field typed here; .meta() adds OpenAPI metadata.
|
|
// When a field changes here, TypeScript catches every usage.
|
|
export const Info = z
|
|
.object({
|
|
id: z.string().meta({
|
|
description: '…',
|
|
example: Examples.SomeModule.id,
|
|
}),
|
|
// For enum fields, use z.enum(SomeEnum.enumValues) to stay in sync:
|
|
provider: z.enum(SomeEnum.enumValues).meta({ … }),
|
|
// Nullable + optional for JSON-blob / optional fields:
|
|
metadata: z.record(z.string(), z.unknown()).nullable().optional().meta({ … }),
|
|
})
|
|
.meta({
|
|
ref: 'SomeModule',
|
|
description: '…',
|
|
example: Examples.SomeModule,
|
|
});
|
|
|
|
export type Info = z.infer<typeof Info>;
|
|
|
|
// ── create ───────────────────────────────────────────────────────────
|
|
// Use Info.pick({…}) for the schema — keeps fields in sync with Info.
|
|
// Input is the parsed object.
|
|
// Use Database.use() for single-operation writes.
|
|
export const create = fn(
|
|
Info.pick({ id: true, name: true, email: true }),
|
|
async (input) => {
|
|
await Database.use(async (tx) => {
|
|
await tx.insert(SomeTable).values({
|
|
id: input.id,
|
|
name: input.name,
|
|
email: input.email ?? null,
|
|
});
|
|
});
|
|
return input.id;
|
|
}
|
|
);
|
|
|
|
// ── Single-field lookups ─────────────────────────────────────────────
|
|
// Use Info.shape.<field> for the schema.
|
|
// The callback receives the raw value, not { field: value }.
|
|
export const fromID = fn(Info.shape.id, async (id) => {
|
|
return Database.use(async (tx) => {
|
|
return tx
|
|
.select()
|
|
.from(SomeTable)
|
|
.where(and(eq(SomeTable.id, id), isNull(SomeTable.timeDeleted)))
|
|
.then((rows) => rows.at(0) ?? null);
|
|
});
|
|
});
|
|
|
|
export const fromSlug = fn(Info.shape.slug, async (slug) => {
|
|
// …
|
|
});
|
|
|
|
// ── Multi-field lookups ──────────────────────────────────────────────
|
|
// Use Info.pick({ field1: true, field2: true })
|
|
export const findByProvider = fn(
|
|
Info.pick({ provider: true, providerAccountId: true }),
|
|
async (input) => {
|
|
return Database.use(async (tx) => {
|
|
return tx
|
|
.select()
|
|
.from(SomeTable)
|
|
.where(and(
|
|
eq(SomeTable.provider, input.provider),
|
|
eq(SomeTable.providerAccountId, input.providerAccountId),
|
|
isNull(SomeTable.timeDeleted),
|
|
))
|
|
.then((rows) => rows.at(0) ?? null);
|
|
});
|
|
}
|
|
);
|
|
|
|
// ── List (no args) ───────────────────────────────────────────────────
|
|
// Plain async function — no fn() wrapper since there's no input.
|
|
export async function list() {
|
|
return Database.use(async (tx) => {
|
|
return tx
|
|
.select()
|
|
.from(SomeTable)
|
|
.where(isNull(SomeTable.timeDeleted))
|
|
.orderBy(SomeTable.timeCreated);
|
|
});
|
|
}
|
|
|
|
// ── List by FK ───────────────────────────────────────────────────────
|
|
export const listByUser = fn(Info.shape.userId, async (userId) => {
|
|
return Database.use(async (tx) => {
|
|
return tx
|
|
.select()
|
|
.from(SomeTable)
|
|
.where(and(eq(SomeTable.userId, userId), isNull(SomeTable.timeDeleted)))
|
|
.orderBy(SomeTable.timeCreated);
|
|
});
|
|
});
|
|
|
|
// ── Update ───────────────────────────────────────────────────────────
|
|
// If the update uses Info fields + possibly extra fields:
|
|
export const updateSomething = fn(
|
|
Info.pick({ id: true, otherField: true }).extend({
|
|
extraField: z.string(),
|
|
}),
|
|
async (input) => {
|
|
await Database.use(async (tx) => {
|
|
await tx
|
|
.update(SomeTable)
|
|
.set({ otherField: input.otherField })
|
|
.where(eq(SomeTable.id, input.id));
|
|
});
|
|
}
|
|
);
|
|
|
|
// ── Soft-delete ──────────────────────────────────────────────────────
|
|
// Use sql\`now()\` for the timestamp (consistent with DB time).
|
|
export const remove = fn(Info.shape.id, async (id) => {
|
|
await Database.use(async (tx) => {
|
|
await tx
|
|
.update(SomeTable)
|
|
.set({ timeDeleted: sql`now()` })
|
|
.where(eq(SomeTable.id, id));
|
|
});
|
|
});
|
|
|
|
// ── serialize ────────────────────────────────────────────────────────
|
|
// Converts a DB row into the public API shape. Keeps serialization
|
|
// decoupled from the DB layer.
|
|
export function serialize(input: typeof SomeTable.$inferSelect): z.infer<typeof Info> {
|
|
return {
|
|
id: input.id,
|
|
name: input.name,
|
|
// Cast enums since drizzle returns a string at runtime:
|
|
provider: input.provider as Info['provider'],
|
|
};
|
|
}
|
|
|
|
// ── listByUserWithGames ──────────────────────────────────────────────
|
|
// JOIN query with serialization inside the fn() boundary.
|
|
// The .then() chain maps raw rows to the public shape immediately.
|
|
export const listByUserWithGames = fn(Info.shape.userId, async (userId) => {
|
|
return Database.use(async (tx) => {
|
|
return tx
|
|
.select({
|
|
library: UserLibraryTable,
|
|
game: GameTable
|
|
})
|
|
.from(UserLibraryTable)
|
|
.leftJoin(GameTable, eq(UserLibraryTable.gameId, GameTable.id))
|
|
.where(and(eq(UserLibraryTable.userId, userId), isNull(UserLibraryTable.timeDeleted)))
|
|
.orderBy(UserLibraryTable.timeCreated)
|
|
.then((rows) =>
|
|
rows
|
|
.filter((row) => row.game !== null)
|
|
.map((row) => ({
|
|
id: row.library.id,
|
|
game: Game.serialize(row.game!),
|
|
playtime2w: row.library.playtime2w,
|
|
playtimeForever: row.library.playtimeForever,
|
|
lastPlayed: row.library.lastPlayed?.toISOString() ?? null
|
|
}))
|
|
);
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
### Key rules for `fn()` usage
|
|
|
|
| Case | Schema | Callback receives |
|
|
| -------------------- | ------------------------------------------ | ------------------------------------- |
|
|
| Single field | `Info.shape.field` | Raw value (`string`, `boolean`, etc.) |
|
|
| Multiple fields | `Info.pick({a:true, b:true})` | `{ a, b }` object |
|
|
| Full entity | `Info` | Full `Info` object |
|
|
| Info fields + extras | `Info.pick({…}).extend({extra: z.type()})` | `{ …fields, extra }` |
|
|
| No input | Regular `async function` | n/a |
|
|
|
|
### What `fn()` does
|
|
|
|
```ts
|
|
fn(schema, callback);
|
|
// → (input) => { schema.parse(input); return callback(parsed); }
|
|
// The returned function also has a .schema property for OpenAPI introspection.
|
|
```
|
|
|
|
---
|
|
|
|
## Serialization Boundary Rule
|
|
|
|
**All data transformation — including JOIN deserialization, field mapping, date stringification, and null filtering — happens INSIDE the `fn()` boundary, right after the DB query in a `.then()` chain. The API route is a dumb pass-through.**
|
|
|
|
### Why this matters
|
|
|
|
| Approach | Queries | Boundary clarity |
|
|
| ----------------------------------------------------- | ------------ | -------------------------------------- |
|
|
| **Bad**: Raw rows from core, map/filter in route | N+1 (or raw) | Leaky — route knows DB schema |
|
|
| **Bad**: JOIN in core, but serialize in route | 1 | Still leaky — route owns shaping logic |
|
|
| **Good**: JOIN + serialize in `.then()` inside `fn()` | 1 | Clean — core returns JSON-safe objects |
|
|
|
|
### The pattern
|
|
|
|
```ts
|
|
// GOOD: serialization inside fn()
|
|
export const listByUserWithGames = fn(Info.shape.userId, async (userId) => {
|
|
return Database.use(async (tx) => {
|
|
return tx
|
|
.select({ library: UserLibraryTable, game: GameTable })
|
|
.from(UserLibraryTable)
|
|
.leftJoin(GameTable, eq(UserLibraryTable.gameId, GameTable.id))
|
|
.where(...)
|
|
.orderBy(...)
|
|
.then((rows) =>
|
|
rows
|
|
.filter((row) => row.game !== null)
|
|
.map((row) => ({
|
|
id: row.library.id,
|
|
game: Game.serialize(row.game!),
|
|
lastPlayed: row.library.lastPlayed?.toISOString() ?? null
|
|
}))
|
|
);
|
|
});
|
|
});
|
|
|
|
// GOOD: API route is a thin pass-through
|
|
async (c) => {
|
|
const data = await Library.listByUserWithGames(Actor.userID);
|
|
return c.json({ data });
|
|
}
|
|
```
|
|
|
|
### Rules
|
|
|
|
1. **Never let raw Drizzle `$inferSelect` rows escape the core module.** If a function returns joined data, it must be shaped before the return.
|
|
2. **Use `.then()` after the query for map/filter/serialize.** Keeps the async pipeline declarative and co-located with the SQL.
|
|
3. **Re-use sibling `serialize()` functions for joined tables.** e.g. `Game.serialize(row.game!)` when joining `GameTable`.
|
|
4. **API routes only do:** auth checks, input validation, calling the core fn, and `c.json({ data })`. No `.map()`, no `.filter()`, no field remapping.
|
|
|
|
---
|
|
|
|
### Mutation Rule: Single-Query Reads via `.returning()`
|
|
|
|
Never execute a separate `tx.select()` or trigger a lookup function immediately after an `insert` or `upsert` mutation to fetch the updated state of a row.
|
|
|
|
Postgres natively supports the `RETURNING` clause. Always append `.returning()` directly to your mutation chains and destructure the resulting array (`const [row] = await tx...`). This ensures mutations remain atomic, avoids unnecessary connection pool overhead, and removes the latency penalty of running two sequential database operations.
|
|
|
|
---
|
|
|
|
## Pattern: ID generation (`src/id.ts`)
|
|
|
|
```ts
|
|
Identifier.ascending('user') // → "usr_<monotonic-id>"
|
|
Identifier.ascending('team') // → "tem_<monotonic-id>"
|
|
Identifier.ascending('linkedAccount') // → "lac_<monotonic-id>"
|
|
|
|
// Prefixes are defined in Identifier.prefixes:
|
|
{
|
|
user: 'usr',
|
|
linkedAccount: 'lac',
|
|
team: 'tem',
|
|
teamMember: 'mem',
|
|
verification: 'ver',
|
|
}
|
|
```
|
|
|
|
The IDs are 30-char strings: `{prefix}_{26 base62 chars}`. They are monotonically increasing (time-sortable) when using `ascending()`.
|
|
|
|
---
|
|
|
|
## Pattern: Examples (`src/examples.ts`)
|
|
|
|
```ts
|
|
export namespace Examples {
|
|
export const Id = (prefix: keyof typeof Identifier.prefixes) =>
|
|
`${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
|
|
|
export const User = { id: Id('user'), name: '…', email: '…', … };
|
|
export const LinkedAccount = { id: Id('linkedAccount'), provider: 'steam', … };
|
|
export const Team = { id: Id('team'), slug: 'my-team', … };
|
|
export const Member = { id: Id('teamMember'), role: 'owner' as const, … };
|
|
export const Fingerprint = { id: Id('userFingerprint'), fingerprint: '…', … };
|
|
export const GameDownload = { id: Id('gameDownload'), hostId: 'hst_…', gameId: Id('game'), status: 'downloading', … };
|
|
export const Library = { id: Id('userLibrary'), playtimeForever: 150000, … };
|
|
export const Depot = { id: Id('gameDepot'), depotId: 730, … };
|
|
}
|
|
```
|
|
|
|
Every entity in `Examples` must be added and imported by the `Info` schema's `.meta({ example: … })`.
|
|
|
|
---
|
|
|
|
## Pattern: Environment (`src/env.ts`)
|
|
|
|
```ts
|
|
export namespace Env {
|
|
export const Info = z.object({
|
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
FRONTEND_URL: z.string().optional(),
|
|
AUTH_ISSUER_URL: z.string().optional() // used by API auth middleware to verify tokens
|
|
});
|
|
export type Info = z.infer<typeof Info>;
|
|
export const env: Info = Info.parse(process.env);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Pattern: OpenAuth Subjects (`src/auth/subjects.ts`)
|
|
|
|
```ts
|
|
import { createSubjects } from '@nestri/auth/subject';
|
|
import { z } from 'zod';
|
|
|
|
export const subjects = createSubjects({
|
|
user: z.object({
|
|
userID: z.string(),
|
|
linkedAccountID: z.string()
|
|
})
|
|
});
|
|
```
|
|
|
|
The JWT contains `{ type: 'user', properties: { userID, linkedAccountID } }`.
|
|
The API verifies via `client.verify(subjects, token)` from `@nestri/auth/client`.
|
|
|
|
---
|
|
|
|
## Entity relationships
|
|
|
|
```
|
|
User ───1:N─── LinkedAccount ← auth methods (Steam, Epic, etc.)
|
|
│
|
|
├─── 1:N ─── Fingerprint ← SSH public keys
|
|
├─── 1:N ─── Library ← owned games with playtime
|
|
│
|
|
└─── N:M ─── Team ← via Member
|
|
└─ role: owner | admin | member
|
|
|
|
Game ───1:N─── Download ← per-host game depot downloads
|
|
```
|
|
|
|
- **User**: Person record. Email is nullable (gaming accounts don't provide one).
|
|
- **LinkedAccount**: A gaming/OAuth identity. `(provider, providerAccountId)` is unique.
|
|
- **Team**: Organization for billing/collaboration. First team is auto-created as "personal" team.
|
|
- **TeamMember**: Joins User → Team with a role. `(teamId, userId)` is unique.
|
|
|
|
---
|
|
|
|
## Database access
|
|
|
|
```ts
|
|
// Auto-scoped transaction (creates one if outside a transaction):
|
|
await Database.use(async (tx) => {
|
|
await tx.insert(SomeTable).values({ … });
|
|
const result = await tx.select().from(SomeTable).where(…);
|
|
});
|
|
|
|
// Explicit transaction:
|
|
await Database.transaction(async (tx) => {
|
|
// All operations in one atomic transaction
|
|
});
|
|
|
|
// Side-effect queued after transaction commits:
|
|
Database.effect(() => sendEmail(…));
|
|
```
|
|
|
|
---
|
|
|
|
## Soft-delete convention
|
|
|
|
Every table has `time_deleted` (nullable timestamp). Queries filter with `isNull(table.timeDeleted)`. Deletion sets `timeDeleted: sql\`now()\`` — never hard-deletes.
|
|
|
|
---
|
|
|
|
## Dependency rule
|
|
|
|
- `.sql.ts` files may import from other `.sql.ts` files (for FK references).
|
|
- `index.ts` files may import from other `index.ts` files and `.sql.ts` files.
|
|
- Never import an `index.ts` from within a `.sql.ts` — that creates circular deps.
|
|
|
|
---
|
|
|
|
## Actor Model (`packages/core/src/actor.ts`)
|
|
|
|
The Actor model identifies who/what is making a request. It uses `AsyncLocalStorage` (via `Context.create()`) so the actor is accessible anywhere in the call chain without passing it around.
|
|
|
|
### Actor types
|
|
|
|
```ts
|
|
type ActorInfo =
|
|
| { type: 'public'; properties: {} }
|
|
| { type: 'user'; properties: { userID: string; linkedAccountID: string } }
|
|
| {
|
|
type: 'member';
|
|
properties: { userID: string; teamID: string; role: 'owner' | 'admin' | 'member' };
|
|
}
|
|
| { type: 'system'; properties: { teamID: string } }
|
|
| { type: 'admin'; properties: {} };
|
|
```
|
|
|
|
### API
|
|
|
|
```ts
|
|
Actor.use(); // → ActorInfo (throws if no context set)
|
|
Actor.with(value, fn); // Run fn in the given actor context
|
|
Actor.assert(type); // Assert current actor type, returns narrowed type
|
|
Actor.type; // → 'public' | 'user' | 'member' | 'system' | 'admin'
|
|
Actor.userID; // → string (user/member only)
|
|
Actor.linkedAccountID; // → string (user only)
|
|
Actor.useTeam; // → string (member/system only — the teamID)
|
|
Actor.role; // → 'owner' | 'admin' | 'member' (member only)
|
|
Actor.isSignedIn; // → boolean (true if not public)
|
|
```
|
|
|
|
### When to pull from Actor vs pass as param
|
|
|
|
Functions that create resources owned by the current user (e.g. `Team.create`) pull `userID` from the actor context rather than requiring it as a parameter. This avoids passing `ownerId`/`userId` through the entire call chain.
|
|
|
|
Domain functions that need the actor's identity import `Actor` and call `Actor.userID` inside the `fn()` callback:
|
|
|
|
```ts
|
|
export const create = fn(Info.pick({ id: true, name: true, slug: true }), async (input) => {
|
|
const ownerId = Actor.userID; // from AsyncLocalStorage
|
|
// ...
|
|
});
|
|
```
|
|
|
|
### Actor.userID inside Database.transaction()
|
|
|
|
When doing find-or-create logic that must scope to the authenticated user, pull `Actor.userID` **inside** the `Database.transaction()` callback. This keeps scoping co-located with the DB logic:
|
|
|
|
```ts
|
|
export const link = fn(
|
|
z.object({ steamId: z.string(), profile: z.record(z.string(), z.unknown()).optional() }),
|
|
async (input) => {
|
|
return Database.transaction(async () => {
|
|
const existing = await LinkedAccount.findByProvider({ ... });
|
|
if (existing) return existing.id;
|
|
const id = Identifier.ascending('linkedAccount');
|
|
await LinkedAccount.create({ id, userId: Actor.userID, provider: 'steam', ... });
|
|
return id;
|
|
});
|
|
}
|
|
);
|
|
```
|
|
|
|
This ensures the API endpoint is scoped to the user only — no `userId` param is passed from the route handler. The `Actor.userID` is set by the auth middleware's `Actor.with()`, propagated via `AsyncLocalStorage`.
|
|
|
|
Callers must wrap actor-dependent work in `Actor.with()` before calling these functions. The API middleware does this automatically for HTTP requests. The auth worker sets it up explicitly:
|
|
|
|
```ts
|
|
await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, async () => {
|
|
await Team.createPersonal({ displayName: personaname });
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Auth Flow
|
|
|
|
### Auth Worker (`apps/auth/src/index.ts`)
|
|
|
|
A Cloudflare Worker using `@nestri/auth` (OpenAuth). Entry point is the `success` callback after OAuth:
|
|
|
|
1. Steam returns `steamid` → worker fetches profile from Steam API
|
|
2. Inside `Database.transaction()`: looks up existing `LinkedAccount.findByProvider`; if found returns existing user, else creates `User` + `LinkedAccount`
|
|
3. Wraps post-login setup in `Actor.with()`, then checks `Member.listByUser(userID)`
|
|
4. If no memberships, calls `Team.createPersonal({ displayName })`
|
|
5. Issues JWT via `context.subject('user', { userID, linkedAccountID })`
|
|
|
|
### Admin Auth via Shared Secret
|
|
|
|
Server-to-server calls can authenticate as an `admin` actor by setting the `x-nestri-admin-token` header to the value of `ADMIN_SHARED_SECRET`. This bypasses JWT auth entirely and grants a system-level actor with no user scope — useful for operations like adding games to the DB, syncing data, or other admin tasks.
|
|
|
|
Configure `ADMIN_SHARED_SECRET` in `.env`. It has no default anywhere — a known value here is an authentication bypass, so nothing falls back to one.
|
|
|
|
### Auth Middleware (`apps/api/app/middleware/auth.ts`)
|
|
|
|
Hono middleware that runs on every API request:
|
|
|
|
1. Checks `x-nestri-admin-token` header — if it matches `Env.get().ADMIN_SHARED_SECRET`, sets actor to `admin` and proceeds immediately
|
|
2. Otherwise, reads `Authorization: Bearer <token>` header
|
|
3. Verifies via `client.verify(subjects, token)` from `@nestri/auth/client`
|
|
4. If valid `user` subject:
|
|
- Checks `x-nestri-team` header for team-scoped access
|
|
- If team header present, verifies membership via `Member.findByTeamAndUser`
|
|
- Sets actor to `member` (with role) or `user` type
|
|
5. If no token/invalid: sets actor to `public` type
|
|
6. Exports `notPublic` guard middleware — throws `VisibleError('authentication', UNAUTHORIZED, …)` if actor is `public`. Caught by `onError` → 401 JSON response. The `admin` actor passes this guard (it's not `public`), so admin routes can use `.use(notPublic)` like any other protected route.
|
|
|
|
### OpenAuth Subjects (`src/auth/subjects.ts`)
|
|
|
|
```ts
|
|
export const subjects = createSubjects({
|
|
user: z.object({ userID: z.string(), linkedAccountID: z.string() })
|
|
});
|
|
```
|
|
|
|
The JWT contains `{ type: 'user', properties: { userID, linkedAccountID } }`.
|
|
Env var `AUTH_ISSUER_URL` configures which issuer to trust for token verification.
|
|
|
|
---
|
|
|
|
## Team Discriminator System
|
|
|
|
When creating a personal team (`Team.createPersonal`), the slug is derived from the display name:
|
|
|
|
```ts
|
|
// 1. Sanitize: lowercase, replace non-alphanumeric with dashes, trim edges, max 50 chars
|
|
const baseSlug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-')...
|
|
|
|
// 2. Check if slug exists (fromSlug)
|
|
// 3. If taken, append random 4-digit discriminator: "name-1234"
|
|
const slug = existing ? `${baseSlug}-${discriminator}` : baseSlug;
|
|
```
|
|
|
|
This mirrors Discord's discriminator pattern. The discriminator is part of the slug string — not a separate column.
|
|
|
|
---
|
|
|
|
## `fn()` and Actor Context
|
|
|
|
`fn()` itself is unchanged — it validates input against a zod schema and calls the callback. The actor context is available inside `fn()` callbacks because `Actor.with()` uses `AsyncLocalStorage`, which propagates automatically through `await` chains.
|
|
|
|
Every `fn()` callback can import and use `Actor` directly. No special wiring needed.
|
|
|
|
---
|
|
|
|
## API Error Pattern (`packages/core/src/error.ts`)
|
|
|
|
Centralized error types used by both the domain layer and the API.
|
|
|
|
### ErrorResponse
|
|
|
|
Zod schema for OpenAPI error responses:
|
|
|
|
```ts
|
|
import { z } from 'zod';
|
|
|
|
export const ErrorResponse = z
|
|
.object({
|
|
type: z.enum([
|
|
'validation',
|
|
'authentication',
|
|
'forbidden',
|
|
'not_found',
|
|
'already_exists',
|
|
'rate_limit',
|
|
'internal'
|
|
]),
|
|
code: z.string(),
|
|
message: z.string(),
|
|
param: z.string().optional(),
|
|
details: z.any().optional()
|
|
})
|
|
.meta({ ref: 'ErrorResponse' });
|
|
```
|
|
|
|
### ErrorCodes
|
|
|
|
Structured error code constants:
|
|
|
|
| Category | Codes |
|
|
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `Validation` | `MISSING_REQUIRED_FIELD`, `ALREADY_EXISTS`, `TEAM_ALREADY_EXISTS`, `INVALID_PARAMETER`, `INVALID_FORMAT`, `INVALID_STATE`, `IN_USE` |
|
|
| `Authentication` | `UNAUTHORIZED`, `INVALID_TOKEN`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` |
|
|
| `Permission` | `FORBIDDEN`, `INSUFFICIENT_PERMISSIONS`, `ACCOUNT_RESTRICTED` |
|
|
| `NotFound` | `RESOURCE_NOT_FOUND` |
|
|
| `RateLimit` | `TOO_MANY_REQUESTS`, `QUOTA_EXCEEDED` |
|
|
| `Server` | `INTERNAL_ERROR`, `SERVICE_UNAVAILABLE`, `DEPENDENCY_FAILURE` |
|
|
|
|
### VisibleError
|
|
|
|
Throw this for any user-facing error. It carries structured data and converts cleanly to HTTP:
|
|
|
|
```ts
|
|
throw new VisibleError(
|
|
'not_found',
|
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
|
`User ${id} does not exist`
|
|
);
|
|
```
|
|
|
|
- `.statusCode()` — maps `type` → HTTP status (validation→400, authentication→401, forbidden→403, not_found→404, already_exists→409, rate_limit→429, internal→500)
|
|
- `.toResponse()` → `{ type, code, message, param?, details? }`
|
|
|
|
The API's global `onError` handler catches `VisibleError` + `HTTPException` + unknown errors, logging each and returning the correct JSON shape.
|
|
|
|
---
|