@pylonts/dsl 1.1.16 → 1.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ export * from './aggregate.js';
31
31
  export * from './repository.js';
32
32
  export * from './domain-event.js';
33
33
  export * from './third-service.js';
34
+ export * from './token.js';
34
35
  export * from './field-rule.js';
35
36
  export * from './exception.js';
36
37
  export * from './page.js';
@@ -1,109 +1,109 @@
1
- import { Field, rateScale } from './dsl.js';
2
- import { ForeignKey, Index, TableSchema } from './db.js';
3
-
4
- // MySQL driver: converts a TableSchema into a CREATE TABLE statement.
5
-
6
- /** Default value constant for created_at columns. */
7
- export const CURRENT_TIMESTAMP = 'CURRENT_TIMESTAMP';
8
- /** Default value constant for updated_at columns (with auto-update). */
9
- export const CURRENT_TIMESTAMP_ON_UPDATE = 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP';
10
-
11
- function columnType(field: Field): string {
12
- switch (field.type) {
13
- case 'string':
14
- if (!field.maxLength) throw new Error(`field ${field.name} (string) requires maxLength`);
15
- return `VARCHAR(${field.maxLength})`;
16
- case 'text':
17
- return 'TEXT';
18
- case 'integer':
19
- return 'INT';
20
- case 'bigint':
21
- return 'BIGINT';
22
- case 'decimal':
23
- return `DECIMAL(${field.precision}, ${field.scale})`;
24
- case 'rate':
25
- return `DECIMAL(5, ${rateScale(field.unit)})`;
26
- case 'boolean':
27
- return 'TINYINT(1)';
28
- case 'date':
29
- return 'DATE';
30
- case 'time':
31
- return 'TIME';
32
- case 'datetime':
33
- return 'DATETIME';
34
- case 'enum':
35
- // Enum is stored as a plain column: string -> VARCHAR(20), integer -> TINYINT.
36
- return field.enum.valueType === 'integer' ? 'TINYINT' : 'VARCHAR(20)';
37
- case 'json':
38
- return 'JSON';
39
- case 'array':
40
- case 'object':
41
- // Nested fields are wire-format only (third-party messages); table columns cannot nest.
42
- throw new Error(`field ${field.name} (${field.type}): nested fields are not supported on table columns`);
43
- case 'aggregate':
44
- // Aggregate fields are query outputs, never table columns.
45
- throw new Error(`field ${field.name} (aggregate): aggregate fields are not supported on table columns`);
46
- }
47
- }
48
-
49
- function renderDefault(field: Field): string {
50
- if (field.default === undefined) return '';
51
- // MySQL keyword expressions (CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
52
- // NULL, etc.) render bare, no quotes.
53
- if (/^[A-Z]/.test(field.default)) return ` DEFAULT ${field.default}`;
54
- // Numeric columns take a bare literal, not a quoted one.
55
- if (field.type === 'integer' || field.type === 'bigint' || field.type === 'decimal' || field.type === 'rate' || field.type === 'boolean') {
56
- return ` DEFAULT ${field.default}`;
57
- }
58
- return ` DEFAULT '${field.default}'`;
59
- }
60
-
61
- function columnDef(field: Field, autoIncrement?: Field): string {
62
- const parts = [field.name, columnType(field)];
63
- if (field.optional === false) parts.push('NOT NULL');
64
- parts.push(renderDefault(field));
65
- if (field === autoIncrement) parts.push('AUTO_INCREMENT');
66
- if (field.description) parts.push(`COMMENT '${field.description.replace(/'/g, "\\'")}'`);
67
- return parts.join(' ');
68
- }
69
-
70
- function primaryKeyClause(schema: TableSchema): string | null {
71
- if (!schema.primaryKey) return null;
72
- const fields = Array.isArray(schema.primaryKey) ? schema.primaryKey : [schema.primaryKey];
73
- return `PRIMARY KEY (${fields.map((f) => f.name).join(', ')})`;
74
- }
75
-
76
- function indexClause(index: Index): string {
77
- const fields = Array.isArray(index.columns) ? index.columns : [index.columns];
78
- const kind = index.unique ? 'UNIQUE KEY' : 'KEY';
79
- const name = index.name ?? fields.map((f) => f.name).join('_');
80
- return `${kind} ${name} (${fields.map((f) => f.name).join(', ')})`;
81
- }
82
-
83
- function foreignKeyClause(name: string, fk: ForeignKey): string {
84
- const fields = Array.isArray(fk.columns) ? fk.columns : [fk.columns];
85
- const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
86
- const refTable = refs[0].schema?.name;
87
- if (!refTable) throw new Error(`foreign key ${name}: references field has no schema`);
88
- const fkCols = fields.map((f) => f.name).join(', ');
89
- const refCols = refs.map((r) => r.name).join(', ');
90
- return `CONSTRAINT \`${name}\` FOREIGN KEY (${fkCols}) REFERENCES \`${refTable}\` (${refCols})`;
91
- }
92
-
93
- export interface BuildCreateTableSqlOptions {
94
- /** 是否生成外键约束,默认 false(不生成) */
95
- generateForeignKeys?: boolean;
96
- }
97
-
98
- export function buildCreateTableSql(schema: TableSchema, options: BuildCreateTableSqlOptions = {}): string {
99
- const lines = Object.values(schema.columns).map((field) => columnDef(field, schema.autoIncrement));
100
- const pk = primaryKeyClause(schema);
101
- if (pk) lines.push(pk);
102
- for (const index of schema.indexes ?? []) lines.push(indexClause(index));
103
- if (options.generateForeignKeys) {
104
- for (const [name, fk] of Object.entries(schema.foreignKeys ?? {})) {
105
- lines.push(foreignKeyClause(name, fk));
106
- }
107
- }
108
- return `CREATE TABLE IF NOT EXISTS \`${schema.name}\` (\n ${lines.join(',\n ')}\n);`;
1
+ import { Field, rateScale } from './dsl.js';
2
+ import { ForeignKey, Index, TableSchema } from './db.js';
3
+
4
+ // MySQL driver: converts a TableSchema into a CREATE TABLE statement.
5
+
6
+ /** Default value constant for created_at columns. */
7
+ export const CURRENT_TIMESTAMP = 'CURRENT_TIMESTAMP';
8
+ /** Default value constant for updated_at columns (with auto-update). */
9
+ export const CURRENT_TIMESTAMP_ON_UPDATE = 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP';
10
+
11
+ function columnType(field: Field): string {
12
+ switch (field.type) {
13
+ case 'string':
14
+ if (!field.maxLength) throw new Error(`field ${field.name} (string) requires maxLength`);
15
+ return `VARCHAR(${field.maxLength})`;
16
+ case 'text':
17
+ return 'TEXT';
18
+ case 'integer':
19
+ return 'INT';
20
+ case 'bigint':
21
+ return 'BIGINT';
22
+ case 'decimal':
23
+ return `DECIMAL(${field.precision}, ${field.scale})`;
24
+ case 'rate':
25
+ return `DECIMAL(5, ${rateScale(field.unit)})`;
26
+ case 'boolean':
27
+ return 'TINYINT(1)';
28
+ case 'date':
29
+ return 'DATE';
30
+ case 'time':
31
+ return 'TIME';
32
+ case 'datetime':
33
+ return 'DATETIME';
34
+ case 'enum':
35
+ // Enum is stored as a plain column: string -> VARCHAR(20), integer -> TINYINT.
36
+ return field.enum.valueType === 'integer' ? 'TINYINT' : 'VARCHAR(20)';
37
+ case 'json':
38
+ return 'JSON';
39
+ case 'array':
40
+ case 'object':
41
+ // Nested fields are wire-format only (third-party messages); table columns cannot nest.
42
+ throw new Error(`field ${field.name} (${field.type}): nested fields are not supported on table columns`);
43
+ case 'aggregate':
44
+ // Aggregate fields are query outputs, never table columns.
45
+ throw new Error(`field ${field.name} (aggregate): aggregate fields are not supported on table columns`);
46
+ }
47
+ }
48
+
49
+ function renderDefault(field: Field): string {
50
+ if (field.default === undefined) return '';
51
+ // MySQL keyword expressions (CURRENT_TIMESTAMP / CURRENT_TIMESTAMP ON UPDATE
52
+ // CURRENT_TIMESTAMP) render bare; any other value is a literal and gets quoted.
53
+ if (field.default.startsWith(CURRENT_TIMESTAMP)) return ` DEFAULT ${field.default}`;
54
+ // Numeric columns take a bare literal, not a quoted one.
55
+ if (field.type === 'integer' || field.type === 'bigint' || field.type === 'decimal' || field.type === 'rate' || field.type === 'boolean') {
56
+ return ` DEFAULT ${field.default}`;
57
+ }
58
+ return ` DEFAULT '${field.default}'`;
59
+ }
60
+
61
+ function columnDef(field: Field, autoIncrement?: Field): string {
62
+ const parts = [field.name, columnType(field)];
63
+ if (field.optional === false) parts.push('NOT NULL');
64
+ parts.push(renderDefault(field));
65
+ if (field === autoIncrement) parts.push('AUTO_INCREMENT');
66
+ if (field.description) parts.push(`COMMENT '${field.description.replace(/'/g, "\\'")}'`);
67
+ return parts.join(' ');
68
+ }
69
+
70
+ function primaryKeyClause(schema: TableSchema): string | null {
71
+ if (!schema.primaryKey) return null;
72
+ const fields = Array.isArray(schema.primaryKey) ? schema.primaryKey : [schema.primaryKey];
73
+ return `PRIMARY KEY (${fields.map((f) => f.name).join(', ')})`;
74
+ }
75
+
76
+ function indexClause(index: Index): string {
77
+ const fields = Array.isArray(index.columns) ? index.columns : [index.columns];
78
+ const kind = index.unique ? 'UNIQUE KEY' : 'KEY';
79
+ const name = index.name ?? fields.map((f) => f.name).join('_');
80
+ return `${kind} ${name} (${fields.map((f) => f.name).join(', ')})`;
81
+ }
82
+
83
+ function foreignKeyClause(name: string, fk: ForeignKey): string {
84
+ const fields = Array.isArray(fk.columns) ? fk.columns : [fk.columns];
85
+ const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
86
+ const refTable = refs[0].schema?.name;
87
+ if (!refTable) throw new Error(`foreign key ${name}: references field has no schema`);
88
+ const fkCols = fields.map((f) => f.name).join(', ');
89
+ const refCols = refs.map((r) => r.name).join(', ');
90
+ return `CONSTRAINT \`${name}\` FOREIGN KEY (${fkCols}) REFERENCES \`${refTable}\` (${refCols})`;
91
+ }
92
+
93
+ export interface BuildCreateTableSqlOptions {
94
+ /** 是否生成外键约束,默认 false(不生成) */
95
+ generateForeignKeys?: boolean;
96
+ }
97
+
98
+ export function buildCreateTableSql(schema: TableSchema, options: BuildCreateTableSqlOptions = {}): string {
99
+ const lines = Object.values(schema.columns).map((field) => columnDef(field, schema.autoIncrement));
100
+ const pk = primaryKeyClause(schema);
101
+ if (pk) lines.push(pk);
102
+ for (const index of schema.indexes ?? []) lines.push(indexClause(index));
103
+ if (options.generateForeignKeys) {
104
+ for (const [name, fk] of Object.entries(schema.foreignKeys ?? {})) {
105
+ lines.push(foreignKeyClause(name, fk));
106
+ }
107
+ }
108
+ return `CREATE TABLE IF NOT EXISTS \`${schema.name}\` (\n ${lines.join(',\n ')}\n);`;
109
109
  }
package/src/service.ts CHANGED
@@ -31,6 +31,11 @@ export function defineService(options: {
31
31
  methods: Record<string, ServiceMethodDef>;
32
32
  description?: string;
33
33
  }): ServiceSchema {
34
+ if (!/(Service|Handler)$/.test(options.name)) {
35
+ throw new Error(
36
+ `service ${options.name}: name must end with 'Service' (or 'Handler' for handler-style services)`,
37
+ );
38
+ }
34
39
  if (options.app && !options.api.apps.includes(options.app)) {
35
40
  throw new Error(`service ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
36
41
  }
@@ -1,7 +1,7 @@
1
1
  import type { SchemaBase } from './dsl.js';
2
2
  import type { DtoMessage } from './dto.js';
3
3
  import type { ExceptionSchema } from './exception.js';
4
- import type { ThirdApiSchema } from './project.js';
4
+ import type { ProjectApiSchema, ThirdApiSchema } from './project.js';
5
5
 
6
6
  // Third-party integration services.
7
7
  // A third-party method's args/results are plain DtoMessages — the same POJO
@@ -13,13 +13,27 @@ import type { ThirdApiSchema } from './project.js';
13
13
  /** A third-party integration service (e.g. tenpay wechat pay).
14
14
  * Distinct from ServiceSchema (backend service bound to an app) and
15
15
  * ThirdApiSchema (project topology: the dir the integration lives in).
16
- * Describes the adapter class contract: constructor config + methods. */
16
+ * Describes the adapter class contract: constructor config + methods.
17
+ * `callbacks` are the inbound half — push messages the third party sends to
18
+ * this platform (e.g. audit-result notify), rendered as @Public controllers. */
19
+ /** Wire data format of the third-party gateway. 'form' (urlencoded) and
20
+ * 'json' are built-in in @pylonts/sandbox; any other string is a custom
21
+ * format the sandbox encoder parses itself. The client and the sandbox are
22
+ * mirror images of one protocol — the format declared here is the single
23
+ * source both generated sides read. */
24
+ export type ThirdServiceFormat = 'form' | 'json' | string;
25
+
17
26
  export interface ThirdServiceSchema extends SchemaBase {
18
27
  type: 'thirdService';
19
28
  /** The third-party system this service integrates (topology reference). */
20
29
  schema: ThirdApiSchema;
30
+ /** Wire data format of the gateway (see ThirdServiceFormat). Default 'json'. */
31
+ format?: ThirdServiceFormat;
21
32
  /** Methods keyed by name — the map key is written back as the method name. */
22
33
  methods: Record<string, ThirdServiceMethodSchema>;
34
+ /** Callbacks (third-party push → platform) keyed by name. Empty when the
35
+ * integration is outbound-only. */
36
+ callbacks: Record<string, ThirdCallbackSchema>;
23
37
  }
24
38
 
25
39
  /** A method of a third-party integration service. Same contract shape as
@@ -29,6 +43,11 @@ export interface ThirdServiceSchema extends SchemaBase {
29
43
  export interface ThirdServiceMethodSchema extends SchemaBase {
30
44
  type: 'method';
31
45
  schema: ThirdServiceSchema;
46
+ /** Wire service name sent to the third-party gateway. Defaults to the method
47
+ * key — declare explicitly when the wire value differs (e.g. camelCase key
48
+ * vs snake_case wire: uploadImage → 'pic_upload'). Both the client and the
49
+ * sandbox read this field, so the mirror pair stays in sync. */
50
+ service?: string;
32
51
  /** Input message. */
33
52
  args: DtoMessage;
34
53
  /** Output message. */
@@ -40,10 +59,59 @@ export interface ThirdServiceMethodSchema extends SchemaBase {
40
59
  /** Method input for defineThirdService: name/schema are set by the builder. */
41
60
  export type ThirdServiceMethodDef = Omit<ThirdServiceMethodSchema, 'type' | 'schema' | 'name'>;
42
61
 
62
+ /** An inbound callback of a third-party service. The third party pushes a
63
+ * payload (e.g. audit-result notify) to a platform endpoint; the platform
64
+ * verifies the third party's own signature/decrypts (hand-written in the
65
+ * generated controller body) and answers with `response`.
66
+ * `api` selects the backend the endpoint is generated into:
67
+ * {api}/src/modules/{thirdApi.name}/controller/ — the only module location
68
+ * whitelisted for @Public (lint controller), because a third party never
69
+ * holds an appKey. */
70
+ export interface ThirdCallbackSchema extends SchemaBase {
71
+ type: 'thirdCallback';
72
+ schema: ThirdServiceSchema;
73
+ /** Backend API the callback controller is generated into. */
74
+ api: ProjectApiSchema;
75
+ /** The third-party push service name (e.g. 'apply_notify', 'alter_notify'). */
76
+ service: string;
77
+ /** Push message (third party → platform). */
78
+ payload: DtoMessage;
79
+ /** Platform answer message (platform → third party). */
80
+ response: DtoMessage;
81
+ }
82
+
83
+ /** Callback input for defineThirdService: name/schema are set by the builder. */
84
+ export type ThirdCallbackDef = Omit<ThirdCallbackSchema, 'type' | 'schema' | 'name'>;
85
+
86
+ /** Builds one callback entry for `defineThirdService({ callbacks })`. The
87
+ * callback name (map key in the callbacks object) becomes the controller
88
+ * method name; everything else is declared here. */
89
+ export function defineThirdCallback(options: {
90
+ /** Backend API the callback controller is generated into. */
91
+ api: ProjectApiSchema;
92
+ /** The third-party push service name (e.g. 'apply_notify', 'alter_notify'). */
93
+ service: string;
94
+ /** Push message (third party → platform). */
95
+ payload: DtoMessage;
96
+ /** Platform answer message (platform → third party). */
97
+ response: DtoMessage;
98
+ description?: string;
99
+ }): ThirdCallbackDef {
100
+ return {
101
+ description: options.description,
102
+ api: options.api,
103
+ service: options.service,
104
+ payload: options.payload,
105
+ response: options.response,
106
+ };
107
+ }
108
+
43
109
  export function defineThirdService(options: {
44
110
  schema: ThirdApiSchema;
45
111
  name: string;
112
+ format?: ThirdServiceFormat;
46
113
  methods: Record<string, ThirdServiceMethodDef>;
114
+ callbacks?: Record<string, ThirdCallbackDef>;
47
115
  description?: string;
48
116
  }): ThirdServiceSchema {
49
117
  const schema: ThirdServiceSchema = {
@@ -51,7 +119,9 @@ export function defineThirdService(options: {
51
119
  name: options.name,
52
120
  description: options.description,
53
121
  schema: options.schema,
122
+ format: options.format,
54
123
  methods: {},
124
+ callbacks: {},
55
125
  };
56
126
  for (const key of Object.keys(options.methods)) {
57
127
  const method = options.methods[key];
@@ -60,10 +130,24 @@ export function defineThirdService(options: {
60
130
  name: key,
61
131
  description: method.description,
62
132
  schema,
133
+ service: method.service,
63
134
  args: method.args,
64
135
  results: method.results,
65
136
  throws: method.throws,
66
137
  };
67
138
  }
139
+ for (const key of Object.keys(options.callbacks ?? {})) {
140
+ const callback = options.callbacks![key];
141
+ schema.callbacks[key] = {
142
+ type: 'thirdCallback',
143
+ name: key,
144
+ description: callback.description,
145
+ schema,
146
+ api: callback.api,
147
+ service: callback.service,
148
+ payload: callback.payload,
149
+ response: callback.response,
150
+ };
151
+ }
68
152
  return schema;
69
153
  }
package/src/token.ts ADDED
@@ -0,0 +1,139 @@
1
+ import type { CollectionSchemaBase, Field } from './dsl.js';
2
+ import { stringField } from './dsl.js';
3
+ import { dtoField } from './dto.js';
4
+ import type { DtoField } from './dto.js';
5
+ import type { TableSchema } from './db.js';
6
+ import type { FrontAppSchema, ProjectApiSchema } from './project.js';
7
+
8
+ // Token = the server-side user identity object (login principal), a two-state
9
+ // object: `security` (built-in secret/cipher, present from get-token on) +
10
+ // `identity` (projected from table columns, attached at login). The client
11
+ // holds a pure random hash token referencing this object — never the object
12
+ // itself. Storage: token_schema/{api.name}/{app.name}/token/{name}.token.ts
13
+ // (one token per file), same layout as service_schema / dao_schema.
14
+
15
+ /** Session-credential columns the identity table must carry (hard constraint,
16
+ * decision #13): the token system writes token + refresh_token + login_at to
17
+ * the account table at login/refresh time. A table missing them cannot host
18
+ * an identity. */
19
+ export const TOKEN_CREDENTIAL_COLUMNS = ['token', 'refresh_token', 'login_at'] as const;
20
+
21
+ /** Built-in security-section field names: secret (signing, required) and
22
+ * cipher (channel encryption, optional). Generated at get-token time and
23
+ * stored in the Redis object — never backed by a table. */
24
+ export const TOKEN_SECURITY_FIELDS = ['secret', 'cipher'] as const;
25
+
26
+ export interface TokenSchema extends CollectionSchemaBase {
27
+ type: 'token';
28
+ /** The backend api module this token belongs to (shared instance from
29
+ * project.config.ts apis). Tokens are always backend-side. */
30
+ api: ProjectApiSchema;
31
+ /** The frontend app this token belongs to (shared instance from
32
+ * project.config). Required — an identity always belongs to one module. */
33
+ app: FrontAppSchema;
34
+ /** Security materials (present from get-token on): built-in secret
35
+ * (required, signing) + cipher (optional, channel encryption). Not backed
36
+ * by any table. */
37
+ security: Record<string, DtoField>;
38
+ /** Identity data (attached at login): fields projected from table columns
39
+ * via from(table, ...). Every source table must carry the session
40
+ * credential columns (TOKEN_CREDENTIAL_COLUMNS). */
41
+ identity: Record<string, DtoField>;
42
+ }
43
+
44
+ /** Built-in security section: secret (required) + cipher (optional). */
45
+ function builtInSecurity(): Record<string, DtoField> {
46
+ return {
47
+ secret: dtoField(stringField({ minLength: 32, maxLength: 64, optional: false, label: '签名密钥' })),
48
+ cipher: dtoField(stringField({ optional: true, label: '加密密钥' })),
49
+ };
50
+ }
51
+
52
+ function validateIdentityFields(tokenName: string, identity: Record<string, DtoField>): void {
53
+ const sourceTables = new Set<TableSchema>();
54
+ for (const [key, f] of Object.entries(identity)) {
55
+ const source = f.field.schema;
56
+ if (source?.type !== 'table') {
57
+ throw new Error(
58
+ `token ${tokenName}: identity field '${key}' must be projected from a table column (from(table, ...)), got a non-column field`,
59
+ );
60
+ }
61
+ if ((TOKEN_SECURITY_FIELDS as readonly string[]).includes(key)) {
62
+ throw new Error(
63
+ `token ${tokenName}: identity field '${key}' collides with the built-in security field of the same name`,
64
+ );
65
+ }
66
+ sourceTables.add(source as TableSchema);
67
+ }
68
+ // Hard constraint (decision #13): every identity source table must carry
69
+ // the session credential columns — the token system writes them at
70
+ // login/refresh time, a table without them breaks the whole system.
71
+ for (const table of sourceTables) {
72
+ for (const col of TOKEN_CREDENTIAL_COLUMNS) {
73
+ if (table.columns[col] === undefined) {
74
+ throw new Error(
75
+ `token ${tokenName}: identity table '${table.name}' must contain column '${col}' (hard constraint — the token system writes session credentials to the account table)`,
76
+ );
77
+ }
78
+ }
79
+ // Identity anchor (decision): every identity source table's primary key
80
+ // must be fully projected into identity — a partial key cannot uniquely
81
+ // locate the row (composite keys project every member).
82
+ const pkFields: Field[] =
83
+ table.primaryKey === undefined
84
+ ? []
85
+ : Array.isArray(table.primaryKey)
86
+ ? table.primaryKey
87
+ : [table.primaryKey];
88
+ for (const pk of pkFields) {
89
+ const projected = Object.values(identity).some((f) => f.field === pk);
90
+ if (!projected) {
91
+ throw new Error(
92
+ `token ${tokenName}: identity must include the primary key column '${pk.name}' of table '${table.name}' (identity anchor — the user id is required)`,
93
+ );
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ /** Write back the DTO field name from the map key (same convention as
100
+ * buildMessage — TokenSchema is a field container, consumers rely on
101
+ * field.name). */
102
+ function writeBackNames(segments: Record<string, DtoField>[]): void {
103
+ for (const segment of segments) {
104
+ for (const [key, df] of Object.entries(segment)) df.name = key;
105
+ }
106
+ }
107
+
108
+ export function defineToken(options: {
109
+ name: string;
110
+ api: ProjectApiSchema;
111
+ app: FrontAppSchema;
112
+ identity: Record<string, DtoField>;
113
+ description?: string;
114
+ }): TokenSchema {
115
+ const { name, api, app, identity, description } = options;
116
+ if (!api.apps.includes(app)) {
117
+ throw new Error(`token ${name}: api '${api.name}' does not serve app '${app.name}'`);
118
+ }
119
+ validateIdentityFields(name, identity);
120
+ const security = builtInSecurity();
121
+ writeBackNames([security, identity]);
122
+ return {
123
+ type: 'token',
124
+ name,
125
+ description,
126
+ api,
127
+ app,
128
+ security,
129
+ identity,
130
+ };
131
+ }
132
+
133
+ /** Structural check — TokenSchema instances may come from a different module
134
+ * copy, so instanceof is unreliable. */
135
+ export function isTokenSchema(v: unknown): v is TokenSchema {
136
+ if (typeof v !== 'object' || v === null) return false;
137
+ const vv = v as Record<string, unknown>;
138
+ return vv.type === 'token' && typeof vv.name === 'string';
139
+ }