@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
|
@@ -0,0 +1,452 @@
|
|
|
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
|
+
export {
|
|
450
|
+
tenantsMigrations,
|
|
451
|
+
API_KEYS_TABLE
|
|
452
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export interface ErrorResponse {
|
|
2
|
+
"error": string;
|
|
3
|
+
"reason"?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface SignupInput {
|
|
6
|
+
"email": string;
|
|
7
|
+
"name"?: string;
|
|
8
|
+
"kind"?: "human" | "agent";
|
|
9
|
+
"org_name"?: string;
|
|
10
|
+
"password"?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface LoginInput {
|
|
13
|
+
"email": string;
|
|
14
|
+
"password"?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface VerifyInput {
|
|
17
|
+
"email": string;
|
|
18
|
+
"code": string;
|
|
19
|
+
}
|
|
20
|
+
export interface ResendInput {
|
|
21
|
+
"email": string;
|
|
22
|
+
}
|
|
23
|
+
export interface TokenInput {
|
|
24
|
+
"app": string;
|
|
25
|
+
"scopes"?: Array<string>;
|
|
26
|
+
"tenant_id"?: string;
|
|
27
|
+
"ttlSeconds"?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface AuthResponse {
|
|
30
|
+
"session"?: string;
|
|
31
|
+
"session_expires_in"?: number;
|
|
32
|
+
"challenge"?: boolean;
|
|
33
|
+
"purpose"?: string;
|
|
34
|
+
"expires_in"?: number;
|
|
35
|
+
"confirmation_required"?: boolean;
|
|
36
|
+
"email_sent"?: boolean;
|
|
37
|
+
"email_message_id"?: string;
|
|
38
|
+
"principal"?: Record<string, unknown>;
|
|
39
|
+
"tenants"?: Array<Record<string, unknown>>;
|
|
40
|
+
"apps"?: Array<string>;
|
|
41
|
+
}
|
|
42
|
+
export interface TokenResponse {
|
|
43
|
+
"access_token"?: string;
|
|
44
|
+
"token_type"?: string;
|
|
45
|
+
"alg"?: string;
|
|
46
|
+
"kid"?: string;
|
|
47
|
+
"aud"?: string;
|
|
48
|
+
"tid"?: string;
|
|
49
|
+
"uid"?: string;
|
|
50
|
+
"pt"?: string;
|
|
51
|
+
"scope"?: Array<string>;
|
|
52
|
+
"expires_in"?: number;
|
|
53
|
+
"api_key"?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface WhoamiResponse {
|
|
56
|
+
"principal"?: Record<string, unknown>;
|
|
57
|
+
"tenants"?: Array<Record<string, unknown>>;
|
|
58
|
+
"apps"?: Array<string>;
|
|
59
|
+
}
|
|
60
|
+
export interface IntrospectResponse {
|
|
61
|
+
"active"?: boolean;
|
|
62
|
+
"kid"?: string;
|
|
63
|
+
"tenant_id"?: string;
|
|
64
|
+
"user_id"?: string;
|
|
65
|
+
"principal_type"?: string;
|
|
66
|
+
}
|
|
67
|
+
export interface Jwks {
|
|
68
|
+
"keys": Array<Record<string, unknown>>;
|
|
69
|
+
}
|
|
70
|
+
export interface TenantsClientOptions {
|
|
71
|
+
/** Base URL, e.g. process.env.HASNA_TENANTS_API_URL. */
|
|
72
|
+
baseUrl: string;
|
|
73
|
+
/** API key, e.g. process.env.HASNA_TENANTS_API_KEY. Sent as the 'x-api-key' header. */
|
|
74
|
+
apiKey?: string;
|
|
75
|
+
/** Custom fetch (defaults to global fetch). */
|
|
76
|
+
fetch?: typeof fetch;
|
|
77
|
+
/** Extra headers merged into every request. */
|
|
78
|
+
headers?: Record<string, string>;
|
|
79
|
+
}
|
|
80
|
+
export declare class ApiError extends Error {
|
|
81
|
+
readonly status: number;
|
|
82
|
+
readonly body: unknown;
|
|
83
|
+
constructor(status: number, message: string, body: unknown);
|
|
84
|
+
}
|
|
85
|
+
export declare class TenantsClient {
|
|
86
|
+
private readonly baseUrl;
|
|
87
|
+
private readonly apiKey;
|
|
88
|
+
private readonly fetchImpl;
|
|
89
|
+
private readonly baseHeaders;
|
|
90
|
+
constructor(options: TenantsClientOptions);
|
|
91
|
+
private request;
|
|
92
|
+
/** Public JWKS (EdDSA) for verifying tenants-issued fleet tokens. */
|
|
93
|
+
getJwks(init?: RequestInit): Promise<Jwks>;
|
|
94
|
+
/** Create a tenant/user (hasna emails only) and send an email confirmation code. */
|
|
95
|
+
signup(body: SignupInput, init?: RequestInit): Promise<AuthResponse>;
|
|
96
|
+
/** Log in with password (session) or request an OTP challenge. */
|
|
97
|
+
login(body: LoginInput, init?: RequestInit): Promise<AuthResponse>;
|
|
98
|
+
/** Verify an OTP code and open a session. */
|
|
99
|
+
verifyOtp(body: VerifyInput, init?: RequestInit): Promise<AuthResponse>;
|
|
100
|
+
/** Confirm an email via the one-click link (email + code) and open a session. */
|
|
101
|
+
confirm(query?: {
|
|
102
|
+
"email": string;
|
|
103
|
+
"code": string;
|
|
104
|
+
}, init?: RequestInit): Promise<AuthResponse>;
|
|
105
|
+
/** Re-send the email confirmation code for an unconfirmed account. */
|
|
106
|
+
resendConfirmation(body: ResendInput, init?: RequestInit): Promise<AuthResponse>;
|
|
107
|
+
/** Mint a per-app fleet access token from a session (Bearer session). */
|
|
108
|
+
issueToken(body: TokenInput, init?: RequestInit): Promise<TokenResponse>;
|
|
109
|
+
/** Resolve the session principal + tenant memberships. */
|
|
110
|
+
whoami(init?: RequestInit): Promise<WhoamiResponse>;
|
|
111
|
+
/** Introspect an issued key by kid (tenant/user binding + status). */
|
|
112
|
+
introspect(query?: {
|
|
113
|
+
"kid": string;
|
|
114
|
+
}, init?: RequestInit): Promise<IntrospectResponse>;
|
|
115
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./client.js";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/sdk/client.ts
|
|
3
|
+
class ApiError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
body;
|
|
6
|
+
constructor(status, message, body) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.body = body;
|
|
10
|
+
this.name = "ApiError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class TenantsClient {
|
|
15
|
+
baseUrl;
|
|
16
|
+
apiKey;
|
|
17
|
+
fetchImpl;
|
|
18
|
+
baseHeaders;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
if (!options.baseUrl)
|
|
21
|
+
throw new Error("TenantsClient requires a baseUrl.");
|
|
22
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
23
|
+
this.apiKey = options.apiKey;
|
|
24
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
25
|
+
this.baseHeaders = options.headers ?? {};
|
|
26
|
+
}
|
|
27
|
+
async request(method, path, opts) {
|
|
28
|
+
const url = new URL(this.baseUrl + path);
|
|
29
|
+
if (opts.query) {
|
|
30
|
+
for (const [key, value] of Object.entries(opts.query)) {
|
|
31
|
+
if (value !== undefined && value !== null)
|
|
32
|
+
url.searchParams.set(key, String(value));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const headers = { Accept: "application/json", ...this.baseHeaders, ...opts.init?.headers };
|
|
36
|
+
if (this.apiKey)
|
|
37
|
+
headers["x-api-key"] = this.apiKey;
|
|
38
|
+
let payload;
|
|
39
|
+
if (opts.body !== undefined) {
|
|
40
|
+
headers["Content-Type"] = "application/json";
|
|
41
|
+
payload = JSON.stringify(opts.body);
|
|
42
|
+
}
|
|
43
|
+
const response = await this.fetchImpl(url.toString(), { ...opts.init, method, headers, body: payload });
|
|
44
|
+
const text = await response.text();
|
|
45
|
+
const data = text ? (() => {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(text);
|
|
48
|
+
} catch {
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
})() : undefined;
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
throw new ApiError(response.status, `${method} ${path} failed: ${response.status}`, data);
|
|
54
|
+
}
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
async getJwks(init) {
|
|
58
|
+
return this.request("GET", `/v1/.well-known/jwks.json`, { body: undefined, query: undefined, init });
|
|
59
|
+
}
|
|
60
|
+
async signup(body, init) {
|
|
61
|
+
return this.request("POST", `/v1/auth/signup`, { body, query: undefined, init });
|
|
62
|
+
}
|
|
63
|
+
async login(body, init) {
|
|
64
|
+
return this.request("POST", `/v1/auth/login`, { body, query: undefined, init });
|
|
65
|
+
}
|
|
66
|
+
async verifyOtp(body, init) {
|
|
67
|
+
return this.request("POST", `/v1/auth/verify`, { body, query: undefined, init });
|
|
68
|
+
}
|
|
69
|
+
async confirm(query, init) {
|
|
70
|
+
return this.request("GET", `/v1/auth/confirm`, { body: undefined, query, init });
|
|
71
|
+
}
|
|
72
|
+
async resendConfirmation(body, init) {
|
|
73
|
+
return this.request("POST", `/v1/auth/resend`, { body, query: undefined, init });
|
|
74
|
+
}
|
|
75
|
+
async issueToken(body, init) {
|
|
76
|
+
return this.request("POST", `/v1/auth/token`, { body, query: undefined, init });
|
|
77
|
+
}
|
|
78
|
+
async whoami(init) {
|
|
79
|
+
return this.request("GET", `/v1/auth/whoami`, { body: undefined, query: undefined, init });
|
|
80
|
+
}
|
|
81
|
+
async introspect(query, init) {
|
|
82
|
+
return this.request("GET", `/v1/introspect`, { body: undefined, query, init });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export {
|
|
86
|
+
TenantsClient,
|
|
87
|
+
ApiError
|
|
88
|
+
};
|