@zhin.js/gateway 1.1.1 → 1.2.0
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/README.md +4 -4
- package/lib/config/configuration-chat-policy.js +2 -2
- package/lib/config/configuration-redaction.d.ts +2 -8
- package/lib/config/configuration-redaction.js +15 -68
- package/lib/config/console-schema.js +4 -3
- package/lib/config/environment-files.d.ts +1 -0
- package/lib/config/environment-files.js +4 -11
- package/lib/config/literal-secrets.d.ts +34 -0
- package/lib/config/literal-secrets.js +136 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +1 -0
- package/lib/database/config.js +31 -0
- package/lib/database/console-role.d.ts +35 -0
- package/lib/database/console-role.js +108 -0
- package/lib/database/gateway-store.d.ts +43 -0
- package/lib/database/gateway-store.js +90 -0
- package/lib/database/index.d.ts +2 -0
- package/lib/database/index.js +2 -0
- package/lib/database/project-database.d.ts +45 -0
- package/lib/database/project-database.js +212 -6
- package/lib/http/console-rpc.d.ts +9 -1
- package/lib/http/gateway-console.d.ts +15 -8
- package/lib/http/gateway-console.js +128 -45
- package/lib/http/gateway-events.d.ts +5 -0
- package/lib/http/gateway-events.js +16 -3
- package/lib/http/gateway-http-server.js +11 -7
- package/lib/http/plugin-switch-rpc.d.ts +19 -0
- package/lib/http/plugin-switch-rpc.js +218 -0
- package/lib/http/start-gateway.js +15 -0
- package/lib/iam/index.d.ts +4 -3
- package/lib/iam/index.js +4 -3
- package/lib/iam/install.d.ts +17 -11
- package/lib/iam/install.js +39 -18
- package/lib/iam/legacy-import.d.ts +151 -0
- package/lib/iam/legacy-import.js +600 -0
- package/lib/iam/local-admin.d.ts +2 -0
- package/lib/iam/local-admin.js +6 -1
- package/lib/iam/routes.js +10 -2
- package/lib/iam/rpc.js +1 -1
- package/lib/iam/service.d.ts +31 -0
- package/lib/iam/service.js +49 -4
- package/lib/iam/setup-service.d.ts +2 -0
- package/lib/iam/setup-service.js +11 -4
- package/lib/iam/tables.d.ts +6 -6
- package/lib/iam/tables.js +6 -8
- package/lib/local/handler.js +1 -1
- package/lib/local/protocol.d.ts +6 -0
- package/lib/local/protocol.js +6 -0
- package/lib/packages/authorization.d.ts +2 -1
- package/lib/packages/authorization.js +20 -1
- package/lib/packages/rpc.js +5 -0
- package/lib/packages/service.d.ts +18 -0
- package/lib/packages/service.js +66 -3
- package/lib/plugin-management/config-explanation.js +3 -3
- package/lib/plugin-management/workflow.d.ts +44 -0
- package/lib/plugin-management/workflow.js +109 -3
- package/package.json +7 -7
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { DatabaseHostModel } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import { type ProjectDatabaseTransaction } from './project-database.js';
|
|
3
|
+
/** Project-relative path of the gateway store. */
|
|
4
|
+
export declare const GATEWAY_STORE_FILE: string;
|
|
5
|
+
/** Key / value table of the gateway store (`schema_version`, `legacy_import`). */
|
|
6
|
+
export declare const GATEWAY_META_TABLE = "gateway_meta";
|
|
7
|
+
/** Schema version written to `gateway_meta.schema_version`. */
|
|
8
|
+
export declare const GATEWAY_STORE_SCHEMA_VERSION = 1;
|
|
9
|
+
export interface GatewayStore {
|
|
10
|
+
/** Absolute path of `gateway.db`. */
|
|
11
|
+
readonly filename: string;
|
|
12
|
+
/**
|
|
13
|
+
* Random id of this store (`gateway_meta.store_id`), also written to the marker in the project
|
|
14
|
+
* database (ADR 0059 §3 修订): a new or different gateway.db is noticed instead of reopening setup.
|
|
15
|
+
*/
|
|
16
|
+
readonly storeId: string;
|
|
17
|
+
readonly started: boolean;
|
|
18
|
+
readonly models: {
|
|
19
|
+
get(name: string): DatabaseHostModel | undefined;
|
|
20
|
+
};
|
|
21
|
+
/** JSON value of a `gateway_meta` key, or undefined. */
|
|
22
|
+
getMeta<T = unknown>(key: string, trx?: ProjectDatabaseTransaction): Promise<T | undefined>;
|
|
23
|
+
setMeta(key: string, value: unknown, trx?: ProjectDatabaseTransaction): Promise<void>;
|
|
24
|
+
deleteMeta(key: string, trx?: ProjectDatabaseTransaction): Promise<void>;
|
|
25
|
+
/** A write transaction (`BEGIN IMMEDIATE`: also the lock between the gateway and the CLI). */
|
|
26
|
+
transaction<T>(callback: (trx: ProjectDatabaseTransaction) => Promise<T>): Promise<T>;
|
|
27
|
+
query<T = unknown>(sql: string, params?: unknown[]): Promise<T>;
|
|
28
|
+
/** Rows in each of the access-control tables. */
|
|
29
|
+
counts(trx?: ProjectDatabaseTransaction): Promise<Readonly<Record<string, number>>>;
|
|
30
|
+
/** A consistent copy of `gateway.db` (0600). */
|
|
31
|
+
backupTo(destination: string): Promise<void>;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export interface OpenGatewayStoreOptions {
|
|
35
|
+
/** Overrides the file (tests). Default `<projectRoot>/.zhin/gateway.db`. */
|
|
36
|
+
readonly filename?: string;
|
|
37
|
+
/** `SQLITE_BUSY` async retry budget (default {@link GATEWAY_SQLITE_BUSY_RETRY_MS}). */
|
|
38
|
+
readonly busyRetryMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/** `<projectRoot>/.zhin/gateway.db`. */
|
|
41
|
+
export declare function gatewayStorePath(projectRoot: string): string;
|
|
42
|
+
/** Opens (creating if needed) the gateway store with its tables. */
|
|
43
|
+
export declare function openGatewayStore(projectRoot: string, options?: OpenGatewayStoreOptions): Promise<GatewayStore>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway's own store `.zhin/gateway.db` (ADR 0059 §1): the six Console access-control
|
|
3
|
+
* tables (`IAM_TABLES`) and `gateway_meta`, in SQLite whatever dialect the project database
|
|
4
|
+
* uses. Owner-only (0600, `-wal` / `-shm` included).
|
|
5
|
+
*
|
|
6
|
+
* Opened only by the gateway, by `zhin console …` while no gateway runs, and by a standalone
|
|
7
|
+
* instance (`zhin start --standalone`, development and tests only: there the instance process
|
|
8
|
+
* holds both connections, so there is no process isolation). A supervised instance never opens
|
|
9
|
+
* it, and its own SQLite connection refuses `ATTACH` (`denyAttach`).
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { defineConsoleIamTables, IAM_TABLES } from '../iam/tables.js';
|
|
15
|
+
import { GATEWAY_SQLITE_BUSY_RETRY_MS, ProjectDatabase } from './project-database.js';
|
|
16
|
+
/** Project-relative path of the gateway store. */
|
|
17
|
+
export const GATEWAY_STORE_FILE = join('.zhin', 'gateway.db');
|
|
18
|
+
/** Key / value table of the gateway store (`schema_version`, `legacy_import`). */
|
|
19
|
+
export const GATEWAY_META_TABLE = 'gateway_meta';
|
|
20
|
+
/** Schema version written to `gateway_meta.schema_version`. */
|
|
21
|
+
export const GATEWAY_STORE_SCHEMA_VERSION = 1;
|
|
22
|
+
const META_DEFINITION = Object.freeze({
|
|
23
|
+
key: { type: 'text', length: 64, primary: true, nullable: false },
|
|
24
|
+
value: { type: 'text', nullable: false },
|
|
25
|
+
});
|
|
26
|
+
/** `<projectRoot>/.zhin/gateway.db`. */
|
|
27
|
+
export function gatewayStorePath(projectRoot) {
|
|
28
|
+
return join(projectRoot, GATEWAY_STORE_FILE);
|
|
29
|
+
}
|
|
30
|
+
function restrict(file) {
|
|
31
|
+
if (process.platform === 'win32')
|
|
32
|
+
return;
|
|
33
|
+
if (existsSync(file))
|
|
34
|
+
chmodSync(file, 0o600);
|
|
35
|
+
}
|
|
36
|
+
/** Opens (creating if needed) the gateway store with its tables. */
|
|
37
|
+
export async function openGatewayStore(projectRoot, options = {}) {
|
|
38
|
+
const filename = options.filename ?? gatewayStorePath(projectRoot);
|
|
39
|
+
mkdirSync(dirname(filename), { recursive: true });
|
|
40
|
+
// Create the file owner-only before SQLite does (it would follow the umask); SQLite gives the
|
|
41
|
+
// -wal / -shm files the mode of the database file.
|
|
42
|
+
if (!existsSync(filename))
|
|
43
|
+
closeSync(openSync(filename, 'a', 0o600));
|
|
44
|
+
restrict(filename);
|
|
45
|
+
const database = new ProjectDatabase({ dialect: 'sqlite', filename, busyRetryMs: options.busyRetryMs ?? GATEWAY_SQLITE_BUSY_RETRY_MS });
|
|
46
|
+
defineConsoleIamTables(database);
|
|
47
|
+
database.define(GATEWAY_META_TABLE, META_DEFINITION);
|
|
48
|
+
await database.start();
|
|
49
|
+
for (const suffix of ['', '-wal', '-shm'])
|
|
50
|
+
restrict(`${filename}${suffix}`);
|
|
51
|
+
const run = (sql, params, trx) => (trx ? trx.query(sql, params) : database.query(sql, params));
|
|
52
|
+
let storeId = '';
|
|
53
|
+
const store = {
|
|
54
|
+
filename,
|
|
55
|
+
get storeId() { return storeId; },
|
|
56
|
+
get started() { return database.started; },
|
|
57
|
+
models: database.models,
|
|
58
|
+
async getMeta(key, trx) {
|
|
59
|
+
const rows = await run(`SELECT value FROM ${GATEWAY_META_TABLE} WHERE key = ?`, [key], trx);
|
|
60
|
+
return rows[0] ? JSON.parse(rows[0].value) : undefined;
|
|
61
|
+
},
|
|
62
|
+
async setMeta(key, value, trx) {
|
|
63
|
+
await run(`INSERT INTO ${GATEWAY_META_TABLE} (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [key, JSON.stringify(value)], trx);
|
|
64
|
+
},
|
|
65
|
+
async deleteMeta(key, trx) {
|
|
66
|
+
await run(`DELETE FROM ${GATEWAY_META_TABLE} WHERE key = ?`, [key], trx);
|
|
67
|
+
},
|
|
68
|
+
transaction: callback => database.transaction(callback),
|
|
69
|
+
query: (sql, params) => database.query(sql, params),
|
|
70
|
+
async counts(trx) {
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const table of Object.values(IAM_TABLES)) {
|
|
73
|
+
const [row] = await run(`SELECT count(*) AS n FROM "${table}"`, [], trx);
|
|
74
|
+
out[table] = Number(row?.n ?? 0);
|
|
75
|
+
}
|
|
76
|
+
return Object.freeze(out);
|
|
77
|
+
},
|
|
78
|
+
backupTo: destination => database.backupTo(destination),
|
|
79
|
+
close: () => database.stop(),
|
|
80
|
+
};
|
|
81
|
+
if (await store.getMeta('schema_version') === undefined)
|
|
82
|
+
await store.setMeta('schema_version', GATEWAY_STORE_SCHEMA_VERSION);
|
|
83
|
+
storeId = await store.getMeta('store_id') ?? '';
|
|
84
|
+
if (!storeId) {
|
|
85
|
+
// Insert-if-absent: two processes opening a new store agree on one id.
|
|
86
|
+
await database.query(`INSERT INTO ${GATEWAY_META_TABLE} (key, value) VALUES ('store_id', ?) ON CONFLICT(key) DO NOTHING`, [JSON.stringify(randomBytes(16).toString('hex'))]);
|
|
87
|
+
storeId = await store.getMeta('store_id') ?? '';
|
|
88
|
+
}
|
|
89
|
+
return Object.freeze(store);
|
|
90
|
+
}
|
package/lib/database/index.d.ts
CHANGED
package/lib/database/index.js
CHANGED
|
@@ -14,6 +14,21 @@ export declare const DATABASE_BUSY_RETRY_AFTER_SECONDS = 2;
|
|
|
14
14
|
export declare function isDatabaseBusyError(error: unknown): boolean;
|
|
15
15
|
/** `503 { code: 'database_busy' }` with `Retry-After`. */
|
|
16
16
|
export declare function databaseBusyResponse(extra?: Readonly<Record<string, unknown>>): Response;
|
|
17
|
+
/** One statement inside {@link ProjectDatabase.transaction}. */
|
|
18
|
+
export interface ProjectDatabaseTransaction {
|
|
19
|
+
query<T = unknown>(sql: string, params?: unknown[]): Promise<T>;
|
|
20
|
+
}
|
|
21
|
+
/** The gateway's marker in the project database. */
|
|
22
|
+
export interface StoreMarker {
|
|
23
|
+
readonly storeId: string;
|
|
24
|
+
readonly migratedAt: number;
|
|
25
|
+
}
|
|
26
|
+
/** Rows keyed by table name. */
|
|
27
|
+
export type TableRows = Readonly<Record<string, readonly Record<string, unknown>[]>>;
|
|
28
|
+
/** Rows read per batch by {@link ProjectDatabase.readSnapshot}. */
|
|
29
|
+
export declare const SNAPSHOT_BATCH_SIZE = 500;
|
|
30
|
+
/** A quoted table name for `dialect`. */
|
|
31
|
+
export declare function quoteTableName(dialect: string, name: string): string;
|
|
17
32
|
/** One database connection; tables are defined before {@link start}. */
|
|
18
33
|
export declare class ProjectDatabase {
|
|
19
34
|
#private;
|
|
@@ -26,6 +41,36 @@ export declare class ProjectDatabase {
|
|
|
26
41
|
readonly models: Readonly<{
|
|
27
42
|
get: (name: string) => DatabaseHostModel | undefined;
|
|
28
43
|
}>;
|
|
44
|
+
/** Whether raw SQL / transactions are available (SQLite, MySQL, PostgreSQL). */
|
|
45
|
+
get relational(): boolean;
|
|
46
|
+
/** One raw statement (gateway-internal: the gateway store, the legacy import and its revert). */
|
|
47
|
+
query<T = unknown>(sql: string, params?: unknown[]): Promise<T>;
|
|
48
|
+
/** A transaction (SQLite write transactions are `BEGIN IMMEDIATE`). */
|
|
49
|
+
transaction<T>(callback: (trx: ProjectDatabaseTransaction) => Promise<T>, options?: {
|
|
50
|
+
readOnly?: boolean;
|
|
51
|
+
repeatableRead?: boolean;
|
|
52
|
+
}): Promise<T>;
|
|
53
|
+
/** SQLite: a consistent copy of the file at `destination` (0600). */
|
|
54
|
+
backupTo(destination: string): Promise<void>;
|
|
55
|
+
/** Which of `names` exist as tables (collections), read from the system catalog without defining them. */
|
|
56
|
+
tableExists(names: readonly string[]): Promise<string[]>;
|
|
57
|
+
/**
|
|
58
|
+
* All rows of `tables`, read in batches of {@link SNAPSHOT_BATCH_SIZE} ordered by `id`. SQL
|
|
59
|
+
* dialects read inside one read-only repeatable-read transaction (one snapshot); MongoDB reads
|
|
60
|
+
* the collections one after another.
|
|
61
|
+
*/
|
|
62
|
+
readSnapshot(tables: readonly string[]): Promise<TableRows>;
|
|
63
|
+
/**
|
|
64
|
+
* Drops `tables` if they exist: in one transaction on SQLite / PostgreSQL, one by one on MySQL
|
|
65
|
+
* (DDL commits implicitly) and MongoDB, where an interrupted run resumes with the rest.
|
|
66
|
+
*/
|
|
67
|
+
dropTables(tables: readonly string[]): Promise<void>;
|
|
68
|
+
/** The gateway's marker (ADR 0059 §3 修订): which gateway.db holds the Console accounts, or undefined. */
|
|
69
|
+
readStoreMarker(): Promise<StoreMarker | undefined>;
|
|
70
|
+
/** Writes (replaces) the marker. */
|
|
71
|
+
writeStoreMarker(marker: StoreMarker): Promise<void>;
|
|
72
|
+
/** Removes the marker (revert to a version without it). */
|
|
73
|
+
deleteStoreMarker(): Promise<void>;
|
|
29
74
|
start(): Promise<void>;
|
|
30
75
|
stop(): Promise<void>;
|
|
31
76
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* A connection the gateway opens itself (ADR 0054 §7, ADR 0059): its own store
|
|
3
|
+
* `.zhin/gateway.db` (`openGatewayStore`), and — only while importing the Console tables an
|
|
4
|
+
* earlier version left in the project database, reverting that import, or testing a connection
|
|
5
|
+
* in the setup wizard — a short-lived connection to the project database. SQLite runs in WAL
|
|
6
|
+
* mode (`@zhin.js/database`), so several processes can hold a file open.
|
|
6
7
|
*/
|
|
7
|
-
import { isSqliteBusyError, Registry } from '@zhin.js/database';
|
|
8
|
+
import { GATEWAY_STORE_MARKER_TABLE, isSqliteBusyError, Registry } from '@zhin.js/database';
|
|
8
9
|
/**
|
|
9
10
|
* How long a gateway statement retries `SQLITE_BUSY` while the instance holds the write lock
|
|
10
11
|
* (`busyRetryMs` in `@zhin.js/database`). The retry backs off asynchronously, so the gateway's
|
|
@@ -40,6 +41,22 @@ export function databaseBusyResponse(extra = {}) {
|
|
|
40
41
|
},
|
|
41
42
|
});
|
|
42
43
|
}
|
|
44
|
+
function markerOf(row) {
|
|
45
|
+
return Object.freeze({ storeId: String(row.store_id ?? ''), migratedAt: Number(row.migrated_at ?? 0) });
|
|
46
|
+
}
|
|
47
|
+
const SQL_DIALECTS = new Set(['sqlite', 'mysql', 'pg']);
|
|
48
|
+
/** Rows read per batch by {@link ProjectDatabase.readSnapshot}. */
|
|
49
|
+
export const SNAPSHOT_BATCH_SIZE = 500;
|
|
50
|
+
const BACKTICK = String.fromCharCode(96);
|
|
51
|
+
/** A quoted table name for `dialect`. */
|
|
52
|
+
export function quoteTableName(dialect, name) {
|
|
53
|
+
return dialect === 'mysql'
|
|
54
|
+
? BACKTICK + name.split(BACKTICK).join(BACKTICK + BACKTICK) + BACKTICK
|
|
55
|
+
: `"${name.replace(/"/gu, '""')}"`;
|
|
56
|
+
}
|
|
57
|
+
function placeholder(dialect, index) {
|
|
58
|
+
return dialect === 'pg' ? `$${index}` : '?';
|
|
59
|
+
}
|
|
43
60
|
function wrapModel(model) {
|
|
44
61
|
return Object.freeze({
|
|
45
62
|
select: (...fields) => {
|
|
@@ -68,7 +85,8 @@ export class ProjectDatabase {
|
|
|
68
85
|
#started = false;
|
|
69
86
|
constructor(config) {
|
|
70
87
|
this.config = config;
|
|
71
|
-
|
|
88
|
+
// `console` (the restricted Console account, ADR 0059) and `denyAttach` (instance only) never reach the driver.
|
|
89
|
+
const { dialect, console: _console, denyAttach: _denyAttach, ...connection } = config;
|
|
72
90
|
// No synchronous busy_timeout: it would block the gateway's event loop (ADR 0054 §7).
|
|
73
91
|
const options = dialect === 'sqlite' && connection.busyRetryMs === undefined
|
|
74
92
|
? { ...connection, busyRetryMs: GATEWAY_SQLITE_BUSY_RETRY_MS }
|
|
@@ -107,6 +125,194 @@ export class ProjectDatabase {
|
|
|
107
125
|
return model;
|
|
108
126
|
},
|
|
109
127
|
});
|
|
128
|
+
/** Whether raw SQL / transactions are available (SQLite, MySQL, PostgreSQL). */
|
|
129
|
+
get relational() {
|
|
130
|
+
return SQL_DIALECTS.has(this.config.dialect);
|
|
131
|
+
}
|
|
132
|
+
#requireStarted() {
|
|
133
|
+
if (!this.#started)
|
|
134
|
+
throw new Error('Database is not connected');
|
|
135
|
+
}
|
|
136
|
+
#requireSql() {
|
|
137
|
+
this.#requireStarted();
|
|
138
|
+
if (!this.relational)
|
|
139
|
+
throw new Error(`Raw SQL is not supported by '${this.config.dialect}'`);
|
|
140
|
+
}
|
|
141
|
+
/** One raw statement (gateway-internal: the gateway store, the legacy import and its revert). */
|
|
142
|
+
async query(sql, params) {
|
|
143
|
+
this.#requireSql();
|
|
144
|
+
return this.#db.query(sql, params);
|
|
145
|
+
}
|
|
146
|
+
/** A transaction (SQLite write transactions are `BEGIN IMMEDIATE`). */
|
|
147
|
+
async transaction(callback, options) {
|
|
148
|
+
this.#requireSql();
|
|
149
|
+
return this.#db.transaction(trx => callback({ query: (sql, params) => trx.query(sql, params) }), {
|
|
150
|
+
...(options?.readOnly ? { readOnly: true } : {}),
|
|
151
|
+
...(options?.repeatableRead ? { isolationLevel: 'REPEATABLE_READ' } : {}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** SQLite: a consistent copy of the file at `destination` (0600). */
|
|
155
|
+
async backupTo(destination) {
|
|
156
|
+
this.#requireStarted();
|
|
157
|
+
if (this.config.dialect !== 'sqlite' || typeof this.#db.dialect?.backupTo !== 'function') {
|
|
158
|
+
throw new Error(`File backups need SQLite (current: ${this.config.dialect})`);
|
|
159
|
+
}
|
|
160
|
+
await this.#db.dialect.backupTo(destination);
|
|
161
|
+
}
|
|
162
|
+
/** Which of `names` exist as tables (collections), read from the system catalog without defining them. */
|
|
163
|
+
async tableExists(names) {
|
|
164
|
+
this.#requireStarted();
|
|
165
|
+
if (!names.length)
|
|
166
|
+
return [];
|
|
167
|
+
const { dialect } = this.config;
|
|
168
|
+
let present;
|
|
169
|
+
if (dialect === 'sqlite') {
|
|
170
|
+
const rows = await this.query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${names.map(() => '?').join(', ')})`, [...names]);
|
|
171
|
+
present = rows.map(row => row.name);
|
|
172
|
+
}
|
|
173
|
+
else if (dialect === 'pg') {
|
|
174
|
+
present = [];
|
|
175
|
+
for (const name of names) {
|
|
176
|
+
const [row] = await this.query('SELECT to_regclass($1) IS NOT NULL AS present', [quoteTableName('pg', name)]);
|
|
177
|
+
if (row?.present === true)
|
|
178
|
+
present.push(name);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else if (dialect === 'mysql') {
|
|
182
|
+
const rows = await this.query(`SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${names.map(() => '?').join(', ')})`, [...names]);
|
|
183
|
+
present = rows.map(row => String(row.name ?? row.TABLE_NAME));
|
|
184
|
+
}
|
|
185
|
+
else if (dialect === 'mongodb') {
|
|
186
|
+
const rows = await this.#db.query({ collection: names[0], operation: 'listCollections', filter: { name: { $in: [...names] } } });
|
|
187
|
+
present = rows.map(row => row.name);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
return names.filter(name => present.includes(name));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* All rows of `tables`, read in batches of {@link SNAPSHOT_BATCH_SIZE} ordered by `id`. SQL
|
|
196
|
+
* dialects read inside one read-only repeatable-read transaction (one snapshot); MongoDB reads
|
|
197
|
+
* the collections one after another.
|
|
198
|
+
*/
|
|
199
|
+
async readSnapshot(tables) {
|
|
200
|
+
this.#requireStarted();
|
|
201
|
+
const { dialect } = this.config;
|
|
202
|
+
const out = {};
|
|
203
|
+
if (dialect === 'mongodb') {
|
|
204
|
+
for (const table of tables) {
|
|
205
|
+
const rows = [];
|
|
206
|
+
let after;
|
|
207
|
+
for (;;) {
|
|
208
|
+
const batch = await this.#db.query({
|
|
209
|
+
collection: table,
|
|
210
|
+
operation: 'find',
|
|
211
|
+
filter: after === undefined ? {} : { id: { $gt: after } },
|
|
212
|
+
sort: { id: 1 },
|
|
213
|
+
limit: SNAPSHOT_BATCH_SIZE,
|
|
214
|
+
});
|
|
215
|
+
for (const document of batch) {
|
|
216
|
+
const { _id: _ignored, ...row } = document;
|
|
217
|
+
rows.push(row);
|
|
218
|
+
}
|
|
219
|
+
if (batch.length < SNAPSHOT_BATCH_SIZE)
|
|
220
|
+
break;
|
|
221
|
+
after = batch[batch.length - 1].id;
|
|
222
|
+
}
|
|
223
|
+
out[table] = rows;
|
|
224
|
+
}
|
|
225
|
+
return Object.freeze(out);
|
|
226
|
+
}
|
|
227
|
+
this.#requireSql();
|
|
228
|
+
await this.transaction(async (trx) => {
|
|
229
|
+
for (const table of tables) {
|
|
230
|
+
const rows = [];
|
|
231
|
+
let after;
|
|
232
|
+
for (;;) {
|
|
233
|
+
const where = after === undefined ? '' : ` WHERE id > ${placeholder(dialect, 1)}`;
|
|
234
|
+
const batch = await trx.query(`SELECT * FROM ${quoteTableName(dialect, table)}${where} ORDER BY id LIMIT ${SNAPSHOT_BATCH_SIZE}`, after === undefined ? [] : [after]);
|
|
235
|
+
rows.push(...batch.map(row => ({ ...row })));
|
|
236
|
+
if (batch.length < SNAPSHOT_BATCH_SIZE)
|
|
237
|
+
break;
|
|
238
|
+
after = batch[batch.length - 1].id;
|
|
239
|
+
}
|
|
240
|
+
out[table] = rows;
|
|
241
|
+
}
|
|
242
|
+
}, { readOnly: true, repeatableRead: dialect !== 'sqlite' });
|
|
243
|
+
return Object.freeze(out);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Drops `tables` if they exist: in one transaction on SQLite / PostgreSQL, one by one on MySQL
|
|
247
|
+
* (DDL commits implicitly) and MongoDB, where an interrupted run resumes with the rest.
|
|
248
|
+
*/
|
|
249
|
+
async dropTables(tables) {
|
|
250
|
+
this.#requireStarted();
|
|
251
|
+
const { dialect } = this.config;
|
|
252
|
+
if (dialect === 'mongodb') {
|
|
253
|
+
for (const table of await this.tableExists(tables)) {
|
|
254
|
+
await this.#db.query({ collection: table, operation: 'dropCollection', filter: {} });
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
this.#requireSql();
|
|
259
|
+
const statements = tables.map(table => `DROP TABLE IF EXISTS ${quoteTableName(dialect, table)}`);
|
|
260
|
+
if (dialect === 'mysql') {
|
|
261
|
+
for (const statement of statements)
|
|
262
|
+
await this.query(statement);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
await this.transaction(async (trx) => {
|
|
266
|
+
for (const statement of statements)
|
|
267
|
+
await trx.query(statement);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/** The gateway's marker (ADR 0059 §3 修订): which gateway.db holds the Console accounts, or undefined. */
|
|
271
|
+
async readStoreMarker() {
|
|
272
|
+
this.#requireStarted();
|
|
273
|
+
const { dialect } = this.config;
|
|
274
|
+
if (!(await this.tableExists([GATEWAY_STORE_MARKER_TABLE])).length)
|
|
275
|
+
return undefined;
|
|
276
|
+
if (dialect === 'mongodb') {
|
|
277
|
+
const [row] = await this.#db.query({ collection: GATEWAY_STORE_MARKER_TABLE, operation: 'find', filter: { id: 'gateway' }, limit: 1 });
|
|
278
|
+
return row ? markerOf(row) : undefined;
|
|
279
|
+
}
|
|
280
|
+
const [row] = await this.query(`SELECT store_id, migrated_at FROM ${quoteTableName(dialect, GATEWAY_STORE_MARKER_TABLE)} WHERE id = ${placeholder(dialect, 1)}`, ['gateway']);
|
|
281
|
+
return row ? markerOf(row) : undefined;
|
|
282
|
+
}
|
|
283
|
+
/** Writes (replaces) the marker. */
|
|
284
|
+
async writeStoreMarker(marker) {
|
|
285
|
+
this.#requireStarted();
|
|
286
|
+
const { dialect } = this.config;
|
|
287
|
+
if (dialect === 'mongodb') {
|
|
288
|
+
await this.#db.query({ collection: GATEWAY_STORE_MARKER_TABLE, operation: 'deleteMany', filter: { id: 'gateway' } });
|
|
289
|
+
await this.#db.query({ collection: GATEWAY_STORE_MARKER_TABLE, operation: 'insertOne', filter: {} }, [{ id: 'gateway', store_id: marker.storeId, migrated_at: marker.migratedAt }]);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
this.#requireSql();
|
|
293
|
+
const table = quoteTableName(dialect, GATEWAY_STORE_MARKER_TABLE);
|
|
294
|
+
await this.query(`CREATE TABLE IF NOT EXISTS ${table} (id VARCHAR(32) NOT NULL PRIMARY KEY, store_id VARCHAR(64) NOT NULL, migrated_at BIGINT NOT NULL)`);
|
|
295
|
+
if (dialect === 'pg') {
|
|
296
|
+
// The main account's default privileges would grant it to the restricted Console account
|
|
297
|
+
// (ADR 0059 §4): take that, and PUBLIC's, away at once. (MySQL grants that account the whole
|
|
298
|
+
// database; there only the text guard and the start-up probe keep it off this table.)
|
|
299
|
+
await this.query(`REVOKE ALL ON TABLE ${table} FROM PUBLIC`);
|
|
300
|
+
// Every role the default privileges (or anyone) granted it to, the console account included,
|
|
301
|
+
// whether or not this connection knows `database.console`.
|
|
302
|
+
const grantees = await this.query(`SELECT DISTINCT a.grantee::regrole::text AS grantee
|
|
303
|
+
FROM pg_class c, aclexplode(c.relacl) a WHERE c.oid = to_regclass($1) AND a.grantee <> c.relowner AND a.grantee <> 0`, [table]);
|
|
304
|
+
for (const { grantee } of grantees)
|
|
305
|
+
await this.query(`REVOKE ALL ON TABLE ${table} FROM ${grantee}`);
|
|
306
|
+
}
|
|
307
|
+
await this.transaction(async (trx) => {
|
|
308
|
+
await trx.query(`DELETE FROM ${table} WHERE id = ${placeholder(dialect, 1)}`, ['gateway']);
|
|
309
|
+
await trx.query(`INSERT INTO ${table} (id, store_id, migrated_at) VALUES (${placeholder(dialect, 1)}, ${placeholder(dialect, 2)}, ${placeholder(dialect, 3)})`, ['gateway', marker.storeId, marker.migratedAt]);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** Removes the marker (revert to a version without it). */
|
|
313
|
+
async deleteStoreMarker() {
|
|
314
|
+
await this.dropTables([GATEWAY_STORE_MARKER_TABLE]);
|
|
315
|
+
}
|
|
110
316
|
async start() {
|
|
111
317
|
if (this.#started)
|
|
112
318
|
return;
|
|
@@ -12,8 +12,16 @@ export declare const GATEWAY_RPC_PREFIX = "gateway.";
|
|
|
12
12
|
export interface GatewayConsoleRpc {
|
|
13
13
|
handle(message: Readonly<Record<string, unknown>>, context: RouteContext, tokenRegistry: TokenRegistry): Promise<Response>;
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Further gateway-owned RPC types (`iam.*`, `onboarding.update`, the global plugin switch), see
|
|
17
|
+
* `GatewayHttpServer.useConsoleRpc`.
|
|
18
|
+
*/
|
|
16
19
|
export interface GatewayRpcHandler extends GatewayConsoleRpc {
|
|
17
20
|
owns(type: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Narrows {@link owns} by payload: when present and false for the parsed message, the request
|
|
23
|
+
* is forwarded to the instance untouched (e.g. `feature.set-enabled` below the global level).
|
|
24
|
+
*/
|
|
25
|
+
claims?(message: Readonly<Record<string, unknown>>): boolean;
|
|
18
26
|
}
|
|
19
27
|
export declare function createGatewayConsoleRpc(dispatch: GatewayRpcDispatcher): GatewayConsoleRpc;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ConsoleAuditEntry } from '@zhin.js/host-http';
|
|
2
2
|
import { type ConsolePluginPermissionInfo } from '@zhin.js/console-protocol';
|
|
3
3
|
import { type ProjectDatabaseConfig } from '../database/config.js';
|
|
4
|
-
import {
|
|
4
|
+
import { type GatewayStore, type OpenGatewayStoreOptions } from '../database/gateway-store.js';
|
|
5
5
|
import { type LocalConsoleAdmin } from '../iam/local-admin.js';
|
|
6
6
|
import { ConsoleIamService } from '../iam/service.js';
|
|
7
7
|
import { ConsoleSetupService } from '../iam/setup-service.js';
|
|
@@ -26,26 +26,33 @@ export interface GatewayConsoleOptions {
|
|
|
26
26
|
readonly instance?: GatewayConsoleInstancePort;
|
|
27
27
|
/** Restarts the instance after the setup wizard switched databases (ADR 0054 §7). */
|
|
28
28
|
readonly restartInstance?: () => Promise<void>;
|
|
29
|
-
/** Overrides the project `database` block (tests). */
|
|
29
|
+
/** Overrides the project `database` block the old `console_*` tables are imported from (tests). */
|
|
30
30
|
readonly database?: ProjectDatabaseConfig;
|
|
31
|
+
/** Gateway store overrides (tests; default `.zhin/gateway.db`). */
|
|
32
|
+
readonly store?: OpenGatewayStoreOptions;
|
|
31
33
|
/** Overrides the project `console.terminal` block. */
|
|
32
34
|
readonly terminal?: unknown;
|
|
33
35
|
readonly onboarding?: OnboardingService;
|
|
34
|
-
/** Retry interval while the
|
|
36
|
+
/** Retry interval while gateway.db or the import of the old tables fails. Default 10 s. */
|
|
35
37
|
readonly retryMs?: number;
|
|
36
38
|
}
|
|
37
39
|
export interface GatewayConsole {
|
|
38
|
-
/** Settles once the first
|
|
40
|
+
/** Settles once the first open attempt finished (never rejects). */
|
|
39
41
|
readonly ready: Promise<void>;
|
|
40
|
-
/** The IAM service
|
|
42
|
+
/** The IAM service; fails closed (`unavailableReason()`) until gateway.db is open and imported. */
|
|
41
43
|
iam(): ConsoleIamService | undefined;
|
|
42
|
-
/** The gateway
|
|
43
|
-
database():
|
|
44
|
+
/** The gateway store `.zhin/gateway.db` (undefined before it opened). */
|
|
45
|
+
database(): GatewayStore | undefined;
|
|
44
46
|
readonly setup: ConsoleSetupService;
|
|
45
47
|
readonly onboarding: OnboardingService;
|
|
46
48
|
readonly terminal: ConsoleTerminalHandle;
|
|
47
|
-
/** Closes
|
|
49
|
+
/** Closes gateway.db and opens it again (and retries a pending import). */
|
|
48
50
|
reopenDatabase(): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Writes the store marker into the project database if it is missing (after the first owner was
|
|
53
|
+
* created and after the instance first reported ready: a first-run project file exists by then).
|
|
54
|
+
*/
|
|
55
|
+
ensureStoreMarker(): Promise<void>;
|
|
49
56
|
/** Socket-only `gateway.console.*` (`zhin console init` / `zhin console user` beside the gateway). */
|
|
50
57
|
readonly localAdmin: LocalConsoleAdmin;
|
|
51
58
|
close(): Promise<void>;
|