@zhin.js/gateway 1.1.2 → 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.
@@ -1,10 +1,11 @@
1
1
  /**
2
- * The gateway's own connection to the project database (ADR 0054 §7). It defines and serves only
3
- * the tables the gateway owns (`console_*`, ADR 0049); the instance opens the same database for
4
- * everything else. SQLite runs in WAL mode (`@zhin.js/database`), so both processes can hold the
5
- * file open.
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
- const { dialect, ...connection } = config;
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;
@@ -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 { ProjectDatabase } from '../database/project-database.js';
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 database cannot be opened. Default 10 s. */
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 database open attempt finished (never rejects). */
40
+ /** Settles once the first open attempt finished (never rejects). */
39
41
  readonly ready: Promise<void>;
40
- /** The IAM service bound to the open database; undefined when the dialect cannot hold accounts. */
42
+ /** The IAM service; fails closed (`unavailableReason()`) until gateway.db is open and imported. */
41
43
  iam(): ConsoleIamService | undefined;
42
- /** The gateway's database connection (undefined before the first open). */
43
- database(): ProjectDatabase | undefined;
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 the gateway's connection and opens the configured database again (setup wizard). */
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>;
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Console access control in the gateway (ADR 0054 §3 / §7, ADR 0049, ADR 0053 §4): the gateway
3
- * opens the project database itself, owns the `console_*` tables, authenticates sessions / API
4
- * tokens / the bootstrap token locally, and serves `/auth/*`, `iam.*`, `onboarding.update`, the
5
- * setup wizard, the terminal and the audit log — all without an instance.
2
+ * Console access control in the gateway (ADR 0054 §3 / §7, ADR 0049, ADR 0053 §4, ADR 0059): the
3
+ * gateway keeps the access-control tables in its own store `.zhin/gateway.db`, authenticates
4
+ * sessions / API tokens / the bootstrap token locally, and serves `/auth/*`, `iam.*`,
5
+ * `onboarding.update`, the setup wizard, the terminal and the audit log — all without an
6
+ * instance. It holds no connection to the project database: it connects only to import the
7
+ * tables an earlier version left there (and the setup wizard tests a connection).
6
8
  *
7
9
  * The instance contributes what only it knows over the bridge: the plugin permission catalog
8
10
  * (`catalog`) and its own audit entries (`audit`); the gateway tells it when a user's credentials
@@ -17,27 +19,39 @@ import { formatCompact, getLogger } from '@zhin.js/logger';
17
19
  import { ConsoleConfigurationStore } from '../config/configuration.js';
18
20
  import { readEnvFile, writeEnvFile } from '../config/environment-files.js';
19
21
  import { loadExpandedProjectConfig, resolveProjectDatabaseConfig } from '../database/config.js';
20
- import { ProjectDatabase } from '../database/project-database.js';
22
+ import { openGatewayStore } from '../database/gateway-store.js';
21
23
  import { projectEnvironment } from '../iam/install.js';
22
24
  import { createLocalConsoleAdmin } from '../iam/local-admin.js';
23
25
  import { registerConsoleAuthRoutes } from '../iam/routes.js';
24
26
  import { dispatchIamRpc, isIamRpc } from '../iam/rpc.js';
27
+ import { ensureGatewayStoreMarker, importLegacyIamTables, inspectLegacyIamImport, isTransientLegacyImportError, LegacyImportError, readLegacyImportRecord } from '../iam/legacy-import.js';
25
28
  import { ConsoleIamService } from '../iam/service.js';
26
29
  import { ConsoleSetupService } from '../iam/setup-service.js';
27
- import { defineConsoleIamTables } from '../iam/tables.js';
28
30
  import { OnboardingError } from '../onboarding/errors.js';
29
31
  import { OnboardingService } from '../onboarding/service.js';
30
32
  import { findConfigFile } from '../project/config-file.js';
31
33
  import { installHostConsoleTerminal } from '../terminal/host.js';
32
34
  const logger = getLogger('gateway');
33
35
  const DEFAULT_RETRY_MS = 10_000;
