import type { CollectionSchemaBase, Field } from './dsl.js'; import { stringField } from './dsl.js'; import { dtoField } from './dto.js'; import type { DtoField } from './dto.js'; import type { TableSchema } from './db.js'; import type { FrontAppSchema, ProjectApiSchema } from './project.js'; // Token = the server-side user identity object (login principal), a two-state // object: `security` (built-in secret/cipher, present from get-token on) + // `identity` (projected from table columns, attached at login). The client // holds a pure random hash token referencing this object — never the object // itself. Storage: token_schema/{api.name}/{app.name}/token/{name}.token.ts // (one token per file), same layout as service_schema / dao_schema. /** Session-credential columns the identity table must carry (hard constraint, * decision #13): the token system writes token + refresh_token + login_at to * the account table at login/refresh time. A table missing them cannot host * an identity. */ export const TOKEN_CREDENTIAL_COLUMNS = ['token', 'refresh_token', 'login_at'] as const; /** Built-in security-section field names: secret (signing, required) and * cipher (channel encryption, optional). Generated at get-token time and * stored in the Redis object — never backed by a table. */ export const TOKEN_SECURITY_FIELDS = ['secret', 'cipher'] as const; export interface TokenSchema extends CollectionSchemaBase { type: 'token'; /** The backend api module this token belongs to (shared instance from * project.config.ts apis). Tokens are always backend-side. */ api: ProjectApiSchema; /** The frontend app this token belongs to (shared instance from * project.config). Required — an identity always belongs to one module. */ app: FrontAppSchema; /** Security materials (present from get-token on): built-in secret * (required, signing) + cipher (optional, channel encryption). Not backed * by any table. */ security: Record; /** Identity data (attached at login): fields projected from table columns * via from(table, ...). Every source table must carry the session * credential columns (TOKEN_CREDENTIAL_COLUMNS). */ identity: Record; } /** Built-in security section: secret (required) + cipher (optional). */ function builtInSecurity(): Record { return { secret: dtoField(stringField({ minLength: 32, maxLength: 64, optional: false, label: '签名密钥' })), cipher: dtoField(stringField({ optional: true, label: '加密密钥' })), }; } function validateIdentityFields(tokenName: string, identity: Record): void { const sourceTables = new Set(); for (const [key, f] of Object.entries(identity)) { const source = f.field.schema; if (source?.type !== 'table') { throw new Error( `token ${tokenName}: identity field '${key}' must be projected from a table column (from(table, ...)), got a non-column field`, ); } if ((TOKEN_SECURITY_FIELDS as readonly string[]).includes(key)) { throw new Error( `token ${tokenName}: identity field '${key}' collides with the built-in security field of the same name`, ); } sourceTables.add(source as TableSchema); } // Hard constraint (decision #13): every identity source table must carry // the session credential columns — the token system writes them at // login/refresh time, a table without them breaks the whole system. for (const table of sourceTables) { for (const col of TOKEN_CREDENTIAL_COLUMNS) { if (table.columns[col] === undefined) { throw new Error( `token ${tokenName}: identity table '${table.name}' must contain column '${col}' (hard constraint — the token system writes session credentials to the account table)`, ); } } // Identity anchor (decision): every identity source table's primary key // must be fully projected into identity — a partial key cannot uniquely // locate the row (composite keys project every member). const pkFields: Field[] = table.primaryKey === undefined ? [] : Array.isArray(table.primaryKey) ? table.primaryKey : [table.primaryKey]; for (const pk of pkFields) { const projected = Object.values(identity).some((f) => f.field === pk); if (!projected) { throw new Error( `token ${tokenName}: identity must include the primary key column '${pk.name}' of table '${table.name}' (identity anchor — the user id is required)`, ); } } } } /** Write back the DTO field name from the map key (same convention as * buildMessage — TokenSchema is a field container, consumers rely on * field.name). */ function writeBackNames(segments: Record[]): void { for (const segment of segments) { for (const [key, df] of Object.entries(segment)) df.name = key; } } export function defineToken(options: { name: string; api: ProjectApiSchema; app: FrontAppSchema; identity: Record; description?: string; }): TokenSchema { const { name, api, app, identity, description } = options; if (!api.apps.includes(app)) { throw new Error(`token ${name}: api '${api.name}' does not serve app '${app.name}'`); } validateIdentityFields(name, identity); const security = builtInSecurity(); writeBackNames([security, identity]); return { type: 'token', name, description, api, app, security, identity, }; } /** Structural check — TokenSchema instances may come from a different module * copy, so instanceof is unreliable. */ export function isTokenSchema(v: unknown): v is TokenSchema { if (typeof v !== 'object' || v === null) return false; const vv = v as Record; return vv.type === 'token' && typeof vv.name === 'string'; }