@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.
- package/README.md +4 -4
- 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/gateway-console.d.ts +15 -8
- package/lib/http/gateway-console.js +128 -45
- package/lib/http/plugin-switch-rpc.js +5 -1
- package/lib/http/start-gateway.js +9 -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/protocol.d.ts +6 -0
- package/lib/local/protocol.js +6 -0
- package/package.json +7 -7
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importing the Console access-control tables an earlier version kept in the project database
|
|
3
|
+
* into `.zhin/gateway.db` (ADR 0059 §3), and putting them back (`zhin console migrate --revert`,
|
|
4
|
+
* required before a downgrade). One function serves the gateway (while it connects), `zhin console
|
|
5
|
+
* …` while no gateway runs, and a standalone instance.
|
|
6
|
+
*
|
|
7
|
+
* `gateway_meta.legacy_import` records the state: absent (not decided yet) → `fresh` (nothing to
|
|
8
|
+
* import) | `copied` (rows are in gateway.db, old tables not dropped yet) → `done`. Until the
|
|
9
|
+
* state is `fresh` or `done`, Console access control stays closed: an empty gateway.db must never
|
|
10
|
+
* be taken for "no owner yet".
|
|
11
|
+
*/
|
|
12
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
13
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
15
|
+
import { ProjectDatabase, quoteTableName, } from '../database/project-database.js';
|
|
16
|
+
import { defineConsoleIamTables, IAM_TABLE_DEFINITIONS, IAM_TABLES } from './tables.js';
|
|
17
|
+
export const LEGACY_IMPORT_KEY = 'legacy_import';
|
|
18
|
+
const TABLES = Object.freeze(Object.values(IAM_TABLES));
|
|
19
|
+
/** Dialects that may hold the old tables (redis / memory never could). */
|
|
20
|
+
const SOURCE_DIALECTS = new Set(['sqlite', 'mysql', 'pg', 'mongodb']);
|
|
21
|
+
const INSERT_BATCH = 500;
|
|
22
|
+
export class LegacyImportError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
constructor(code, message, options) {
|
|
25
|
+
super(message, options);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.name = 'LegacyImportError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Whether retrying later may help (the gateway backs off); otherwise the operator must act. */
|
|
31
|
+
export function isTransientLegacyImportError(error) {
|
|
32
|
+
return !(error instanceof LegacyImportError) || error.code === 'unavailable';
|
|
33
|
+
}
|
|
34
|
+
/** `gateway_meta` key of the last non-transient failure (for `zhin doctor`; cleared on success). */
|
|
35
|
+
export const LEGACY_IMPORT_FAILURE_KEY = 'legacy_import_failure';
|
|
36
|
+
/** A `LegacyImportError` for whatever went wrong while copying / dropping. */
|
|
37
|
+
function classify(error) {
|
|
38
|
+
if (error instanceof LegacyImportError)
|
|
39
|
+
return error;
|
|
40
|
+
// Only what this module decided is permanent (conflict, mismatch, invalid rows, store_lost /
|
|
41
|
+
// store_mismatch). Any driver error — connection terminated, statement / lock timeout,
|
|
42
|
+
// serialization failure, deadlock, too many connections, a network error — may pass: retried.
|
|
43
|
+
return new LegacyImportError('unavailable', `项目数据库暂时不可用:${errorText(error)}`, { cause: error });
|
|
44
|
+
}
|
|
45
|
+
export async function readLegacyImportRecord(store, trx) {
|
|
46
|
+
return store.getMeta(LEGACY_IMPORT_KEY, trx);
|
|
47
|
+
}
|
|
48
|
+
/** True when the record lets Console access control open. */
|
|
49
|
+
export function legacyImportSettled(record) {
|
|
50
|
+
return record?.status === 'fresh' || record?.status === 'done';
|
|
51
|
+
}
|
|
52
|
+
function summarize(projectRoot, config) {
|
|
53
|
+
const value = config;
|
|
54
|
+
if (config.dialect === 'sqlite') {
|
|
55
|
+
const filename = String(value.filename ?? '');
|
|
56
|
+
return Object.freeze({ dialect: 'sqlite', location: isAbsolute(filename) ? relative(projectRoot, filename) || filename : filename });
|
|
57
|
+
}
|
|
58
|
+
if (config.dialect === 'mongodb') {
|
|
59
|
+
const url = typeof value.url === 'string' ? value.url.replace(/\/\/[^@/]*@/u, '//') : '';
|
|
60
|
+
return Object.freeze({ dialect: 'mongodb', location: `${url}/${String(value.dbName ?? '')}` });
|
|
61
|
+
}
|
|
62
|
+
const host = value.host ?? (typeof value.connectionString === 'string' || typeof value.uri === 'string' ? '(connection string)' : 'localhost');
|
|
63
|
+
return Object.freeze({ dialect: config.dialect, location: `${String(host)}${value.port ? `:${String(value.port)}` : ''}/${String(value.database ?? '')}` });
|
|
64
|
+
}
|
|
65
|
+
function errorText(error) {
|
|
66
|
+
return error instanceof Error ? error.message : String(error);
|
|
67
|
+
}
|
|
68
|
+
const columnType = (table, column) => {
|
|
69
|
+
const definition = (IAM_TABLE_DEFINITIONS[table] ?? {})[column];
|
|
70
|
+
return definition?.type;
|
|
71
|
+
};
|
|
72
|
+
/** A cell as the checksum sees it: integers as decimal strings, text as strings, null kept. */
|
|
73
|
+
function canonicalCell(table, column, value) {
|
|
74
|
+
if (value === null || value === undefined)
|
|
75
|
+
return null;
|
|
76
|
+
const type = columnType(table, column);
|
|
77
|
+
if (type === 'integer' || type === 'bigint') {
|
|
78
|
+
if (typeof value === 'boolean')
|
|
79
|
+
return value ? '1' : '0';
|
|
80
|
+
const text = typeof value === 'bigint' ? value.toString() : String(value).trim();
|
|
81
|
+
if (/^-?\d+$/u.test(text))
|
|
82
|
+
return BigInt(text).toString();
|
|
83
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
84
|
+
return String(value);
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === 'string')
|
|
88
|
+
return value;
|
|
89
|
+
if (typeof value === 'bigint')
|
|
90
|
+
return value.toString();
|
|
91
|
+
if (value instanceof Date)
|
|
92
|
+
return value.toISOString();
|
|
93
|
+
if (typeof value === 'object')
|
|
94
|
+
return JSON.stringify(value);
|
|
95
|
+
return String(value);
|
|
96
|
+
}
|
|
97
|
+
/** A cell as written to the target (integers as numbers, or bigint past 2^53). */
|
|
98
|
+
function targetCell(table, column, value) {
|
|
99
|
+
const canonical = canonicalCell(table, column, value);
|
|
100
|
+
if (canonical === null)
|
|
101
|
+
return null;
|
|
102
|
+
const type = columnType(table, column);
|
|
103
|
+
if ((type === 'integer' || type === 'bigint') && /^-?\d+$/u.test(canonical)) {
|
|
104
|
+
const number = Number(canonical);
|
|
105
|
+
return Number.isSafeInteger(number) ? number : BigInt(canonical);
|
|
106
|
+
}
|
|
107
|
+
return canonical;
|
|
108
|
+
}
|
|
109
|
+
/** Target columns the source rows carry (`id` required). */
|
|
110
|
+
function sharedColumns(table, rows) {
|
|
111
|
+
const present = new Set(rows.flatMap(row => Object.keys(row)));
|
|
112
|
+
return Object.keys(IAM_TABLE_DEFINITIONS[table] ?? {}).filter(column => present.has(column));
|
|
113
|
+
}
|
|
114
|
+
function canonicalRows(table, rows, columns) {
|
|
115
|
+
return rows.map(row => columns.map(column => canonicalCell(table, column, row[column])))
|
|
116
|
+
.sort((left, right) => {
|
|
117
|
+
const a = String(left[columns.indexOf('id')] ?? '');
|
|
118
|
+
const b = String(right[columns.indexOf('id')] ?? '');
|
|
119
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* SHA-256 of the rows ordered by `id` (code-unit order), as canonical JSON of `[columns, rows]`
|
|
124
|
+
* with integers as decimal strings and null kept: the same rows read from any dialect hash equal.
|
|
125
|
+
*/
|
|
126
|
+
export function tableChecksum(table, rows, columns = sharedColumns(table, rows)) {
|
|
127
|
+
const sorted = [...columns].sort();
|
|
128
|
+
return createHash('sha256').update(JSON.stringify([sorted, canonicalRows(table, rows, sorted)])).digest('hex');
|
|
129
|
+
}
|
|
130
|
+
function jsonReplacer(_key, value) {
|
|
131
|
+
return typeof value === 'bigint' ? value.toString() : value;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The backup directory of a legacy snapshot, named after its content: a retried import of the
|
|
135
|
+
* same rows reuses it instead of writing another full copy every retry.
|
|
136
|
+
*/
|
|
137
|
+
function snapshotBackupDirectory(projectRoot, rows) {
|
|
138
|
+
const digest = createHash('sha256').update(JSON.stringify(rows, jsonReplacer)).digest('hex').slice(0, 16);
|
|
139
|
+
const directory = join('.zhin', 'backups', `gateway-db-legacy-${digest}`);
|
|
140
|
+
const existing = existsSync(join(projectRoot, directory, 'console-tables.json'));
|
|
141
|
+
if (!existing)
|
|
142
|
+
mkdirSync(join(projectRoot, directory), { recursive: true, mode: 0o700 });
|
|
143
|
+
return { directory, existing };
|
|
144
|
+
}
|
|
145
|
+
function backupDirectory(projectRoot, now) {
|
|
146
|
+
const stamp = new Date(now).toISOString().replace(/[:.]/gu, '-');
|
|
147
|
+
let directory = join('.zhin', 'backups', `gateway-db-${stamp}`);
|
|
148
|
+
for (let suffix = 1; existsSync(join(projectRoot, directory)); suffix += 1)
|
|
149
|
+
directory = join('.zhin', 'backups', `gateway-db-${stamp}-${suffix}`);
|
|
150
|
+
mkdirSync(join(projectRoot, directory), { recursive: true, mode: 0o700 });
|
|
151
|
+
return directory;
|
|
152
|
+
}
|
|
153
|
+
/** Writes `rows` as JSON (owner-only, never overwriting); returns the project-relative path. */
|
|
154
|
+
function exportRows(projectRoot, directory, file, source, rows, now) {
|
|
155
|
+
const path = join(directory, file);
|
|
156
|
+
writeFileSync(join(projectRoot, path), `${JSON.stringify({ exportedAt: new Date(now).toISOString(), source, tables: rows }, jsonReplacer, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
157
|
+
return path;
|
|
158
|
+
}
|
|
159
|
+
function defaultSource(config) {
|
|
160
|
+
return new ProjectDatabase(config);
|
|
161
|
+
}
|
|
162
|
+
async function withSource(options, config, use) {
|
|
163
|
+
let source;
|
|
164
|
+
try {
|
|
165
|
+
source = (options.openSource ?? defaultSource)(config);
|
|
166
|
+
await source.start();
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
throw new LegacyImportError('unavailable', `无法连接项目数据库(${config.dialect}):${errorText(error)}`, { cause: error });
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return await use(source);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
await source.stop().catch(() => undefined);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function insertRows(trx, dialect, table, columns, rows) {
|
|
179
|
+
const quoted = columns.map(column => quoteTableName(dialect, column)).join(', ');
|
|
180
|
+
for (let offset = 0; offset < rows.length; offset += INSERT_BATCH) {
|
|
181
|
+
for (const row of rows.slice(offset, offset + INSERT_BATCH)) {
|
|
182
|
+
const values = columns.map(column => targetCell(table, column, row[column]));
|
|
183
|
+
const placeholders = columns.map((_, index) => (dialect === 'pg' ? `$${index + 1}` : '?')).join(', ');
|
|
184
|
+
await trx.query(`INSERT INTO ${quoteTableName(dialect, table)} (${quoted}) VALUES (${placeholders})`, values);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function readStoreTable(trx, table, columns) {
|
|
189
|
+
if (!columns.length)
|
|
190
|
+
return [];
|
|
191
|
+
return trx.query(`SELECT ${columns.map(column => quoteTableName('sqlite', column)).join(', ')} FROM ${quoteTableName('sqlite', table)}`);
|
|
192
|
+
}
|
|
193
|
+
/** Dialects the marker can live in (a table, or a MongoDB collection). */
|
|
194
|
+
function markerCapable(config) {
|
|
195
|
+
if (!SOURCE_DIALECTS.has(config.dialect))
|
|
196
|
+
return false;
|
|
197
|
+
const filename = config.filename;
|
|
198
|
+
// A SQLite project file that does not exist yet is created (directory included, as the instance
|
|
199
|
+
// would) for the marker: the instance creates it only after the gateway's first connect, and a
|
|
200
|
+
// first run without a marker would let a later loss of gateway.db reopen setup.
|
|
201
|
+
return !(config.dialect === 'sqlite' && (filename === ':memory:' || filename === '' || (typeof filename === 'string' && filename.startsWith('file::memory:'))));
|
|
202
|
+
}
|
|
203
|
+
function storeLostMessage(marker, storeId) {
|
|
204
|
+
return `项目数据库记录 Console 账号存在另一个 gateway.db(store ${marker.storeId},${new Date(marker.migratedAt).toISOString()} 迁入),当前 .zhin/gateway.db(store ${storeId})是新的或不同的:`
|
|
205
|
+
+ '为防止重新打开初始化(初始化令牌重新生效),Console 访问控制保持关闭。请从备份恢复 .zhin/gateway.db;确认要重新开始时运行 zhin console init --reset-store。';
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Brings `gateway_meta.legacy_import` to `fresh` or `done` (or throws). Runs under the gateway
|
|
209
|
+
* store's write lock (`BEGIN IMMEDIATE`), so the gateway and the CLI never import twice. Once
|
|
210
|
+
* settled, it writes the marker (`zhin_gateway_store`) into the project database: a later empty or
|
|
211
|
+
* foreign gateway.db is refused (`store_lost` / `store_mismatch`) instead of reopening setup.
|
|
212
|
+
* Non-transient failures are recorded in `gateway_meta.legacy_import_failure` for `zhin doctor`.
|
|
213
|
+
*/
|
|
214
|
+
export async function importLegacyIamTables(options) {
|
|
215
|
+
try {
|
|
216
|
+
const record = await importOnce(options);
|
|
217
|
+
await options.store.deleteMeta(LEGACY_IMPORT_FAILURE_KEY).catch(() => undefined);
|
|
218
|
+
return record;
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
const failure = classify(error);
|
|
222
|
+
if (!isTransientLegacyImportError(failure)) {
|
|
223
|
+
const value = { code: failure.code, message: failure.message, at: (options.now ?? Date.now)() };
|
|
224
|
+
await options.store.setMeta(LEGACY_IMPORT_FAILURE_KEY, value).catch(() => undefined);
|
|
225
|
+
}
|
|
226
|
+
throw failure;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function importOnce(options) {
|
|
230
|
+
const now = options.now ?? Date.now;
|
|
231
|
+
const { store, projectRoot } = options;
|
|
232
|
+
const current = await readLegacyImportRecord(store);
|
|
233
|
+
if (legacyImportSettled(current)) {
|
|
234
|
+
await verifyStoreMarker(options, now);
|
|
235
|
+
return current;
|
|
236
|
+
}
|
|
237
|
+
const copied = current?.status === 'copied' ? current : await copyLegacyTables(now);
|
|
238
|
+
if (copied.status === 'copied') {
|
|
239
|
+
const done = await dropLegacyTables(options, now);
|
|
240
|
+
await verifyStoreMarker(options, now);
|
|
241
|
+
return done;
|
|
242
|
+
}
|
|
243
|
+
await verifyStoreMarker(options, now);
|
|
244
|
+
return copied;
|
|
245
|
+
async function copyLegacyTables(clock) {
|
|
246
|
+
let config;
|
|
247
|
+
try {
|
|
248
|
+
config = await options.projectDatabase();
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
throw new LegacyImportError('unavailable', `无法读取项目数据库配置:${errorText(error)}`, { cause: error });
|
|
252
|
+
}
|
|
253
|
+
const source = summarize(projectRoot, config);
|
|
254
|
+
return store.transaction(async (trx) => {
|
|
255
|
+
const record = await readLegacyImportRecord(store, trx);
|
|
256
|
+
if (record)
|
|
257
|
+
return record;
|
|
258
|
+
const counts = await store.counts(trx);
|
|
259
|
+
if (Object.values(counts).some(count => count > 0)) {
|
|
260
|
+
throw new LegacyImportError('conflict', 'gateway.db 已有 Console 数据却没有迁移记录;不合并,请检查 .zhin/gateway.db(或删除它后重新迁移)');
|
|
261
|
+
}
|
|
262
|
+
const fresh = async (reason) => {
|
|
263
|
+
const value = Object.freeze({ status: 'fresh', at: clock(), source, reason });
|
|
264
|
+
await store.setMeta(LEGACY_IMPORT_KEY, value, trx);
|
|
265
|
+
return value;
|
|
266
|
+
};
|
|
267
|
+
if (!SOURCE_DIALECTS.has(config.dialect))
|
|
268
|
+
return fresh('non-relational');
|
|
269
|
+
const filename = config.filename;
|
|
270
|
+
// Never create the project SQLite file just to look into it.
|
|
271
|
+
if (config.dialect === 'sqlite' && typeof filename === 'string' && filename !== ':memory:' && !existsSync(filename))
|
|
272
|
+
return fresh('no-database-file');
|
|
273
|
+
return withSource(options, config, async (legacy) => {
|
|
274
|
+
// An empty gateway.db beside a project database that already handed its accounts to a
|
|
275
|
+
// gateway.db: the store was lost. Never treat that as "no owner yet".
|
|
276
|
+
const marker = await legacy.readStoreMarker?.();
|
|
277
|
+
if (marker)
|
|
278
|
+
throw new LegacyImportError(marker.storeId === store.storeId ? 'store_mismatch' : 'store_lost', storeLostMessage(marker, store.storeId));
|
|
279
|
+
const present = await legacy.tableExists(TABLES);
|
|
280
|
+
if (!present.length)
|
|
281
|
+
return fresh('no-tables');
|
|
282
|
+
const rows = await legacy.readSnapshot(present);
|
|
283
|
+
const at = clock();
|
|
284
|
+
const { directory: backup, existing } = snapshotBackupDirectory(projectRoot, rows);
|
|
285
|
+
if (!existing)
|
|
286
|
+
exportRows(projectRoot, backup, 'console-tables.json', source, rows, at);
|
|
287
|
+
const countsOut = {};
|
|
288
|
+
const checksums = {};
|
|
289
|
+
const columnsOut = {};
|
|
290
|
+
for (const table of present) {
|
|
291
|
+
const tableRows = rows[table] ?? [];
|
|
292
|
+
const columns = sharedColumns(table, tableRows);
|
|
293
|
+
if (tableRows.length && !columns.includes('id'))
|
|
294
|
+
throw new LegacyImportError('invalid', `${table} 没有 id 列,无法迁移`);
|
|
295
|
+
await insertRows(trx, 'sqlite', table, columns, tableRows);
|
|
296
|
+
const expected = tableChecksum(table, tableRows, columns);
|
|
297
|
+
const written = tableChecksum(table, await readStoreTable(trx, table, columns), columns);
|
|
298
|
+
if (written !== expected)
|
|
299
|
+
throw new LegacyImportError('mismatch', `${table} 复制后校验和不一致,已回滚`);
|
|
300
|
+
countsOut[table] = tableRows.length;
|
|
301
|
+
checksums[table] = expected;
|
|
302
|
+
columnsOut[table] = Object.freeze(columns);
|
|
303
|
+
}
|
|
304
|
+
const value = Object.freeze({
|
|
305
|
+
status: 'copied', at, source, backup,
|
|
306
|
+
counts: Object.freeze(countsOut), checksums: Object.freeze(checksums), columns: Object.freeze(columnsOut),
|
|
307
|
+
});
|
|
308
|
+
await store.setMeta(LEGACY_IMPORT_KEY, value, trx);
|
|
309
|
+
options.logger?.info?.(`console iam: copied ${Object.entries(countsOut).map(([table, count]) => `${table}=${count}`).join(' ')} from the project database into .zhin/gateway.db (backup ${backup})`);
|
|
310
|
+
return value;
|
|
311
|
+
}).catch((error) => { throw classify(error); });
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Settled store: the marker in the project database must name this store. Missing → written (best
|
|
317
|
+
* effort; also for a SQLite file that did not exist at the first start). A different store id →
|
|
318
|
+
* `store_mismatch`. An unreachable project database is not an error here: this gateway.db is
|
|
319
|
+
* intact, and the marker is checked again on the next start.
|
|
320
|
+
*/
|
|
321
|
+
/**
|
|
322
|
+
* Writes the marker if it is missing for a settled store (the gateway calls it again after the
|
|
323
|
+
* first owner was created and after the supervised instance first reported ready). A store whose
|
|
324
|
+
* import has not settled is left alone; errors are logged, never thrown, except `store_mismatch`.
|
|
325
|
+
*/
|
|
326
|
+
export async function ensureGatewayStoreMarker(options) {
|
|
327
|
+
if (!legacyImportSettled(await readLegacyImportRecord(options.store)))
|
|
328
|
+
return;
|
|
329
|
+
try {
|
|
330
|
+
await verifyStoreMarker(options, options.now ?? Date.now);
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
if (error instanceof LegacyImportError && error.code === 'store_mismatch')
|
|
334
|
+
throw error;
|
|
335
|
+
options.logger?.warn?.(`console iam: cannot check the gateway store marker in the project database: ${errorText(error)}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function verifyStoreMarker(options, now) {
|
|
339
|
+
let config;
|
|
340
|
+
try {
|
|
341
|
+
config = await options.projectDatabase();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (!markerCapable(config))
|
|
347
|
+
return;
|
|
348
|
+
let source;
|
|
349
|
+
try {
|
|
350
|
+
source = (options.openSource ?? defaultSource)(config);
|
|
351
|
+
await source.start();
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
options.logger?.warn?.(`console iam: cannot check the gateway store marker in the project database: ${errorText(error)}`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
if (!source.readStoreMarker || !source.writeStoreMarker)
|
|
359
|
+
return;
|
|
360
|
+
const marker = await source.readStoreMarker();
|
|
361
|
+
if (marker && marker.storeId !== options.store.storeId)
|
|
362
|
+
throw new LegacyImportError('store_mismatch', storeLostMessage(marker, options.store.storeId));
|
|
363
|
+
if (!marker) {
|
|
364
|
+
await source.writeStoreMarker({ storeId: options.store.storeId, migratedAt: now() }).catch((error) => {
|
|
365
|
+
options.logger?.warn?.(`console iam: cannot write the gateway store marker into the project database: ${errorText(error)}`);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
await source.stop().catch(() => undefined);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function dropLegacyTables(options, now) {
|
|
374
|
+
const { store, projectRoot } = options;
|
|
375
|
+
let config;
|
|
376
|
+
try {
|
|
377
|
+
config = await options.projectDatabase();
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
throw new LegacyImportError('unavailable', `无法读取项目数据库配置:${errorText(error)}`, { cause: error });
|
|
381
|
+
}
|
|
382
|
+
return store.transaction(async (trx) => {
|
|
383
|
+
const record = await readLegacyImportRecord(store, trx);
|
|
384
|
+
if (!record || record.status !== 'copied') {
|
|
385
|
+
if (record)
|
|
386
|
+
return record;
|
|
387
|
+
throw new LegacyImportError('unavailable', '迁移记录丢失,稍后重试');
|
|
388
|
+
}
|
|
389
|
+
return withSource(options, config, async (legacy) => {
|
|
390
|
+
const present = await legacy.tableExists(TABLES);
|
|
391
|
+
let changed = false;
|
|
392
|
+
let changedExport;
|
|
393
|
+
if (present.length) {
|
|
394
|
+
const rows = await legacy.readSnapshot(present);
|
|
395
|
+
changed = present.some((table) => {
|
|
396
|
+
const tableRows = rows[table] ?? [];
|
|
397
|
+
const expected = record.checksums[table];
|
|
398
|
+
if (expected === undefined)
|
|
399
|
+
return tableRows.length > 0;
|
|
400
|
+
return tableChecksum(table, tableRows, record.columns[table] ?? []) !== expected;
|
|
401
|
+
});
|
|
402
|
+
if (changed) {
|
|
403
|
+
mkdirSync(join(projectRoot, record.backup), { recursive: true, mode: 0o700 });
|
|
404
|
+
const name = `console-tables-changed-${createHash('sha256').update(JSON.stringify(rows, jsonReplacer)).digest('hex').slice(0, 12)}.json`;
|
|
405
|
+
changedExport = join(record.backup, name);
|
|
406
|
+
if (!existsSync(join(projectRoot, changedExport)))
|
|
407
|
+
exportRows(projectRoot, record.backup, name, record.source, rows, now());
|
|
408
|
+
options.logger?.warn?.(`console iam: the old console_* tables changed after they were copied; their rows as dropped are in ${changedExport} (gateway.db keeps the copy)`);
|
|
409
|
+
}
|
|
410
|
+
await legacy.dropTables(present);
|
|
411
|
+
}
|
|
412
|
+
const value = Object.freeze({
|
|
413
|
+
status: 'done',
|
|
414
|
+
at: now(),
|
|
415
|
+
copiedAt: record.at,
|
|
416
|
+
source: record.source,
|
|
417
|
+
counts: record.counts,
|
|
418
|
+
checksums: record.checksums,
|
|
419
|
+
columns: record.columns,
|
|
420
|
+
backup: record.backup,
|
|
421
|
+
changed,
|
|
422
|
+
...(changedExport ? { changedExport } : {}),
|
|
423
|
+
});
|
|
424
|
+
await store.setMeta(LEGACY_IMPORT_KEY, value, trx);
|
|
425
|
+
return value;
|
|
426
|
+
}).catch((error) => { throw classify(error); });
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* `zhin console init --reset-store`: the operator accepts that the Console accounts of the
|
|
431
|
+
* previous gateway.db are gone (ADR 0059 §3 修订). Marks this store as settled (`fresh`, reason
|
|
432
|
+
* `reset-store`) when it has no record, rewrites the project database's marker to this store and
|
|
433
|
+
* records the reset in this store's audit log. Run only while no gateway runs.
|
|
434
|
+
*/
|
|
435
|
+
export async function resetGatewayStore(options) {
|
|
436
|
+
const now = options.now ?? Date.now;
|
|
437
|
+
const { store, projectRoot } = options;
|
|
438
|
+
let config;
|
|
439
|
+
try {
|
|
440
|
+
config = await options.projectDatabase();
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
throw new LegacyImportError('unavailable', `无法读取项目数据库配置:${errorText(error)}`, { cause: error });
|
|
444
|
+
}
|
|
445
|
+
let previous;
|
|
446
|
+
if (markerCapable(config)) {
|
|
447
|
+
previous = await withSource(options, config, async (source) => {
|
|
448
|
+
// Old account tables still there: migrate instead of resetting.
|
|
449
|
+
if ((await source.tableExists(TABLES)).length)
|
|
450
|
+
throw new LegacyImportError('conflict', '项目数据库里还有旧版 Console 账号表:请运行 zhin console migrate,而不是重置');
|
|
451
|
+
const marker = await source.readStoreMarker?.();
|
|
452
|
+
await source.writeStoreMarker?.({ storeId: store.storeId, migratedAt: now() });
|
|
453
|
+
return marker;
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
await store.transaction(async (trx) => {
|
|
457
|
+
if (!(await readLegacyImportRecord(store, trx))) {
|
|
458
|
+
await store.setMeta(LEGACY_IMPORT_KEY, Object.freeze({ status: 'fresh', at: now(), source: summarize(projectRoot, config), reason: 'reset-store' }), trx);
|
|
459
|
+
}
|
|
460
|
+
await store.deleteMeta(LEGACY_IMPORT_FAILURE_KEY, trx);
|
|
461
|
+
await trx.query(`INSERT INTO ${quoteTableName('sqlite', IAM_TABLES.audit)} (id, at, actor, action, target, outcome, detail, ip) VALUES (?, ?, 'local', 'auth.store.reset', ?, 'ok', ?, NULL)`, [
|
|
462
|
+
randomUUID(), now(), `store:${store.storeId}`, JSON.stringify({ previousStoreId: previous?.storeId ?? null, previousMigratedAt: previous?.migratedAt ?? null }),
|
|
463
|
+
]);
|
|
464
|
+
});
|
|
465
|
+
return Object.freeze({ ...(previous ? { previousStoreId: previous.storeId } : {}), storeId: store.storeId });
|
|
466
|
+
}
|
|
467
|
+
/** For `zhin doctor`: the record, and whether old tables showed up again in the project database. */
|
|
468
|
+
export async function inspectLegacyIamImport(options) {
|
|
469
|
+
const record = await readLegacyImportRecord(options.store);
|
|
470
|
+
const failure = await options.store.getMeta(LEGACY_IMPORT_FAILURE_KEY);
|
|
471
|
+
if (!legacyImportSettled(record))
|
|
472
|
+
return Object.freeze({ record, ...(failure ? { failure } : {}) });
|
|
473
|
+
try {
|
|
474
|
+
const config = await options.projectDatabase();
|
|
475
|
+
if (!SOURCE_DIALECTS.has(config.dialect))
|
|
476
|
+
return Object.freeze({ record, reappeared: Object.freeze([]) });
|
|
477
|
+
const filename = config.filename;
|
|
478
|
+
if (config.dialect === 'sqlite' && typeof filename === 'string' && !existsSync(filename))
|
|
479
|
+
return Object.freeze({ record, reappeared: Object.freeze([]) });
|
|
480
|
+
const reappeared = await withSource(options, config, legacy => legacy.tableExists(TABLES));
|
|
481
|
+
return Object.freeze({ record, reappeared: Object.freeze(reappeared) });
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
return Object.freeze({ record, problem: errorText(error) });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* `zhin console migrate --revert` (before installing a version older than ADR 0059): writes the
|
|
489
|
+
* gateway's tables back into the project database, verifies them, then empties them in
|
|
490
|
+
* gateway.db and clears the record. The project database must not hold rows in those tables.
|
|
491
|
+
* Run only while no gateway runs.
|
|
492
|
+
*/
|
|
493
|
+
export async function revertLegacyIamImport(options) {
|
|
494
|
+
const now = options.now ?? Date.now;
|
|
495
|
+
const { store, projectRoot } = options;
|
|
496
|
+
let config;
|
|
497
|
+
try {
|
|
498
|
+
config = await options.projectDatabase();
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
throw new LegacyImportError('unavailable', `无法读取项目数据库配置:${errorText(error)}`, { cause: error });
|
|
502
|
+
}
|
|
503
|
+
if (!SOURCE_DIALECTS.has(config.dialect)) {
|
|
504
|
+
throw new LegacyImportError('unsupported', `项目数据库是 ${config.dialect}:旧版本无法在这里存放 Console 账号,不能回退`);
|
|
505
|
+
}
|
|
506
|
+
// A copy of gateway.db first (SQLite cannot back up a connection inside its own write transaction).
|
|
507
|
+
const at = now();
|
|
508
|
+
const backup = backupDirectory(projectRoot, at);
|
|
509
|
+
await store.backupTo(join(projectRoot, backup, 'gateway.db'));
|
|
510
|
+
return store.transaction(async (trx) => {
|
|
511
|
+
const rows = {};
|
|
512
|
+
for (const table of TABLES)
|
|
513
|
+
rows[table] = await readStoreTable(trx, table, Object.keys(IAM_TABLE_DEFINITIONS[table] ?? {}));
|
|
514
|
+
exportRows(projectRoot, backup, 'gateway-tables.json', { dialect: 'sqlite', location: '.zhin/gateway.db' }, rows, at);
|
|
515
|
+
const target = new ProjectDatabase(config);
|
|
516
|
+
defineConsoleIamTables(target);
|
|
517
|
+
try {
|
|
518
|
+
await target.start();
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
throw new LegacyImportError('unavailable', `无法连接项目数据库(${config.dialect}):${errorText(error)}`, { cause: error });
|
|
522
|
+
}
|
|
523
|
+
const counts = {};
|
|
524
|
+
const warnings = [];
|
|
525
|
+
try {
|
|
526
|
+
const existing = await target.readSnapshot(TABLES);
|
|
527
|
+
const occupied = TABLES.filter(table => (existing[table]?.length ?? 0) > 0);
|
|
528
|
+
if (occupied.length)
|
|
529
|
+
throw new LegacyImportError('conflict', `项目数据库里这些表已有数据,不覆盖:${occupied.join(', ')}`);
|
|
530
|
+
if (target.relational) {
|
|
531
|
+
await target.transaction(async (project) => {
|
|
532
|
+
for (const table of TABLES)
|
|
533
|
+
await insertRows(project, config.dialect, table, Object.keys(IAM_TABLE_DEFINITIONS[table] ?? {}), rows[table] ?? []);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
for (const table of TABLES) {
|
|
538
|
+
const model = target.models.get(table);
|
|
539
|
+
if (!model)
|
|
540
|
+
throw new LegacyImportError('unavailable', `项目数据库缺少 ${table}`);
|
|
541
|
+
for (const row of rows[table] ?? []) {
|
|
542
|
+
await model.insert(Object.fromEntries(Object.keys(IAM_TABLE_DEFINITIONS[table] ?? {}).map(column => [column, targetCell(table, column, row[column])])));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const written = await target.readSnapshot(TABLES);
|
|
547
|
+
for (const table of TABLES) {
|
|
548
|
+
const columns = Object.keys(IAM_TABLE_DEFINITIONS[table] ?? {});
|
|
549
|
+
if (tableChecksum(table, written[table] ?? [], columns) !== tableChecksum(table, rows[table] ?? [], columns)) {
|
|
550
|
+
throw new LegacyImportError('mismatch', `${table} 写回项目数据库后校验和不一致;请检查项目数据库里的 console_* 表(gateway.db 未改动,备份在 ${backup})`);
|
|
551
|
+
}
|
|
552
|
+
counts[table] = rows[table]?.length ?? 0;
|
|
553
|
+
}
|
|
554
|
+
// The older version knows no marker; a later upgrade migrates again.
|
|
555
|
+
await target.deleteStoreMarker().catch((error) => { warnings.push(`无法删除项目数据库里的标记表 zhin_gateway_store:${errorText(error)}`); });
|
|
556
|
+
warnings.push(...await revokeConsoleRoleAccess(target, config));
|
|
557
|
+
}
|
|
558
|
+
finally {
|
|
559
|
+
await target.stop().catch(() => undefined);
|
|
560
|
+
}
|
|
561
|
+
for (const table of TABLES)
|
|
562
|
+
await trx.query(`DELETE FROM ${quoteTableName('sqlite', table)}`);
|
|
563
|
+
await store.deleteMeta(LEGACY_IMPORT_KEY, trx);
|
|
564
|
+
await store.deleteMeta(LEGACY_IMPORT_FAILURE_KEY, trx);
|
|
565
|
+
return Object.freeze({ counts: Object.freeze(counts), backup, warnings: Object.freeze(warnings) });
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* After a revert the account tables are back in the project database, where the restricted
|
|
570
|
+
* Console account (`database.console`) would reach them: PostgreSQL's default privileges grant it
|
|
571
|
+
* every new table of the main account, MySQL grants it the whole database. Revoke what can be
|
|
572
|
+
* revoked and tell the operator to drop the account (an older version does not use it).
|
|
573
|
+
*/
|
|
574
|
+
async function revokeConsoleRoleAccess(target, config) {
|
|
575
|
+
const consoleUser = config.console?.user;
|
|
576
|
+
if (typeof consoleUser !== 'string' || !consoleUser || (config.dialect !== 'pg' && config.dialect !== 'mysql'))
|
|
577
|
+
return [];
|
|
578
|
+
const warnings = [];
|
|
579
|
+
const attempt = async (sql, params) => {
|
|
580
|
+
await target.query(sql, params).catch((error) => { warnings.push(`${sql.split(' ').slice(0, 6).join(' ')}… 失败:${errorText(error)}`); });
|
|
581
|
+
};
|
|
582
|
+
if (config.dialect === 'pg') {
|
|
583
|
+
const role = quoteTableName('pg', consoleUser);
|
|
584
|
+
for (const table of TABLES)
|
|
585
|
+
await attempt(`REVOKE ALL ON TABLE ${quoteTableName('pg', table)} FROM ${role}`);
|
|
586
|
+
await attempt(`ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM ${role}`);
|
|
587
|
+
await attempt(`ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON SEQUENCES FROM ${role}`);
|
|
588
|
+
warnings.push(`旧版本不使用受限账号 ${consoleUser}:请删除它(DROP OWNED BY ${role}; DROP ROLE ${role};)并从配置删除 database.console`);
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
const grantees = await target.query('SELECT DISTINCT GRANTEE AS grantee FROM information_schema.SCHEMA_PRIVILEGES WHERE GRANTEE LIKE ?', [`'${consoleUser.replace(/'/gu, "''")}'@%`])
|
|
592
|
+
.catch(() => []);
|
|
593
|
+
for (const { grantee } of grantees)
|
|
594
|
+
await attempt(`REVOKE ALL PRIVILEGES, GRANT OPTION FROM ${grantee}`);
|
|
595
|
+
if (!grantees.length)
|
|
596
|
+
warnings.push(`无法确认受限账号 ${consoleUser} 的授权已撤销`);
|
|
597
|
+
warnings.push(`旧版本不使用受限账号 ${consoleUser}:请删除它(DROP USER '${consoleUser}'@'<主机>';)并从配置删除 database.console`);
|
|
598
|
+
}
|
|
599
|
+
return warnings;
|
|
600
|
+
}
|
package/lib/iam/local-admin.d.ts
CHANGED
|
@@ -23,5 +23,7 @@ export interface LocalConsoleAdminOptions {
|
|
|
23
23
|
readonly switchDatabase: () => Promise<{
|
|
24
24
|
restarting: boolean;
|
|
25
25
|
}>;
|
|
26
|
+
/** `gateway.console.migration.status`: the import state and whether old tables reappeared. */
|
|
27
|
+
readonly migration?: () => Promise<unknown>;
|
|
26
28
|
}
|
|
27
29
|
export declare function createLocalConsoleAdmin(options: LocalConsoleAdminOptions): LocalConsoleAdmin;
|
package/lib/iam/local-admin.js
CHANGED
|
@@ -19,6 +19,7 @@ const TYPES = new Set([
|
|
|
19
19
|
LOCAL_GATEWAY_RPC.CONSOLE_USER_CREATE,
|
|
20
20
|
LOCAL_GATEWAY_RPC.CONSOLE_USER_PASSWD,
|
|
21
21
|
LOCAL_GATEWAY_RPC.CONSOLE_USER_DISABLE,
|
|
22
|
+
LOCAL_GATEWAY_RPC.CONSOLE_MIGRATION_STATUS,
|
|
22
23
|
]);
|
|
23
24
|
const ACTOR = 'local';
|
|
24
25
|
export function createLocalConsoleAdmin(options) {
|
|
@@ -26,7 +27,7 @@ export function createLocalConsoleAdmin(options) {
|
|
|
26
27
|
const requireIam = () => {
|
|
27
28
|
const iam = options.iam();
|
|
28
29
|
if (!iam?.ready)
|
|
29
|
-
throw new IamError('unavailable', '
|
|
30
|
+
throw new IamError('unavailable', iam?.unavailableReason() ?? 'Console 访问控制尚未就绪');
|
|
30
31
|
return iam;
|
|
31
32
|
};
|
|
32
33
|
const requireUser = async (iam, username) => {
|
|
@@ -79,6 +80,10 @@ export function createLocalConsoleAdmin(options) {
|
|
|
79
80
|
audit(updated.disabled ? 'iam.users.disable' : 'iam.users.enable', `user:${user.id}`);
|
|
80
81
|
return { user: updated };
|
|
81
82
|
}
|
|
83
|
+
case LOCAL_GATEWAY_RPC.CONSOLE_MIGRATION_STATUS:
|
|
84
|
+
if (!options.migration)
|
|
85
|
+
throw new IamError('unavailable', '迁移状态不可用');
|
|
86
|
+
return options.migration();
|
|
82
87
|
default: throw new IamError('invalid_input', `Unknown RPC type: ${type}`);
|
|
83
88
|
}
|
|
84
89
|
}
|
package/lib/iam/routes.js
CHANGED
|
@@ -9,6 +9,13 @@ export function registerConsoleAuthRoutes(options) {
|
|
|
9
9
|
const { http, base } = options;
|
|
10
10
|
const route = (method, path, handler, summary) => http.route(method, `${base}${path}`, async (request, { principal, remoteAddress }) => {
|
|
11
11
|
const noStore = { 'cache-control': 'no-store' };
|
|
12
|
+
// ADR 0059 §3: closed until gateway.db is open and the old tables are imported (`/auth/me`
|
|
13
|
+
// still answers, anonymously and with `setupRequired: false`, so the Console can say why).
|
|
14
|
+
// The reason itself (paths, driver messages) is for the logs and the local owner only.
|
|
15
|
+
const closed = path === CONSOLE_AUTH_ROUTES.ME ? undefined : options.iam()?.publicUnavailable();
|
|
16
|
+
if (closed !== undefined) {
|
|
17
|
+
return jsonResponse(503, { success: false, code: 'iam_unavailable', reason: closed.code, error: closed.message }, { ...noStore, 'retry-after': String(DATABASE_BUSY_RETRY_AFTER_SECONDS) });
|
|
18
|
+
}
|
|
12
19
|
try {
|
|
13
20
|
const result = await handler({
|
|
14
21
|
request,
|
|
@@ -37,7 +44,7 @@ export function registerConsoleAuthRoutes(options) {
|
|
|
37
44
|
const requireIam = () => {
|
|
38
45
|
const iam = options.iam();
|
|
39
46
|
if (!iam?.ready)
|
|
40
|
-
throw new IamError('unavailable', '
|
|
47
|
+
throw new IamError('unavailable', iam?.publicUnavailable()?.message ?? 'Console 访问控制尚未就绪');
|
|
41
48
|
return iam;
|
|
42
49
|
};
|
|
43
50
|
const requireUser = (principal) => {
|
|
@@ -76,8 +83,9 @@ export function registerConsoleAuthRoutes(options) {
|
|
|
76
83
|
}, 'Console logout'),
|
|
77
84
|
route('GET', CONSOLE_AUTH_ROUTES.ME, async ({ principal }) => {
|
|
78
85
|
const setupRequired = options.setup.setupRequired();
|
|
86
|
+
const closed = options.iam()?.publicUnavailable();
|
|
79
87
|
if (!principal)
|
|
80
|
-
return ok({ kind: 'anonymous', permissions: [], bindings: [], setupRequired });
|
|
88
|
+
return ok({ kind: 'anonymous', permissions: [], bindings: [], setupRequired, ...(closed ? { unavailable: closed.message, unavailableCode: closed.code } : {}) });
|
|
81
89
|
if (principal.kind === 'bootstrap') {
|
|
82
90
|
const permissions = globalPermissions(principal);
|
|
83
91
|
return ok({
|