@pylonts/dsl 1.1.16 → 1.1.17
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/dist/convert.d.ts +4 -5
- package/dist/dto.d.ts +13 -0
- package/dist/dto.js +28 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mysql-driver.js +3 -3
- package/dist/service.js +3 -0
- package/dist/third-service.d.ts +58 -2
- package/dist/third-service.js +28 -0
- package/dist/token.d.ts +39 -0
- package/dist/token.js +96 -0
- package/dist/typebox-driver.d.ts +13 -0
- package/dist/typebox-driver.js +120 -5
- package/docs/curd.md +150 -146
- package/docs/gen-login.md +136 -0
- package/docs/third-service.md +201 -151
- package/docs/token-migration.md +60 -0
- package/docs/token.md +341 -327
- package/docs/wechat.md +235 -0
- package/package.json +2 -2
- package/src/convert.ts +9 -9
- package/src/dto.ts +364 -331
- package/src/index.ts +1 -0
- package/src/mysql-driver.ts +108 -108
- package/src/service.ts +5 -0
- package/src/third-service.ts +86 -2
- package/src/token.ts +139 -0
- package/src/typebox-driver.ts +128 -6
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';
|
package/src/mysql-driver.ts
CHANGED
|
@@ -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
|
|
52
|
-
//
|
|
53
|
-
if (
|
|
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
|
}
|
package/src/third-service.ts
CHANGED
|
@@ -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
|
+
}
|