@everystack/server 0.3.6 → 0.3.7
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/package.json +2 -2
- package/src/plugin.ts +39 -30
- package/src/provision.ts +129 -0
- package/src/role-chain.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
"ts-jest": "29.4.9",
|
|
145
145
|
"typescript": "5.9.3",
|
|
146
146
|
"@everystack/auth": "0.4.0",
|
|
147
|
-
"@everystack/cli": "0.4.
|
|
147
|
+
"@everystack/cli": "0.4.5"
|
|
148
148
|
},
|
|
149
149
|
"scripts": {
|
|
150
150
|
"test": "jest",
|
package/src/plugin.ts
CHANGED
|
@@ -731,46 +731,55 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
731
731
|
);
|
|
732
732
|
};
|
|
733
733
|
|
|
734
|
-
// --- db:provision —
|
|
734
|
+
// --- db:provision — BOTH sides of the credential split, in the safe order ---
|
|
735
735
|
// Runs as the operator and creates ONLY roles (no database, no tables, no schema), so
|
|
736
736
|
// it is safe on an existing database: an app that brings its own DATABASE_URL can run
|
|
737
737
|
// this to adopt the least-privilege split without re-provisioning anything. The
|
|
738
738
|
// auto-provisioning construct invokes it at deploy time with a generated password.
|
|
739
|
+
// With adminPassword, also creates the operator login (migrator) and VERIFIES its
|
|
740
|
+
// real connectivity BEFORE the authenticator password is set — the ordering that
|
|
741
|
+
// keeps a BYO-database app from sawing off its own ops plane (see provision.ts).
|
|
739
742
|
actions['db:provision'] = async (payload, _ctx) => {
|
|
740
|
-
const { authPassword, loginRole, appRoles } = (payload || {}) as {
|
|
741
|
-
authPassword?: string;
|
|
742
|
-
loginRole?: string;
|
|
743
|
-
appRoles?: string[];
|
|
744
|
-
};
|
|
745
743
|
const { sql } = await import('drizzle-orm');
|
|
746
|
-
const {
|
|
744
|
+
const { runProvision } = await import('./provision');
|
|
747
745
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
746
|
+
// Same host/port/database resolution as db:psql — the verification URL must
|
|
747
|
+
// point at the database this connection is actually provisioning.
|
|
748
|
+
let connection: { host: string; port?: number | string; database: string } | null = null;
|
|
749
|
+
if (options.connectionInfo) {
|
|
750
|
+
const info = await options.connectionInfo();
|
|
751
|
+
if (info?.host && info?.database) {
|
|
752
|
+
connection = { host: info.host, port: info.port, database: info.database };
|
|
753
|
+
}
|
|
754
|
+
} else {
|
|
755
|
+
try {
|
|
756
|
+
const { Resource } = await import('sst');
|
|
757
|
+
const db = (Resource as any).Database;
|
|
758
|
+
if (db?.host) connection = { host: db.host, port: db.port, database: db.database };
|
|
759
|
+
} catch {
|
|
760
|
+
// No linked component and no callback — the admin login lands unverified.
|
|
763
761
|
}
|
|
764
|
-
await opsDb.execute(sql.raw(`ALTER ROLE ${role} WITH LOGIN PASSWORD '${authPassword}';`));
|
|
765
|
-
passwordSet = true;
|
|
766
762
|
}
|
|
767
763
|
|
|
768
|
-
return
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
764
|
+
return await runProvision(
|
|
765
|
+
(payload || {}) as Parameters<typeof runProvision>[0],
|
|
766
|
+
{
|
|
767
|
+
execute: async (statement) => opsDb.execute(sql.raw(statement)),
|
|
768
|
+
connection,
|
|
769
|
+
connectProbe: async (url) => {
|
|
770
|
+
const { default: postgres } = await import('postgres');
|
|
771
|
+
// 'prefer' handles both TLS-required RDS and plain local Postgres.
|
|
772
|
+
const probe = postgres(url, { max: 1, connect_timeout: 5, ssl: 'prefer' });
|
|
773
|
+
try {
|
|
774
|
+
await probe`SELECT 1`;
|
|
775
|
+
await probe.unsafe('CREATE TEMP TABLE _everystack_provision_probe (x int)');
|
|
776
|
+
await probe.unsafe('DROP TABLE _everystack_provision_probe');
|
|
777
|
+
} finally {
|
|
778
|
+
await probe.end({ timeout: 1 });
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
);
|
|
774
783
|
};
|
|
775
784
|
|
|
776
785
|
// --- db:backup:probe — is the pg_dump layer attached, and does it match the server? ---
|
package/src/provision.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runProvision — the db:provision orchestration: both sides of the credential split,
|
|
3
|
+
* in the only safe order.
|
|
4
|
+
*
|
|
5
|
+
* The split has two logins: the least-privilege `authenticator` the API connects as
|
|
6
|
+
* (DATABASE_URL), and the operator `migrator` that owns migrations/DDL
|
|
7
|
+
* (ADMIN_DATABASE_URL). Provisioning only the first half strands the operator path on
|
|
8
|
+
* the linked Postgres master — and for a BYO-database app (no linked component) the
|
|
9
|
+
* operator fallback bottoms out in DATABASE_URL itself, so rewriting it to the
|
|
10
|
+
* authenticator saws off the branch provision sits on: the next ops invoke (including
|
|
11
|
+
* the re-run that would fix it) dies "permission denied".
|
|
12
|
+
*
|
|
13
|
+
* Hence the ordering contract, pinned by __tests__/provision.test.ts:
|
|
14
|
+
* 1. role chain (NOLOGIN tiers + authenticator role, no password)
|
|
15
|
+
* 2. admin role + admin password
|
|
16
|
+
* 3. VERIFY the admin login with a real connection
|
|
17
|
+
* 4. only then the authenticator password — a failed verification aborts the swap.
|
|
18
|
+
*
|
|
19
|
+
* Dependency-injected (execute / connection / connectProbe) so the ordering is testable
|
|
20
|
+
* without a database; plugin.ts wires the real deps.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { generateRoleChainSQL, generateAdminRoleSQL } from './role-chain';
|
|
24
|
+
|
|
25
|
+
export interface ProvisionPayload {
|
|
26
|
+
/** Password for the least-privilege API login. Alphanumeric only. */
|
|
27
|
+
authPassword?: string;
|
|
28
|
+
/** Password for the operator login. Alphanumeric only. Absent = today's half-split. */
|
|
29
|
+
adminPassword?: string;
|
|
30
|
+
loginRole?: string;
|
|
31
|
+
adminRole?: string;
|
|
32
|
+
appRoles?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProvisionDeps {
|
|
36
|
+
/** Run one SQL string on the (privileged) provisioning connection. */
|
|
37
|
+
execute: (sql: string) => Promise<unknown>;
|
|
38
|
+
/** host/port/database for the verification URL; null when unresolvable. */
|
|
39
|
+
connection?: { host: string; port?: number | string; database: string } | null;
|
|
40
|
+
/** Real login attempt as the admin role — resolves on success, throws on failure. */
|
|
41
|
+
connectProbe?: (url: string) => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ProvisionResult {
|
|
45
|
+
provisioned?: boolean;
|
|
46
|
+
loginRole?: string;
|
|
47
|
+
adminRole?: string;
|
|
48
|
+
appRoles?: string[];
|
|
49
|
+
passwordSet?: boolean;
|
|
50
|
+
adminPasswordSet?: boolean;
|
|
51
|
+
/** true = login proven; null = no way to probe (unverified); absent = no admin requested. */
|
|
52
|
+
adminVerified?: boolean | null;
|
|
53
|
+
error?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ALNUM = /^[A-Za-z0-9]+$/;
|
|
57
|
+
|
|
58
|
+
export async function runProvision(
|
|
59
|
+
payload: ProvisionPayload,
|
|
60
|
+
deps: ProvisionDeps,
|
|
61
|
+
): Promise<ProvisionResult> {
|
|
62
|
+
const { authPassword, adminPassword, loginRole, adminRole, appRoles } = payload;
|
|
63
|
+
|
|
64
|
+
// Passwords are generated (RandomPassword special:false / hex) — reject anything else
|
|
65
|
+
// BEFORE any DDL runs, so nothing untrusted is interpolated into an ALTER ROLE literal.
|
|
66
|
+
if (authPassword && !ALNUM.test(authPassword)) {
|
|
67
|
+
return { error: 'authPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
|
|
68
|
+
}
|
|
69
|
+
if (adminPassword && !ALNUM.test(adminPassword)) {
|
|
70
|
+
return { error: 'adminPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let roleSql: string;
|
|
74
|
+
let adminSql: string | null = null;
|
|
75
|
+
try {
|
|
76
|
+
roleSql = generateRoleChainSQL({ loginRole, appRoles });
|
|
77
|
+
if (adminPassword) adminSql = generateAdminRoleSQL({ adminRole });
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
return { error: err?.message || String(err) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await deps.execute(roleSql);
|
|
83
|
+
|
|
84
|
+
const login = loginRole ?? 'authenticator';
|
|
85
|
+
const result: ProvisionResult = {
|
|
86
|
+
provisioned: true,
|
|
87
|
+
loginRole: login,
|
|
88
|
+
appRoles: appRoles ?? ['anon', 'authenticated', 'admin'],
|
|
89
|
+
passwordSet: false,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Admin side FIRST — created and verified before the authenticator swap can happen.
|
|
93
|
+
if (adminPassword && adminSql) {
|
|
94
|
+
const admin = adminRole ?? 'migrator';
|
|
95
|
+
await deps.execute(adminSql);
|
|
96
|
+
await deps.execute(`ALTER ROLE ${admin} WITH LOGIN PASSWORD '${adminPassword}';`);
|
|
97
|
+
result.adminRole = admin;
|
|
98
|
+
result.adminPasswordSet = true;
|
|
99
|
+
|
|
100
|
+
if (deps.connection && deps.connectProbe) {
|
|
101
|
+
const { host, port, database } = deps.connection;
|
|
102
|
+
const url = `postgresql://${admin}:${adminPassword}@${host}:${port ?? 5432}/${database}`;
|
|
103
|
+
try {
|
|
104
|
+
await deps.connectProbe(url);
|
|
105
|
+
result.adminVerified = true;
|
|
106
|
+
} catch (err: any) {
|
|
107
|
+
// The whole point of the ordering: a broken admin login must abort the swap,
|
|
108
|
+
// or a BYO-database app loses its only privileged path.
|
|
109
|
+
return {
|
|
110
|
+
error:
|
|
111
|
+
`admin login verification failed for role '${admin}': ${err?.message || String(err)} — ` +
|
|
112
|
+
'the authenticator password was NOT set and DATABASE_URL must not be rewritten.',
|
|
113
|
+
adminRole: admin,
|
|
114
|
+
adminVerified: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
result.adminVerified = null; // no way to probe — caller should verify via db:doctor
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Authenticator LAST — only reached when the admin side (if requested) held up.
|
|
123
|
+
if (authPassword) {
|
|
124
|
+
await deps.execute(`ALTER ROLE ${login} WITH LOGIN PASSWORD '${authPassword}';`);
|
|
125
|
+
result.passwordSet = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return result;
|
|
129
|
+
}
|
package/src/role-chain.ts
CHANGED
|
@@ -73,3 +73,35 @@ export function generateRoleChainSQL(options: RoleChainOptions = {}): string {
|
|
|
73
73
|
|
|
74
74
|
return lines.join('\n') + '\n';
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
export interface AdminRoleOptions {
|
|
78
|
+
/**
|
|
79
|
+
* The LOGIN role operator tasks (migrate/seed/DDL/console) connect as — the OTHER half
|
|
80
|
+
* of the credential split, so the master credential stops being the working credential.
|
|
81
|
+
* INHERIT (unlike the authenticator): its power comes from owner-role membership, and it
|
|
82
|
+
* must hold it ambiently to run DDL over owner-created objects. Default 'migrator'.
|
|
83
|
+
*/
|
|
84
|
+
adminRole?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Generate idempotent DDL for the operator (admin) login role. Membership is granted BOTH
|
|
89
|
+
* ways with the CURRENT user (the master/owner running the provision): owner → admin gives
|
|
90
|
+
* the admin ownership rights over every owner-created object (no REASSIGN OWNED), and
|
|
91
|
+
* admin → owner keeps the owner able to manage objects the admin creates later. The grants
|
|
92
|
+
* are guarded so a RE-provision running AS the admin role (post-split, the ops connection
|
|
93
|
+
* is this role) never attempts the self-grant Postgres hard-errors on. Does NOT set a
|
|
94
|
+
* password — that's `db:provision`'s job, from a deploy-generated value.
|
|
95
|
+
*/
|
|
96
|
+
export function generateAdminRoleSQL(options: AdminRoleOptions = {}): string {
|
|
97
|
+
const adminRole = options.adminRole ?? 'migrator';
|
|
98
|
+
assertIdent(adminRole, 'adminRole');
|
|
99
|
+
|
|
100
|
+
const lines: string[] = [
|
|
101
|
+
`DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${adminRole}') THEN CREATE ROLE ${adminRole} LOGIN INHERIT NOBYPASSRLS; END IF; END $$;`,
|
|
102
|
+
`ALTER ROLE ${adminRole} WITH LOGIN INHERIT NOBYPASSRLS;`,
|
|
103
|
+
`DO $$ BEGIN IF current_user <> '${adminRole}' THEN EXECUTE format('GRANT %I TO ${adminRole}', current_user); EXECUTE format('GRANT ${adminRole} TO %I', current_user); END IF; END $$;`,
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
return lines.join('\n') + '\n';
|
|
107
|
+
}
|