34
- /** A database that never starts: the IAM service then fails closed on its setup marker. */
36
+ /** Cap of the retry backoff while the project database stays unreachable. */
37
+ const MAX_RETRY_MS = 5 * 60_000;
38
+ /** A database that never starts: the IAM service then fails closed. */
35
39
  const UNAVAILABLE_DATABASE = Object.freeze({ started: false, models: Object.freeze({ get: () => undefined }) });
36
40
  export async function startGatewayConsole(options) {
37
41
  const { server, projectRoot } = options;
42
+ /** Why access control is closed (undefined once gateway.db is open and the import settled). */
43
+ let pending = 'Console 访问控制正在启动';
44
+ /** Category of `pending` shown to anonymous callers (never the detail). */
45
+ let pendingCode = 'starting';
46
+ /** Consecutive transient failures (backoff exponent). */
47
+ let failures = 0;
38
48
  const failClosed = () => ({
39
- service: new ConsoleIamService({ database: UNAVAILABLE_DATABASE, projectRoot }),
40
- supported: true,
49
+ service: new ConsoleIamService({
50
+ database: UNAVAILABLE_DATABASE,
51
+ projectRoot,
52
+ unavailable: () => pending ?? 'Console 访问控制正在重新连接',
53
+ unavailableCode: () => (pending === undefined ? 'starting' : pendingCode),
54
+ }),
41
55
  dispose: [],
42
56
  });
43
57
  const environment = options.environment ?? 'development';
@@ -52,12 +66,12 @@ export async function startGatewayConsole(options) {
52
66
  let closed = false;
53
67
  let retryTimer;
54
68
  let opening;
55
- const iam = () => (connection.supported ? connection.service : undefined);
69
+ const iam = () => connection.service;
56
70
  const disposers = [];
57
71
  // Level grants need the catalog of the instance's loaded plugins (kept across restarts).
58
72
  disposers.push(registry.usePluginPermissions(() => options.instance?.pluginPermissions?.() ?? []));
59
73
  const access = Object.freeze({
60
- initialized: () => connection.service?.initialized() ?? false,
74
+ initialized: () => connection.service.initialized(),
61
75
  authenticate: async (token, meta) => (await iam()?.authenticate(token, meta)) ?? null,
62
76
  audit: (entry) => { iam()?.audit(entry); },
63
77
  });
@@ -79,68 +93,134 @@ export async function startGatewayConsole(options) {
79
93
  }
80
94
  catch { /* bridge gone */ }
81
95
  };
96
+ const projectDatabase = async () => options.database
97
+ ?? resolveProjectDatabaseConfig(projectRoot, await loadExpandedProjectConfig(projectRoot, environment));
82
98
  async function connect() {
83
- let config;
99
+ let store;
84
100
  try {
85
- config = options.database ?? await resolveProjectDatabaseConfig(projectRoot, await loadExpandedProjectConfig(projectRoot, environment));
101
+ store = await openGatewayStore(projectRoot, options.store ?? {});
86
102
  }
87
103
  catch (error) {
88
- logger.warn(formatCompact({ op: 'gateway_database_config_invalid', error: errorText(error) }));
89
- connection = failClosed();
104
+ pending = `无法打开 .zhin/gateway.db:${errorText(error)}`;
105
+ pendingCode = 'store';
106
+ logger.warn(formatCompact({ op: 'gateway_store_open_failed', error: errorText(error) }));
107
+ scheduleRetry();
90
108
  return;
91
109
  }
92
- let database;
110
+ connection = { database: store, service: connection.service, dispose: [] };
111
+ // ADR 0059 §3: the old console_* tables of the project database come over before anything
112
+ // reads gateway.db; until then access control stays closed (an empty store is not "no owner").
93
113
  try {
94
- database = new ProjectDatabase(config);
114
+ const record = await importLegacyIamTables({
115
+ store,
116
+ projectRoot,
117
+ projectDatabase,
118
+ logger: { info: (message) => logger.info(message), warn: (message) => logger.warn(message) },
119
+ });
120
+ if (record.status === 'done' && record.changed) {
121
+ logger.warn(formatCompact({ op: 'gateway_legacy_import_changed', export: record.changedExport }));
122
+ }
95
123
  }
96
124
  catch (error) {
97
- logger.warn(formatCompact({ op: 'gateway_database_driver_failed', dialect: config.dialect, error: errorText(error) }));
98
- connection = failClosed();
99
- return;
100
- }
101
- const supported = defineConsoleIamTables(database);
102
- if (!supported) {
103
- logger.warn('console iam: the configured database cannot store Console users; run the setup wizard to choose sqlite/mysql/pg/mongodb');
104
- connection = { database, supported: false, dispose: [] };
125
+ const code = error instanceof LegacyImportError ? error.code : 'error';
126
+ const transient = isTransientLegacyImportError(error);
127
+ pending = code === 'store_lost' || code === 'store_mismatch'
128
+ ? errorText(error)
129
+ : transient
130
+ ? `正在等待项目数据库,以迁移旧版的 Console 账号表:${errorText(error)}`
131
+ : `迁移旧版 Console 账号表失败,需要处理后重启网关(zhin stop && zhin start):${errorText(error)}`;
132
+ pendingCode = code === 'store_lost' || code === 'store_mismatch' ? code : transient ? 'migration_pending' : 'migration_failed';
133
+ logger.warn(formatCompact({ op: 'gateway_legacy_import_failed', code, transient, error: errorText(error) }));
134
+ // Retrying cannot fix a conflict, a mismatch or a lost store: stay closed until the operator acts.
135
+ if (transient)
136
+ scheduleRetry();
105
137
  return;
106
138
  }
107
139
  const service = new ConsoleIamService({
108
- database,
140
+ database: store,
109
141
  projectRoot,
110
142
  onError: (error) => logger.warn(`console iam: ${errorText(error)}`),
111
143
  pluginPermissions: () => registry.pluginPermissions(),
144
+ onOwnerCreated: () => { void ensureStoreMarker(); },
112
145
  });
113
- const dispose = [service.onCredentialsRevoked(({ userId, reason }) => revoke(userId, reason))];
114
- connection = { database, service, supported: true, dispose };
115
146
  try {
116
- await database.start();
117
147
  await service.start();
118
- logger.info(formatCompact({ op: 'gateway_database_open', dialect: config.dialect }));
119
148
  }
120
149
  catch (error) {
121
- // Not ready: `/auth/*` answers 503 and the owner state comes from the setup marker.
122
- logger.warn(formatCompact({ op: 'gateway_database_open_failed', dialect: config.dialect, error: errorText(error) }));
150
+ pending = `Console 访问控制无法启动:${errorText(error)}`;
151
+ pendingCode = 'store';
152
+ logger.warn(formatCompact({ op: 'gateway_store_open_failed', error: errorText(error) }));
153
+ service.stop();
123
154
  scheduleRetry();
155
+ return;
124
156
  }
157
+ const dispose = [service.onCredentialsRevoked(({ userId, reason }) => revoke(userId, reason))];
158
+ connection = { database: store, service, dispose };
159
+ pending = undefined;
160
+ failures = 0;
161
+ logger.info(formatCompact({ op: 'gateway_store_open', file: store.filename }));
125
162
  }
163
+ /** Writes the project database's store marker if missing; a foreign marker closes the Console. */
164
+ async function ensureStoreMarker() {
165
+ const store = connection.database;
166
+ if (!store || pending !== undefined)
167
+ return;
168
+ try {
169
+ await ensureGatewayStoreMarker({ store, projectRoot, projectDatabase, logger: { warn: (message) => logger.warn(message) } });
170
+ }
171
+ catch (error) {
172
+ if (error instanceof LegacyImportError && (error.code === 'store_mismatch' || error.code === 'store_lost')) {
173
+ // The project database belongs to another gateway.db (found after the start, e.g. once the
174
+ // project database became reachable): the same fail-closed state as at connect — Console
175
+ // closed, no retry, until the operator acts (`zhin console init --reset-store`).
176
+ logger.warn(formatCompact({ op: 'gateway_legacy_import_failed', code: error.code, transient: false, error: errorText(error) }));
177
+ opening = (opening ?? Promise.resolve()).then(async () => {
178
+ const current = connection;
179
+ if (closed || current.database !== store)
180
+ return;
181
+ pending = errorText(error);
182
+ pendingCode = error.code;
183
+ const users = await current.service.listUsers().catch(() => []);
184
+ // gateway.db stays open (the local owner can inspect it, `--reset-store` can run).
185
+ connection = { database: store, service: failClosed().service, dispose: [] };
186
+ for (const dispose of current.dispose.splice(0))
187
+ dispose();
188
+ current.service.stop();
189
+ // Sessions and API tokens no longer authenticate (the service is closed); what is already
190
+ // open ends too: terminals and the instance's sockets of every user, as on a revocation.
191
+ for (const user of users)
192
+ revoke(user.id, error.code);
193
+ for (const session of terminal.sessions())
194
+ terminal.terminatePrincipal(session.principalId, 'revoked');
195
+ });
196
+ await opening;
197
+ return;
198
+ }
199
+ logger.warn(formatCompact({ op: 'gateway_store_marker_failed', error: errorText(error) }));
200
+ }
201
+ }
202
+ /** Transient failure: retry with a growing delay (retryMs, 2×, 4×, … at most MAX_RETRY_MS). */
126
203
  function scheduleRetry() {
127
204
  if (closed || retryTimer)
128
205
  return;
206
+ const delay = Math.min(retryMs * 2 ** failures, Math.max(retryMs, MAX_RETRY_MS));
207
+ failures += 1;
129
208
  retryTimer = setTimeout(() => {
130
209
  retryTimer = undefined;
131
210
  if (closed)
132
211
  return;
133
212
  void reopen();
134
- }, retryMs);
213
+ }, delay);
135
214
  retryTimer.unref?.();
