@hasna/tenants 0.1.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/LICENSE +191 -0
- package/README.md +86 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +228 -0
- package/dist/db.d.ts +22 -0
- package/dist/db.js +480 -0
- package/dist/generated/storage-kit/health.d.ts +19 -0
- package/dist/generated/storage-kit/index.d.ts +7 -0
- package/dist/generated/storage-kit/migrations.d.ts +47 -0
- package/dist/generated/storage-kit/mode.d.ts +47 -0
- package/dist/generated/storage-kit/pool.d.ts +33 -0
- package/dist/generated/storage-kit/query.d.ts +35 -0
- package/dist/generated/storage-kit/tls.d.ts +25 -0
- package/dist/idp/backfill.d.ts +12 -0
- package/dist/idp/ids.d.ts +24 -0
- package/dist/idp/mailer.d.ts +58 -0
- package/dist/idp/migrations.d.ts +10 -0
- package/dist/idp/passwords.d.ts +4 -0
- package/dist/idp/policy.d.ts +27 -0
- package/dist/idp/service.d.ts +103 -0
- package/dist/idp/store.d.ts +205 -0
- package/dist/idp/tokens.d.ts +89 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +2129 -0
- package/dist/migrations.d.ts +3 -0
- package/dist/migrations.js +452 -0
- package/dist/sdk/client.d.ts +115 -0
- package/dist/sdk/index.d.ts +1 -0
- package/dist/sdk/index.js +88 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +2149 -0
- package/dist/server/openapi.d.ts +819 -0
- package/dist/server/serve.d.ts +41 -0
- package/dist/version.d.ts +3 -0
- package/package.json +82 -0
package/dist/db.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/generated/storage-kit/mode.ts
|
|
3
|
+
var DEPRECATED_STORAGE_MODE_ALIASES = [
|
|
4
|
+
"remote",
|
|
5
|
+
"hybrid",
|
|
6
|
+
"self_hosted"
|
|
7
|
+
];
|
|
8
|
+
function normalizeStorageMode(value) {
|
|
9
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
10
|
+
if (normalized === "local")
|
|
11
|
+
return { mode: "local", deprecatedAlias: null };
|
|
12
|
+
if (normalized === "cloud")
|
|
13
|
+
return { mode: "cloud", deprecatedAlias: null };
|
|
14
|
+
if (DEPRECATED_STORAGE_MODE_ALIASES.includes(normalized)) {
|
|
15
|
+
return { mode: "cloud", deprecatedAlias: normalized };
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
|
|
18
|
+
}
|
|
19
|
+
function envToken(name) {
|
|
20
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
21
|
+
}
|
|
22
|
+
function storageEnvKeys(name) {
|
|
23
|
+
const token = envToken(name);
|
|
24
|
+
return {
|
|
25
|
+
modeKeys: [`HASNA_${token}_STORAGE_MODE`, `${token}_STORAGE_MODE`],
|
|
26
|
+
databaseUrlKeys: [`HASNA_${token}_DATABASE_URL`, `${token}_DATABASE_URL`]
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function firstEnv(env, keys) {
|
|
30
|
+
for (const key of keys) {
|
|
31
|
+
const value = env[key]?.trim();
|
|
32
|
+
if (value)
|
|
33
|
+
return { key, value };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function resolveStorageMode(name, env = process.env) {
|
|
38
|
+
const { modeKeys, databaseUrlKeys } = storageEnvKeys(name);
|
|
39
|
+
const dbHit = firstEnv(env, databaseUrlKeys);
|
|
40
|
+
const databaseUrlPresent = Boolean(dbHit);
|
|
41
|
+
const databaseUrlSource = dbHit ? dbHit.key : null;
|
|
42
|
+
const modeHit = firstEnv(env, modeKeys);
|
|
43
|
+
if (!modeHit) {
|
|
44
|
+
return {
|
|
45
|
+
mode: "local",
|
|
46
|
+
source: "default",
|
|
47
|
+
deprecatedAlias: null,
|
|
48
|
+
databaseUrlPresent,
|
|
49
|
+
databaseUrlSource,
|
|
50
|
+
warning: null
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const { mode, deprecatedAlias } = normalizeStorageMode(modeHit.value);
|
|
54
|
+
const warnings = [];
|
|
55
|
+
if (deprecatedAlias) {
|
|
56
|
+
warnings.push(`Deprecated storage mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Set ${modeKeys[0]}=cloud instead.`);
|
|
57
|
+
}
|
|
58
|
+
if (mode === "cloud" && !databaseUrlPresent) {
|
|
59
|
+
warnings.push(`cloud mode needs ${databaseUrlKeys[0]} (PURE REMOTE: reads and writes go to cloud Postgres).`);
|
|
60
|
+
}
|
|
61
|
+
if (modeHit.key !== modeKeys[0]) {
|
|
62
|
+
warnings.push(`Using alias env ${modeHit.key}; the canonical key is ${modeKeys[0]}.`);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
mode,
|
|
66
|
+
source: modeHit.key,
|
|
67
|
+
deprecatedAlias,
|
|
68
|
+
databaseUrlPresent,
|
|
69
|
+
databaseUrlSource,
|
|
70
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function resolveDatabaseUrl(name, env = process.env) {
|
|
74
|
+
const { databaseUrlKeys } = storageEnvKeys(name);
|
|
75
|
+
const hit = firstEnv(env, databaseUrlKeys);
|
|
76
|
+
return hit ? hit.value : null;
|
|
77
|
+
}
|
|
78
|
+
// src/generated/storage-kit/tls.ts
|
|
79
|
+
import { readFileSync } from "fs";
|
|
80
|
+
function sslModeFromConnectionString(connectionString) {
|
|
81
|
+
const queryStart = connectionString.indexOf("?");
|
|
82
|
+
const params = new URLSearchParams(queryStart === -1 ? "" : connectionString.slice(queryStart + 1));
|
|
83
|
+
const sslmode = params.get("sslmode")?.trim().toLowerCase();
|
|
84
|
+
if (sslmode) {
|
|
85
|
+
switch (sslmode) {
|
|
86
|
+
case "disable":
|
|
87
|
+
case "prefer":
|
|
88
|
+
case "require":
|
|
89
|
+
case "verify-ca":
|
|
90
|
+
case "verify-full":
|
|
91
|
+
return sslmode;
|
|
92
|
+
case "allow":
|
|
93
|
+
return "prefer";
|
|
94
|
+
default:
|
|
95
|
+
throw new Error(`Unknown sslmode '${sslmode}' in connection string.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const ssl = params.get("ssl")?.trim().toLowerCase();
|
|
99
|
+
if (ssl && ["1", "true", "yes", "on", "require"].includes(ssl))
|
|
100
|
+
return "require";
|
|
101
|
+
return "disable";
|
|
102
|
+
}
|
|
103
|
+
function loadCaBundle(options) {
|
|
104
|
+
const env = options.env ?? process.env;
|
|
105
|
+
if (options.ca && options.ca.trim())
|
|
106
|
+
return options.ca;
|
|
107
|
+
const path = options.caCertPath ?? env.PGSSLROOTCERT ?? env.NODE_EXTRA_CA_CERTS;
|
|
108
|
+
if (path && path.trim())
|
|
109
|
+
return readFileSync(path.trim(), "utf8");
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function resolveTlsConfig(connectionString, options = {}) {
|
|
113
|
+
const mode = sslModeFromConnectionString(connectionString);
|
|
114
|
+
if (mode === "disable" || mode === "prefer") {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const ca = loadCaBundle(options);
|
|
118
|
+
if (mode === "require") {
|
|
119
|
+
return ca ? { rejectUnauthorized: false, ca } : { rejectUnauthorized: false };
|
|
120
|
+
}
|
|
121
|
+
if (!ca) {
|
|
122
|
+
throw new Error(`sslmode=${mode} requires a CA bundle. Set PGSSLROOTCERT (or pass caCertPath/ca) to the ` + `Amazon RDS global bundle: https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem`);
|
|
123
|
+
}
|
|
124
|
+
return { rejectUnauthorized: true, ca };
|
|
125
|
+
}
|
|
126
|
+
// src/generated/storage-kit/query.ts
|
|
127
|
+
function wrapExecutor(executor) {
|
|
128
|
+
return {
|
|
129
|
+
async query(sql, params) {
|
|
130
|
+
const result = await executor.query(sql, params);
|
|
131
|
+
return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length };
|
|
132
|
+
},
|
|
133
|
+
async many(sql, params) {
|
|
134
|
+
const result = await executor.query(sql, params);
|
|
135
|
+
return result.rows;
|
|
136
|
+
},
|
|
137
|
+
async get(sql, params) {
|
|
138
|
+
const result = await executor.query(sql, params);
|
|
139
|
+
return result.rows[0] ?? null;
|
|
140
|
+
},
|
|
141
|
+
async one(sql, params) {
|
|
142
|
+
const result = await executor.query(sql, params);
|
|
143
|
+
if (result.rows.length !== 1) {
|
|
144
|
+
throw new Error(`Expected exactly one row, got ${result.rows.length}.`);
|
|
145
|
+
}
|
|
146
|
+
return result.rows[0];
|
|
147
|
+
},
|
|
148
|
+
async execute(sql, params) {
|
|
149
|
+
await executor.query(sql, params);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function createQueryClient(pool) {
|
|
154
|
+
const base = wrapExecutor(pool);
|
|
155
|
+
return {
|
|
156
|
+
...base,
|
|
157
|
+
pool,
|
|
158
|
+
async transaction(fn) {
|
|
159
|
+
const client = await pool.connect();
|
|
160
|
+
try {
|
|
161
|
+
await client.query("BEGIN");
|
|
162
|
+
const result = await fn(wrapExecutor(client));
|
|
163
|
+
await client.query("COMMIT");
|
|
164
|
+
return result;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
try {
|
|
167
|
+
await client.query("ROLLBACK");
|
|
168
|
+
} catch {}
|
|
169
|
+
throw error;
|
|
170
|
+
} finally {
|
|
171
|
+
client.release();
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
async close() {
|
|
175
|
+
await pool.end();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// src/generated/storage-kit/pool.ts
|
|
180
|
+
import pg from "pg";
|
|
181
|
+
function createPgPool(options) {
|
|
182
|
+
const ssl = resolveTlsConfig(options.connectionString, {
|
|
183
|
+
...options.ca !== undefined ? { ca: options.ca } : {},
|
|
184
|
+
...options.caCertPath !== undefined ? { caCertPath: options.caCertPath } : {},
|
|
185
|
+
...options.env !== undefined ? { env: options.env } : {}
|
|
186
|
+
});
|
|
187
|
+
const config = { connectionString: options.connectionString };
|
|
188
|
+
if (ssl !== undefined)
|
|
189
|
+
config.ssl = ssl;
|
|
190
|
+
if (options.max !== undefined)
|
|
191
|
+
config.max = options.max;
|
|
192
|
+
if (options.idleTimeoutMillis !== undefined)
|
|
193
|
+
config.idleTimeoutMillis = options.idleTimeoutMillis;
|
|
194
|
+
if (options.connectionTimeoutMillis !== undefined)
|
|
195
|
+
config.connectionTimeoutMillis = options.connectionTimeoutMillis;
|
|
196
|
+
if (options.applicationName !== undefined)
|
|
197
|
+
config.application_name = options.applicationName;
|
|
198
|
+
return new pg.Pool(config);
|
|
199
|
+
}
|
|
200
|
+
function createCloudPoolFromEnv(appName, options = {}) {
|
|
201
|
+
const env = options.env ?? process.env;
|
|
202
|
+
const resolution = resolveStorageMode(appName, env);
|
|
203
|
+
if (resolution.mode !== "cloud") {
|
|
204
|
+
throw new Error(`createCloudPoolFromEnv requires ${appName} storage mode 'cloud', got '${resolution.mode}'. ` + `Set HASNA_${appName.toUpperCase().replace(/-/g, "_")}_STORAGE_MODE=cloud.`);
|
|
205
|
+
}
|
|
206
|
+
const connectionString = resolveDatabaseUrl(appName, env);
|
|
207
|
+
if (!connectionString) {
|
|
208
|
+
throw new Error(`cloud mode for ${appName} needs a database URL. Set ` + `HASNA_${appName.toUpperCase().replace(/-/g, "_")}_DATABASE_URL.`);
|
|
209
|
+
}
|
|
210
|
+
const pool = createPgPool({
|
|
211
|
+
connectionString,
|
|
212
|
+
...options.ca !== undefined ? { ca: options.ca } : {},
|
|
213
|
+
...options.caCertPath !== undefined ? { caCertPath: options.caCertPath } : {},
|
|
214
|
+
env,
|
|
215
|
+
...options.max !== undefined ? { max: options.max } : {},
|
|
216
|
+
...options.idleTimeoutMillis !== undefined ? { idleTimeoutMillis: options.idleTimeoutMillis } : {},
|
|
217
|
+
...options.connectionTimeoutMillis !== undefined ? { connectionTimeoutMillis: options.connectionTimeoutMillis } : {},
|
|
218
|
+
...options.applicationName !== undefined ? { applicationName: options.applicationName } : {}
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
client: createQueryClient(pool),
|
|
222
|
+
connectionSource: resolution.databaseUrlSource ?? "unknown"
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// src/generated/storage-kit/migrations.ts
|
|
226
|
+
import { createHash } from "crypto";
|
|
227
|
+
var DEFAULT_MIGRATION_LEDGER_TABLE = "schema_migrations";
|
|
228
|
+
function checksumSql(sql) {
|
|
229
|
+
const normalized = sql.trim().replace(/\r\n/g, `
|
|
230
|
+
`);
|
|
231
|
+
return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
|
|
232
|
+
}
|
|
233
|
+
function defineMigration(id, sql) {
|
|
234
|
+
return Object.freeze({ id, sql: sql.trim(), checksum: checksumSql(sql) });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
class MigrationLedger {
|
|
238
|
+
client;
|
|
239
|
+
migrations;
|
|
240
|
+
ledgerTable;
|
|
241
|
+
constructor(client, migrations, options = {}) {
|
|
242
|
+
this.client = client;
|
|
243
|
+
this.migrations = migrations;
|
|
244
|
+
this.ledgerTable = options.ledgerTable ?? DEFAULT_MIGRATION_LEDGER_TABLE;
|
|
245
|
+
const seen = new Set;
|
|
246
|
+
for (const migration of migrations) {
|
|
247
|
+
if (seen.has(migration.id))
|
|
248
|
+
throw new Error(`Duplicate migration id: ${migration.id}`);
|
|
249
|
+
seen.add(migration.id);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async ensureLedger() {
|
|
253
|
+
await this.client.execute(`CREATE TABLE IF NOT EXISTS ${this.ledgerTable} (
|
|
254
|
+
id TEXT PRIMARY KEY,
|
|
255
|
+
checksum TEXT NOT NULL,
|
|
256
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
257
|
+
)`);
|
|
258
|
+
}
|
|
259
|
+
async listApplied() {
|
|
260
|
+
await this.ensureLedger();
|
|
261
|
+
return this.readApplied();
|
|
262
|
+
}
|
|
263
|
+
async readApplied() {
|
|
264
|
+
const rows = await this.client.many(`SELECT id, checksum, applied_at FROM ${this.ledgerTable} ORDER BY id ASC`);
|
|
265
|
+
return rows.map((row) => ({
|
|
266
|
+
id: row.id,
|
|
267
|
+
checksum: row.checksum,
|
|
268
|
+
appliedAt: row.applied_at instanceof Date ? row.applied_at.toISOString() : String(row.applied_at)
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
buildPlan(applied) {
|
|
272
|
+
const known = new Set(this.migrations.map((m) => m.id));
|
|
273
|
+
for (const row of applied) {
|
|
274
|
+
if (!known.has(row.id)) {
|
|
275
|
+
throw new Error(`Applied migration '${row.id}' is not recognized by this build (downgrade?).`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const appliedById = new Map(applied.map((row) => [row.id, row]));
|
|
279
|
+
for (const migration of this.migrations) {
|
|
280
|
+
const existing = appliedById.get(migration.id);
|
|
281
|
+
if (existing && existing.checksum !== migration.checksum) {
|
|
282
|
+
throw new Error(`Migration checksum mismatch for '${migration.id}': the SQL changed after it was applied.`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return this.migrations.map((migration) => ({
|
|
286
|
+
migration,
|
|
287
|
+
state: appliedById.has(migration.id) ? "already_applied" : "pending"
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
async migrate(opts = {}) {
|
|
291
|
+
const dryRun = opts.dryRun === true;
|
|
292
|
+
await this.ensureLedger();
|
|
293
|
+
const applied = await this.readApplied();
|
|
294
|
+
const plan = this.buildPlan(applied);
|
|
295
|
+
if (dryRun)
|
|
296
|
+
return { dryRun, applied, plan };
|
|
297
|
+
for (const item of plan) {
|
|
298
|
+
if (item.state === "already_applied")
|
|
299
|
+
continue;
|
|
300
|
+
await this.client.execute(item.migration.sql);
|
|
301
|
+
await this.client.execute(`INSERT INTO ${this.ledgerTable} (id, checksum, applied_at) VALUES ($1, $2, now())`, [item.migration.id, item.migration.checksum]);
|
|
302
|
+
}
|
|
303
|
+
return { dryRun, applied: await this.readApplied(), plan };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// src/generated/storage-kit/health.ts
|
|
307
|
+
async function checkHealth(client) {
|
|
308
|
+
const start = Date.now();
|
|
309
|
+
try {
|
|
310
|
+
await client.get("SELECT 1 AS ok");
|
|
311
|
+
return { ok: true, latencyMs: Date.now() - start };
|
|
312
|
+
} catch (error) {
|
|
313
|
+
return {
|
|
314
|
+
ok: false,
|
|
315
|
+
latencyMs: Date.now() - start,
|
|
316
|
+
error: error instanceof Error ? error.message : String(error)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async function checkReady(client, migrations, options = {}) {
|
|
321
|
+
const start = Date.now();
|
|
322
|
+
try {
|
|
323
|
+
const ledger = new MigrationLedger(client, migrations, options);
|
|
324
|
+
const result = await ledger.migrate({ dryRun: true });
|
|
325
|
+
const pending = result.plan.filter((item) => item.state === "pending").map((item) => item.migration.id);
|
|
326
|
+
return { ok: pending.length === 0, latencyMs: Date.now() - start, pendingMigrations: pending };
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
latencyMs: Date.now() - start,
|
|
331
|
+
pendingMigrations: [],
|
|
332
|
+
error: error instanceof Error ? error.message : String(error)
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// src/migrations.ts
|
|
337
|
+
import { apiKeyMigrations } from "@hasna/contracts/auth";
|
|
338
|
+
|
|
339
|
+
// src/idp/migrations.ts
|
|
340
|
+
var TENANTS_TABLE = "tenants";
|
|
341
|
+
var USERS_TABLE = "users";
|
|
342
|
+
var SERVICE_PRINCIPALS_TABLE = "service_principals";
|
|
343
|
+
var MEMBERSHIPS_TABLE = "memberships";
|
|
344
|
+
var SESSIONS_TABLE = "sessions";
|
|
345
|
+
var AUTH_CHALLENGES_TABLE = "auth_challenges";
|
|
346
|
+
var JWT_SIGNING_KEYS_TABLE = "jwt_signing_keys";
|
|
347
|
+
function idpMigrations(apiKeysTable) {
|
|
348
|
+
return [
|
|
349
|
+
defineMigration("tenants_0001_tenants", `CREATE TABLE IF NOT EXISTS ${TENANTS_TABLE} (
|
|
350
|
+
id UUID PRIMARY KEY,
|
|
351
|
+
slug TEXT UNIQUE NOT NULL,
|
|
352
|
+
name TEXT NOT NULL,
|
|
353
|
+
kind TEXT NOT NULL DEFAULT 'org',
|
|
354
|
+
parent_id UUID REFERENCES ${TENANTS_TABLE}(id),
|
|
355
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
356
|
+
identity_id TEXT,
|
|
357
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
358
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
359
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
360
|
+
)`),
|
|
361
|
+
defineMigration("tenants_0002_users", `CREATE TABLE IF NOT EXISTS ${USERS_TABLE} (
|
|
362
|
+
id UUID PRIMARY KEY,
|
|
363
|
+
kind TEXT NOT NULL,
|
|
364
|
+
email TEXT UNIQUE,
|
|
365
|
+
display_name TEXT,
|
|
366
|
+
identity_id TEXT,
|
|
367
|
+
home_tenant_id UUID REFERENCES ${TENANTS_TABLE}(id),
|
|
368
|
+
auth_method TEXT,
|
|
369
|
+
password_hash TEXT,
|
|
370
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
371
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
372
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
373
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
374
|
+
email_verified_at TIMESTAMPTZ
|
|
375
|
+
)`),
|
|
376
|
+
defineMigration("tenants_0003_service_principals", `CREATE TABLE IF NOT EXISTS ${SERVICE_PRINCIPALS_TABLE} (
|
|
377
|
+
id UUID PRIMARY KEY,
|
|
378
|
+
tenant_id UUID NOT NULL REFERENCES ${TENANTS_TABLE}(id),
|
|
379
|
+
kind TEXT NOT NULL DEFAULT 'machine',
|
|
380
|
+
display_name TEXT,
|
|
381
|
+
identity_id TEXT,
|
|
382
|
+
enrollment_secret_ref TEXT,
|
|
383
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
384
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
385
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
386
|
+
last_seen_at TIMESTAMPTZ
|
|
387
|
+
)`),
|
|
388
|
+
defineMigration("tenants_0004_memberships", `CREATE TABLE IF NOT EXISTS ${MEMBERSHIPS_TABLE} (
|
|
389
|
+
id BIGSERIAL PRIMARY KEY,
|
|
390
|
+
tenant_id UUID NOT NULL REFERENCES ${TENANTS_TABLE}(id),
|
|
391
|
+
principal_id UUID NOT NULL,
|
|
392
|
+
principal_type TEXT NOT NULL,
|
|
393
|
+
role TEXT NOT NULL,
|
|
394
|
+
scopes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
395
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
396
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
397
|
+
UNIQUE (tenant_id, principal_id, principal_type)
|
|
398
|
+
)`),
|
|
399
|
+
defineMigration("tenants_0005_sessions", `CREATE TABLE IF NOT EXISTS ${SESSIONS_TABLE} (
|
|
400
|
+
id UUID PRIMARY KEY,
|
|
401
|
+
user_id UUID REFERENCES ${USERS_TABLE}(id),
|
|
402
|
+
tenant_id UUID REFERENCES ${TENANTS_TABLE}(id),
|
|
403
|
+
token_hash TEXT UNIQUE NOT NULL,
|
|
404
|
+
method TEXT,
|
|
405
|
+
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
406
|
+
expires_at TIMESTAMPTZ,
|
|
407
|
+
revoked_at TIMESTAMPTZ,
|
|
408
|
+
ip TEXT,
|
|
409
|
+
user_agent TEXT
|
|
410
|
+
)`),
|
|
411
|
+
defineMigration("tenants_0006_auth_challenges", `CREATE TABLE IF NOT EXISTS ${AUTH_CHALLENGES_TABLE} (
|
|
412
|
+
id UUID PRIMARY KEY,
|
|
413
|
+
email TEXT NOT NULL,
|
|
414
|
+
code_hash TEXT NOT NULL,
|
|
415
|
+
purpose TEXT NOT NULL,
|
|
416
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
417
|
+
consumed_at TIMESTAMPTZ,
|
|
418
|
+
attempts INT NOT NULL DEFAULT 0,
|
|
419
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
420
|
+
)`),
|
|
421
|
+
defineMigration("tenants_0007_auth_challenges_email_idx", `CREATE INDEX IF NOT EXISTS ${AUTH_CHALLENGES_TABLE}_email_idx
|
|
422
|
+
ON ${AUTH_CHALLENGES_TABLE} (email, purpose, created_at DESC)`),
|
|
423
|
+
defineMigration("tenants_0008_jwt_signing_keys", `CREATE TABLE IF NOT EXISTS ${JWT_SIGNING_KEYS_TABLE} (
|
|
424
|
+
kid TEXT PRIMARY KEY,
|
|
425
|
+
alg TEXT NOT NULL DEFAULT 'EdDSA',
|
|
426
|
+
public_jwk JSONB NOT NULL,
|
|
427
|
+
private_jwk JSONB NOT NULL,
|
|
428
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
429
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
430
|
+
retired_at TIMESTAMPTZ
|
|
431
|
+
)`),
|
|
432
|
+
defineMigration("tenants_0009_memberships_principal_idx", `CREATE INDEX IF NOT EXISTS ${MEMBERSHIPS_TABLE}_principal_idx
|
|
433
|
+
ON ${MEMBERSHIPS_TABLE} (principal_id, principal_type)`),
|
|
434
|
+
defineMigration("tenants_0010_api_keys_tenant", `ALTER TABLE ${apiKeysTable} ADD COLUMN IF NOT EXISTS tenant_id UUID;
|
|
435
|
+
ALTER TABLE ${apiKeysTable} ADD COLUMN IF NOT EXISTS user_id UUID;
|
|
436
|
+
ALTER TABLE ${apiKeysTable} ADD COLUMN IF NOT EXISTS principal_type TEXT;`),
|
|
437
|
+
defineMigration("tenants_0011_api_keys_tenant_idx", `CREATE INDEX IF NOT EXISTS ${apiKeysTable}_tenant_idx ON ${apiKeysTable} (tenant_id)`)
|
|
438
|
+
];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/migrations.ts
|
|
442
|
+
var API_KEYS_TABLE = "api_keys";
|
|
443
|
+
function tenantsMigrations() {
|
|
444
|
+
return [
|
|
445
|
+
...apiKeyMigrations(API_KEYS_TABLE).map((m) => defineMigration(m.id, m.sql)),
|
|
446
|
+
...idpMigrations(API_KEYS_TABLE)
|
|
447
|
+
];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/db.ts
|
|
451
|
+
var TENANTS_APP_NAME = "tenants";
|
|
452
|
+
function createCloudClient(options = {}) {
|
|
453
|
+
const { client, connectionSource } = createCloudPoolFromEnv(TENANTS_APP_NAME, {
|
|
454
|
+
applicationName: options.applicationName ?? "tenants-serve"
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
client,
|
|
458
|
+
connectionSource,
|
|
459
|
+
close: async () => {
|
|
460
|
+
await client.close();
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
async function runTenantsMigrations(client, opts = {}) {
|
|
465
|
+
const ledger = new MigrationLedger(client, tenantsMigrations());
|
|
466
|
+
return ledger.migrate(opts);
|
|
467
|
+
}
|
|
468
|
+
async function cloudHealth(client) {
|
|
469
|
+
return checkHealth(client);
|
|
470
|
+
}
|
|
471
|
+
async function cloudReady(client) {
|
|
472
|
+
return checkReady(client, tenantsMigrations());
|
|
473
|
+
}
|
|
474
|
+
export {
|
|
475
|
+
runTenantsMigrations,
|
|
476
|
+
createCloudClient,
|
|
477
|
+
cloudReady,
|
|
478
|
+
cloudHealth,
|
|
479
|
+
TENANTS_APP_NAME
|
|
480
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TypedQueryClient } from "./query.js";
|
|
2
|
+
import { type Migration, type MigrationRunnerOptions } from "./migrations.js";
|
|
3
|
+
export interface HealthResult {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
/** Round-trip latency of the probe query, in milliseconds. */
|
|
6
|
+
latencyMs: number;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Cheap reachability probe: `SELECT 1`. Never throws — reports `ok: false`. */
|
|
10
|
+
export declare function checkHealth(client: TypedQueryClient): Promise<HealthResult>;
|
|
11
|
+
export interface ReadyResult extends HealthResult {
|
|
12
|
+
/** Migration ids that are defined but not yet applied. */
|
|
13
|
+
pendingMigrations: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Readiness probe: reachable AND fully migrated. Reports `ok: false` with the
|
|
17
|
+
* list of pending migration ids when the schema is behind.
|
|
18
|
+
*/
|
|
19
|
+
export declare function checkReady(client: TypedQueryClient, migrations: readonly Migration[], options?: MigrationRunnerOptions): Promise<ReadyResult>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { TypedQueryClient } from "./query.js";
|
|
2
|
+
/** Default ledger table name. Override per app if a legacy name exists. */
|
|
3
|
+
export declare const DEFAULT_MIGRATION_LEDGER_TABLE = "schema_migrations";
|
|
4
|
+
export interface Migration {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly sql: string;
|
|
7
|
+
readonly checksum: string;
|
|
8
|
+
}
|
|
9
|
+
export type MigrationState = "already_applied" | "pending";
|
|
10
|
+
export interface MigrationPlanItem {
|
|
11
|
+
readonly migration: Migration;
|
|
12
|
+
readonly state: MigrationState;
|
|
13
|
+
}
|
|
14
|
+
export interface AppliedMigration {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly checksum: string;
|
|
17
|
+
readonly appliedAt: string;
|
|
18
|
+
}
|
|
19
|
+
export interface MigrationResult {
|
|
20
|
+
readonly dryRun: boolean;
|
|
21
|
+
readonly applied: AppliedMigration[];
|
|
22
|
+
readonly plan: MigrationPlanItem[];
|
|
23
|
+
}
|
|
24
|
+
/** Stable sha256 checksum for a migration's SQL text. */
|
|
25
|
+
export declare function checksumSql(sql: string): string;
|
|
26
|
+
/** Freeze a migration definition, computing its checksum from the SQL. */
|
|
27
|
+
export declare function defineMigration(id: string, sql: string): Migration;
|
|
28
|
+
export interface MigrationRunnerOptions {
|
|
29
|
+
ledgerTable?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare class MigrationLedger {
|
|
32
|
+
private readonly client;
|
|
33
|
+
private readonly migrations;
|
|
34
|
+
private readonly ledgerTable;
|
|
35
|
+
constructor(client: TypedQueryClient, migrations: readonly Migration[], options?: MigrationRunnerOptions);
|
|
36
|
+
ensureLedger(): Promise<void>;
|
|
37
|
+
listApplied(): Promise<AppliedMigration[]>;
|
|
38
|
+
private readApplied;
|
|
39
|
+
/** Compute the migration plan and guard against drift/downgrade. */
|
|
40
|
+
private buildPlan;
|
|
41
|
+
/** Apply all pending migrations. With `dryRun`, report the plan only. */
|
|
42
|
+
migrate(opts?: {
|
|
43
|
+
dryRun?: boolean;
|
|
44
|
+
}): Promise<MigrationResult>;
|
|
45
|
+
}
|
|
46
|
+
/** Convenience: build a ledger and run all pending migrations. */
|
|
47
|
+
export declare function createMigrationLedger(client: TypedQueryClient, migrations: readonly Migration[], options?: MigrationRunnerOptions): MigrationLedger;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export declare const STORAGE_MODES: readonly ["local", "cloud"];
|
|
2
|
+
export type StorageMode = (typeof STORAGE_MODES)[number];
|
|
3
|
+
export declare const DEPRECATED_STORAGE_MODE_ALIASES: readonly ["remote", "hybrid", "self_hosted"];
|
|
4
|
+
export type Env = Record<string, string | undefined>;
|
|
5
|
+
export interface StorageModeNormalization {
|
|
6
|
+
mode: StorageMode;
|
|
7
|
+
/** The deprecated alias that was normalized to `cloud`, if any. */
|
|
8
|
+
deprecatedAlias: string | null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a raw storage-mode string to the `local | cloud` runtime enum.
|
|
12
|
+
* Accepts deprecated aliases (`remote`, `hybrid`, `self_hosted`) and maps them
|
|
13
|
+
* to `cloud`. Throws on any other value.
|
|
14
|
+
*/
|
|
15
|
+
export declare function normalizeStorageMode(value: string): StorageModeNormalization;
|
|
16
|
+
/** Upper-snake env token for an app name, e.g. `todos` -> `TODOS`. */
|
|
17
|
+
export declare function envToken(name: string): string;
|
|
18
|
+
export interface StorageEnvKeys {
|
|
19
|
+
/** `HASNA_<NAME>_STORAGE_MODE` then the optional `<NAME>_STORAGE_MODE` alias. */
|
|
20
|
+
modeKeys: string[];
|
|
21
|
+
/** `HASNA_<NAME>_DATABASE_URL` then the optional `<NAME>_DATABASE_URL` alias. */
|
|
22
|
+
databaseUrlKeys: string[];
|
|
23
|
+
}
|
|
24
|
+
/** Resolve the canonical env-key spec for an app's storage config. */
|
|
25
|
+
export declare function storageEnvKeys(name: string): StorageEnvKeys;
|
|
26
|
+
export interface StorageModeResolution {
|
|
27
|
+
mode: StorageMode;
|
|
28
|
+
/** Env key the mode came from, or `"default"`. */
|
|
29
|
+
source: string;
|
|
30
|
+
deprecatedAlias: string | null;
|
|
31
|
+
databaseUrlPresent: boolean;
|
|
32
|
+
/** Env key the database URL came from, or `null`. */
|
|
33
|
+
databaseUrlSource: string | null;
|
|
34
|
+
warning: string | null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve an app's storage mode from the environment per the contract env spec.
|
|
38
|
+
* Precedence: `HASNA_<NAME>_STORAGE_MODE`, then `<NAME>_STORAGE_MODE`, else
|
|
39
|
+
* `local`. Never reads secret values — only detects DATABASE_URL presence.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveStorageMode(name: string, env?: Env): StorageModeResolution;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the database URL value for an app, honoring the canonical then alias
|
|
44
|
+
* env keys. Returns `null` when unset. The caller is responsible for never
|
|
45
|
+
* logging the returned value.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveDatabaseUrl(name: string, env?: Env): string | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
import { type TlsResolveOptions } from "./tls.js";
|
|
3
|
+
import { type PoolQueryClient } from "./query.js";
|
|
4
|
+
export interface CreatePgPoolOptions extends TlsResolveOptions {
|
|
5
|
+
connectionString: string;
|
|
6
|
+
/** Max clients in the pool. Defaults to pg's default (10). */
|
|
7
|
+
max?: number;
|
|
8
|
+
/** Idle client timeout (ms). */
|
|
9
|
+
idleTimeoutMillis?: number;
|
|
10
|
+
/** Connection acquisition timeout (ms). */
|
|
11
|
+
connectionTimeoutMillis?: number;
|
|
12
|
+
/** Application name reported to Postgres (shows in pg_stat_activity). */
|
|
13
|
+
applicationName?: string;
|
|
14
|
+
}
|
|
15
|
+
/** Build a `pg.Pool` with fleet-standard TLS handling. */
|
|
16
|
+
export declare function createPgPool(options: CreatePgPoolOptions): Pool;
|
|
17
|
+
export interface CreateCloudPoolFromEnvOptions extends TlsResolveOptions {
|
|
18
|
+
max?: number;
|
|
19
|
+
idleTimeoutMillis?: number;
|
|
20
|
+
connectionTimeoutMillis?: number;
|
|
21
|
+
applicationName?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface CloudPoolFromEnv {
|
|
24
|
+
client: PoolQueryClient;
|
|
25
|
+
connectionSource: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve mode + database URL from the environment and build a cloud pool.
|
|
29
|
+
*
|
|
30
|
+
* Throws when the resolved mode is not `cloud` (PURE REMOTE has no Postgres in
|
|
31
|
+
* `local` mode) or when the database URL is missing. Never logs the URL.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createCloudPoolFromEnv(appName: string, options?: CreateCloudPoolFromEnvOptions): CloudPoolFromEnv;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Pool, QueryResultRow } from "pg";
|
|
2
|
+
export interface QueryResult<T extends QueryResultRow> {
|
|
3
|
+
rows: T[];
|
|
4
|
+
rowCount: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Minimal executor contract. `pg.Pool` and `pg.PoolClient` both satisfy the
|
|
8
|
+
* `query` method; the wrapper builds the rest on top so tests can substitute a
|
|
9
|
+
* lightweight shim without pulling in a live Postgres.
|
|
10
|
+
*/
|
|
11
|
+
export interface PgExecutor {
|
|
12
|
+
query<T extends QueryResultRow>(sql: string, params?: readonly unknown[]): Promise<{
|
|
13
|
+
rows: T[];
|
|
14
|
+
rowCount: number | null;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
export interface TypedQueryClient {
|
|
18
|
+
query<T extends QueryResultRow>(sql: string, params?: readonly unknown[]): Promise<QueryResult<T>>;
|
|
19
|
+
many<T extends QueryResultRow>(sql: string, params?: readonly unknown[]): Promise<T[]>;
|
|
20
|
+
/** First row or `null`. Restored here after open-knowledge dropped it. */
|
|
21
|
+
get<T extends QueryResultRow>(sql: string, params?: readonly unknown[]): Promise<T | null>;
|
|
22
|
+
/** Exactly one row; throws if zero or more than one row is returned. */
|
|
23
|
+
one<T extends QueryResultRow>(sql: string, params?: readonly unknown[]): Promise<T>;
|
|
24
|
+
execute(sql: string, params?: readonly unknown[]): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/** Wrap any `PgExecutor` (a Pool, a PoolClient, or a test shim) with the typed vocabulary. */
|
|
27
|
+
export declare function wrapExecutor(executor: PgExecutor): TypedQueryClient;
|
|
28
|
+
export interface PoolQueryClient extends TypedQueryClient {
|
|
29
|
+
readonly pool: Pool;
|
|
30
|
+
/** Run a callback inside a `BEGIN`/`COMMIT` transaction on a dedicated client. */
|
|
31
|
+
transaction<T>(fn: (client: TypedQueryClient) => Promise<T>): Promise<T>;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** Build a `PoolQueryClient` around a live `pg.Pool`. */
|
|
35
|
+
export declare function createQueryClient(pool: Pool): PoolQueryClient;
|