@everystack/server 0.4.0 → 0.4.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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>",
@@ -143,8 +143,8 @@
143
143
  "sst": "4.13.1",
144
144
  "ts-jest": "29.4.9",
145
145
  "typescript": "5.9.3",
146
- "@everystack/auth": "0.4.0",
147
- "@everystack/cli": "0.4.5"
146
+ "@everystack/cli": "0.4.7",
147
+ "@everystack/auth": "0.4.0"
148
148
  },
149
149
  "scripts": {
150
150
  "test": "jest",
package/src/db-doctor.ts CHANGED
@@ -324,8 +324,13 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
324
324
  remediation:
325
325
  'Run `everystack db:provision --stage <stage>` — it creates the least-privilege role chain on this '
326
326
  + 'database (idempotent, creates no database) AND writes the DATABASE_URL secret for you, without ever '
327
- + 'printing the credential. Then in sst.config.ts declare `new sst.Secret("DatabaseUrl")`, link it to the '
328
- + 'API function, and drop the raw Postgres component from the API link (keep it on ops/worker); redeploy. '
327
+ + 'printing the credential. PRECONDITION before flipping the API: the handler must escalate per request '
328
+ + '(pgSettings + SET ROLE / withRole) — the authenticator holds no privileges of its own (NOINHERIT), so a '
329
+ + 'handler that never escalates cannot even see the schema and every endpoint fails "relation does not exist". '
330
+ + 'Then flip in TWO deploys: (1) declare `new sst.Secret("DatabaseUrl")` and link it to the API function while '
331
+ + 'KEEPING the raw Postgres component linked (an explicit DatabaseUrl already outranks it, and the component is '
332
+ + 'your instant rollback — unlink the secret to revert); verify with real requests and `everystack db:doctor`; '
333
+ + '(2) then drop the raw Postgres component from the API link (keep it on ops/worker) and redeploy. '
329
334
  + 'New apps get all of this automatically from the `everystack.Postgres` construct.',
330
335
  });
331
336
  } else {
@@ -359,7 +364,7 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
359
364
  status: accessible ? 'fail' : 'pass',
360
365
  detail: accessible
361
366
  ? `the api connection read "${table}" without setting a role — it carries ambient privileges (likely INHERIT + membership in a USING(true) role). A least-privilege role has no grants of its own and must SET ROLE first.`
362
- : `the api connection cannot read "${table}" without SET ROLE — correct fail-closed behavior`,
367
+ : `the api connection cannot read "${table}" without SET ROLE — correct fail-closed behavior. NOTE the runtime requirement this creates: the handler MUST escalate per request (pgSettings + SET ROLE / withRole); a handler that never escalates fails every endpoint with "relation ... does not exist".`,
363
368
  remediation: accessible
364
369
  ? `Connect the API as a NOINHERIT role with no table grants of its own (e.g. \`authenticator\`), so memberships only apply via SET ROLE: \`ALTER ROLE <api_role> NOINHERIT;\` and revoke any direct table grants. The handler/SSR then escalate per-request via withRole().`
365
370
  : undefined,
package/src/role-chain.ts CHANGED
@@ -85,13 +85,18 @@ export interface AdminRoleOptions {
85
85
  }
86
86
 
87
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.
88
+ * Generate idempotent DDL for the operator (admin) login role. Membership is granted ONE
89
+ * way — owner → admin — giving the admin ownership rights over every owner-created object
90
+ * (no REASSIGN OWNED). Never the reverse: Postgres forbids circular role membership, and
91
+ * the 0.4.0 reciprocal grant broke the SECOND stage on a shared RDS instance (roles are
92
+ * INSTANCE-level; the first stage's `migrator ∈ owner` membership made the reciprocal a
93
+ * cycle — 'role "migrator" is a member of role "postgres"').
94
+ *
95
+ * Adoption over creation: the grant is skipped when the membership already exists (a
96
+ * prior stage or a manual setup made it) and when granting would FORM a cycle (the owner
97
+ * is already a member of the admin role in some prior topology). A re-provision running
98
+ * AS the admin role (post-split, the ops connection is this role) skips the self-grant
99
+ * Postgres hard-errors on. Does NOT set a password — that's `db:provision`'s job.
95
100
  */
96
101
  export function generateAdminRoleSQL(options: AdminRoleOptions = {}): string {
97
102
  const adminRole = options.adminRole ?? 'migrator';
@@ -100,7 +105,7 @@ export function generateAdminRoleSQL(options: AdminRoleOptions = {}): string {
100
105
  const lines: string[] = [
101
106
  `DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${adminRole}') THEN CREATE ROLE ${adminRole} LOGIN INHERIT NOBYPASSRLS; END IF; END $$;`,
102
107
  `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 $$;`,
108
+ `DO $$ BEGIN IF current_user <> '${adminRole}' AND NOT pg_has_role('${adminRole}', current_user, 'MEMBER') AND NOT pg_has_role(current_user, '${adminRole}', 'MEMBER') THEN EXECUTE format('GRANT %I TO ${adminRole}', current_user); END IF; END $$;`,
104
109
  ];
105
110
 
106
111
  return lines.join('\n') + '\n';