136
215
  }
137
216
  async function disconnect() {
138
217
  const current = connection;
218
+ pending ??= 'Console 访问控制正在重新连接';
139
219
  connection = failClosed();
140
220
  for (const dispose of current.dispose.splice(0))
141
221
  dispose();
142
- current.service?.stop();
143
- await current.database?.stop().catch((error) => {
222
+ current.service.stop();
223
+ await current.database?.close().catch((error) => {
144
224
  logger.warn(formatCompact({ op: 'gateway_database_close_failed', error: errorText(error) }));
145
225
  });
146
226
  }
@@ -169,7 +249,7 @@ export async function startGatewayConsole(options) {
169
249
  });
170
250
  const onboarding = options.onboarding ?? new OnboardingService({
171
251
  projectRoot,
172
- hasOwner: () => connection.service?.initialized() ?? false,
252
+ hasOwner: () => connection.service.initialized(),
173
253
  });
174
254
  const restartInstance = () => {
175
255
  void options.restartInstance?.().catch((error) => {
@@ -179,12 +259,18 @@ export async function startGatewayConsole(options) {
179
259
  const localAdmin = createLocalConsoleAdmin({
180
260
  setup,
181
261
  iam,
182
- // ADR 0054 §7, as for the web wizard: the gateway uses the new database before it answers.
262
+ // The accounts stay in gateway.db (ADR 0059): only the instance uses the new database.
183
263
  switchDatabase: async () => {
184
- await reopen();
185
264
  restartInstance();
186
265
  return { restarting: options.restartInstance !== undefined };
187
266
  },
267
+ migration: async () => {
268
+ const store = connection.database;
269
+ if (!store)
270
+ return { pending };
271
+ const inspection = await inspectLegacyIamImport({ store, projectRoot, projectDatabase });
272
+ return { ...inspection, record: inspection.record ?? await readLegacyImportRecord(store), ...(pending !== undefined ? { pending, pendingCode } : {}) };
273
+ },
188
274
  });
189
275
  disposers.push(...registerConsoleAuthRoutes({
190
276
  http: server,
@@ -192,18 +278,14 @@ export async function startGatewayConsole(options) {
192
278
  iam,
193
279
  setup,
194
280
  onboarding: () => onboarding.status(),
195
- // ADR 0054 §7: close the gateway's connection, open the new database, restart the instance.
196
- requestRestart: () => {
197
- void reopen()
198
- .then(() => options.restartInstance?.())
199
- .catch((error) => logger.warn(formatCompact({ op: 'gateway_database_switch_failed', error: errorText(error) })));
200
- },
281
+ // The instance restarts on the new project database; the accounts stay in gateway.db (ADR 0059).
282
+ requestRestart: () => { restartInstance(); },
201
283
  }));
202
284
  // `/auth/*` belongs to the gateway alone: nothing under it is forwarded to the instance.
203
285
  disposers.push(server.route('ALL', `${server.apiBase}/auth/*`, () => jsonResponse(404, { success: false, code: 'not_found', error: 'Not found' }), { summary: 'Unknown /auth route', tags: ['auth'] }));
204
286
  disposers.push(server.useConsoleRpc(createGatewayAccountRpc({ iam, onboarding })));
205
287
  const ready = reopen().catch((error) => {
206
- logger.warn(formatCompact({ op: 'gateway_database_open_failed', error: errorText(error) }));
288
+ logger.warn(formatCompact({ op: 'gateway_store_open_failed', error: errorText(error) }));
207
289
  });
208
290
  let closeResult;
209
291
  return Object.freeze({
@@ -214,6 +296,7 @@ export async function startGatewayConsole(options) {
214
296
  onboarding,
215
297
  terminal,
216
298
  reopenDatabase: reopen,
299
+ ensureStoreMarker,
217
300
  localAdmin,
218
301
  close() {
219
302
  closeResult ??= (async () => {
@@ -197,7 +197,11 @@ function disabledPlugins(document) {
197
197
  const list = document?.disable_plugins;
198
198
  return Object.freeze(Array.isArray(list) ? list.filter((item) => typeof item === 'string') : []);
199
199
  }
200
- /** The global plugin switch's warnings about the plugin's own overrides (as the instance computed them). */
200
+ /**
201
+ * The global plugin switch's warnings about a legacy `overrides` block still in the file (as the
202
+ * instance computed them). Since ADR 0057 §2 adapter / account overrides live in the instance's
203
+ * database, which the gateway does not read, so this only reports a block waiting for its import.
204
+ */
201
205
  function overrideWarnings(document, plugin) {
202
206
  const block = document[plugin];
203
207
  const overrides = block && typeof block === 'object' && !Array.isArray(block) ? block[FEATURE_OVERRIDES_KEY] : undefined;
@@ -141,6 +141,15 @@ export async function startGateway(options) {
141
141
  },
142
142
  });
143
143
  await settleWithin(gatewayConsole.ready, options.databaseWaitMs ?? 10_000);
144
+ // ADR 0059 §3: once the instance runs, a first-run SQLite project file exists; make sure the
145
+ // store marker is there (normally written at connect time already).
146
+ let markerChecked = false;
147
+ supervisor.on('state', (status) => {
148
+ if (markerChecked || status.state !== 'running')
149
+ return;
150
+ markerChecked = true;
151
+ void gatewayConsole?.ensureStoreMarker();
152
+ });
144
153
  }
145
154
  assertNotOpenOnNetwork(http, server.tokenRegistry);
146
155
  // Settings for the first instance start, read before listening so it starts right away.
@@ -1,7 +1,8 @@
1
1
  export { installConsoleIam, projectEnvironment, type ConsoleIamInstallation, type InstallConsoleIamOptions } from './install.js';
2
- export { accountInputProblem, ConsoleIamService, IamError, type BindingInput, type ConsoleAuditView, type ConsoleBindingView, type ConsoleCredentialRevocation, type ConsoleCredentialRevocationReason, type ConsoleIamServiceOptions, type ConsoleRoleView, type ConsoleTokenView, type ConsoleUserView, type IamDatabase, type IamErrorCode, } from './service.js';
3
- export { ConsoleSetupService, SETUP_NO_MIGRATION_NOTICE, upsertDotenv, type ConsoleSetupServiceOptions, type SetupConfigPort, type SetupDatabaseField, type SetupDatabaseInput, type SetupDatabaseSummary, type SetupDriver, type SetupEnvFilePort, type SetupStatus, } from './setup-service.js';
4
- export { defineConsoleIamTables, IAM_DIALECTS, IAM_TABLE_DEFINITIONS, IAM_TABLES } from './tables.js';
2
+ export { accountInputProblem, IAM_UNAVAILABLE_PUBLIC_MESSAGE, ConsoleIamService, IamError, type BindingInput, type ConsoleAuditView, type ConsoleBindingView, type ConsoleCredentialRevocation, type ConsoleCredentialRevocationReason, type ConsoleIamServiceOptions, type ConsoleRoleView, type ConsoleTokenView, type ConsoleUserView, type IamDatabase, type IamErrorCode, } from './service.js';
3
+ export { ConsoleSetupService, SETUP_CONSOLE_ROLE_NOTICE, SETUP_NO_MIGRATION_NOTICE, upsertDotenv, type ConsoleSetupServiceOptions, type SetupConfigPort, type SetupDatabaseField, type SetupDatabaseInput, type SetupDatabaseSummary, type SetupDriver, type SetupEnvFilePort, type SetupStatus, } from './setup-service.js';
4
+ export { defineConsoleIamTables, IAM_TABLE_DEFINITIONS, IAM_TABLES, type IamTableHost } from './tables.js';
5
+ export { importLegacyIamTables, ensureGatewayStoreMarker, isTransientLegacyImportError, LEGACY_IMPORT_FAILURE_KEY, resetGatewayStore, type LegacyImportFailure, inspectLegacyIamImport, LEGACY_IMPORT_KEY, LegacyImportError, legacyImportSettled, readLegacyImportRecord, revertLegacyIamImport, tableChecksum, type LegacyIamSource, type LegacyImportErrorCode, type LegacyImportInspection, type LegacyImportOptions, type LegacyImportRecord, type LegacySourceSummary, type RevertLegacyImportResult, } from './legacy-import.js';
5
6
  export { dispatchIamRpc, isIamRpc } from './rpc.js';
6
7
  export { registerConsoleAuthRoutes } from './routes.js';
7
8
  export { LoginRateLimiter } from './rate-limit.js';
package/lib/iam/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export { installConsoleIam, projectEnvironment } from './install.js';
2
- export { accountInputProblem, ConsoleIamService, IamError, } from './service.js';
3
- export { ConsoleSetupService, SETUP_NO_MIGRATION_NOTICE, upsertDotenv, } from './setup-service.js';
4
- export { defineConsoleIamTables, IAM_DIALECTS, IAM_TABLE_DEFINITIONS, IAM_TABLES } from './tables.js';
2
+ export { accountInputProblem, IAM_UNAVAILABLE_PUBLIC_MESSAGE, ConsoleIamService, IamError, } from './service.js';
3
+ export { ConsoleSetupService, SETUP_CONSOLE_ROLE_NOTICE, SETUP_NO_MIGRATION_NOTICE, upsertDotenv, } from './setup-service.js';
4
+ export { defineConsoleIamTables, IAM_TABLE_DEFINITIONS, IAM_TABLES } from './tables.js';
5
+ export { importLegacyIamTables, ensureGatewayStoreMarker, isTransientLegacyImportError, LEGACY_IMPORT_FAILURE_KEY, resetGatewayStore, inspectLegacyIamImport, LEGACY_IMPORT_KEY, LegacyImportError, legacyImportSettled, readLegacyImportRecord, revertLegacyIamImport, tableChecksum, } from './legacy-import.js';
5
6
  export { dispatchIamRpc, isIamRpc } from './rpc.js';
6
7
  export { registerConsoleAuthRoutes } from './routes.js';
7
8
  export { LoginRateLimiter } from './rate-limit.js';