@everystack/cli 0.4.5 → 0.4.6

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/cli",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -292,13 +292,19 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
292
292
  for (const warning of plan.warnings) info(` ⚠ ${warning}`);
293
293
  console.log('');
294
294
  info('Finish the wiring in sst.config.ts (one-time):');
295
- info(' API: declare `new sst.Secret("DatabaseUrl")` and link it to the Api function; remove the');
296
- info(' raw Postgres component from the Api link (keep it on the ops/worker functions).');
295
+ info(' API — PRECONDITION: your handler must escalate per request (pgSettings + SET ROLE /');
296
+ info(' withRole). The authenticator holds no privileges of its own (NOINHERIT); a handler');
297
+ info(' that never escalates cannot see the schema — every endpoint fails "relation does');
298
+ info(' not exist". If unsure, do NOT flip the API yet.');
299
+ info(' Flip in TWO deploys: (1) declare `new sst.Secret("DatabaseUrl")` and link it to the');
300
+ info(' Api function, KEEPING the raw Postgres component linked (the explicit secret already');
301
+ info(' outranks it; the component is your instant rollback). Verify with real requests +');
302
+ info(` db:doctor. (2) Then remove the raw Postgres component from the Api link (keep it on`);
303
+ info(' the ops/worker functions) and redeploy.');
297
304
  if (result.adminRole) {
298
305
  info(' Ops: declare `new sst.Secret("AdminDatabaseUrl", "")` and link it to the Ops function —');
299
306
  info(` operator tasks then run as ${result.adminRole}, not the Postgres master.`);
300
307
  }
301
- info('Then redeploy.');
302
308
  info(`Verify: everystack db:doctor --stage ${flags.stage}`);
303
309
  }
304
310
 
@@ -99,6 +99,104 @@ async function probeFunction(region: string, functionName: string): Promise<Prob
99
99
  }
100
100
  }
101
101
 
102
+ /** A minimal APIGW v2 GET event — what a Function URL delivers for a real request. */
103
+ export function syntheticGetEvent(path: string, queryString = ''): {
104
+ version: string;
105
+ routeKey: string;
106
+ rawPath: string;
107
+ rawQueryString: string;
108
+ headers: Record<string, string>;
109
+ requestContext: { http: { method: string; path: string; protocol: string; sourceIp: string; userAgent: string } };
110
+ isBase64Encoded: boolean;
111
+ } {
112
+ return {
113
+ version: '2.0',
114
+ routeKey: '$default',
115
+ rawPath: path,
116
+ rawQueryString: queryString,
117
+ headers: { 'user-agent': 'everystack-deploy-probe' },
118
+ requestContext: {
119
+ http: { method: 'GET', path, protocol: 'HTTP/1.1', sourceIp: '127.0.0.1', userAgent: 'everystack-deploy-probe' },
120
+ },
121
+ isBase64Encoded: false,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Classify one table GET driven through the api function. `_health` proves BOOT; it
127
+ * touches no tables — a stage whose API role cannot see the schema (a NOINHERIT
128
+ * authenticator behind a handler that never escalates) boots fine and 500s on every
129
+ * real request. A consumer shipped exactly that dark site; this is the check that
130
+ * catches it at deploy time, with the failure named.
131
+ */
132
+ export interface ReadProbeVerdict {
133
+ verdict: 'reads-ok' | 'auth-gated' | 'dark-site' | 'failed' | 'no-surface';
134
+ detail?: string;
135
+ }
136
+
137
+ export function classifyReadProbe(response: { statusCode?: number; body?: string }): ReadProbeVerdict {
138
+ const status = response.statusCode ?? 0;
139
+ if (status >= 200 && status < 300) return { verdict: 'reads-ok' };
140
+ if (status === 401 || status === 403) return { verdict: 'auth-gated' };
141
+ if (status === 404) return { verdict: 'no-surface' };
142
+ const body = response.body ?? '';
143
+ if (status >= 500 && /relation .* does not exist/i.test(body)) {
144
+ return {
145
+ verdict: 'dark-site',
146
+ detail:
147
+ 'the API role cannot see the schema — the handler never escalates. The authenticator holds no '
148
+ + 'privileges of its own (NOINHERIT); the handler must SET ROLE per request (pgSettings + withRole). '
149
+ + `Raw response: ${body}`,
150
+ };
151
+ }
152
+ return { verdict: 'failed', detail: body || `HTTP ${status}` };
153
+ }
154
+
155
+ /** Invoke the api function with a synthetic HTTP GET; null when the invoke itself failed. */
156
+ async function invokeHttpGet(
157
+ region: string,
158
+ functionName: string,
159
+ path: string,
160
+ queryString = '',
161
+ ): Promise<{ statusCode?: number; body?: string } | null> {
162
+ try {
163
+ const { LambdaClient, InvokeCommand } = await import('@aws-sdk/client-lambda');
164
+ const client = new LambdaClient({ region });
165
+ const response = await client.send(new InvokeCommand({
166
+ FunctionName: functionName,
167
+ Payload: new TextEncoder().encode(JSON.stringify(syntheticGetEvent(path, queryString))),
168
+ }));
169
+ if (response.FunctionError || !response.Payload) return null;
170
+ const parsed = JSON.parse(new TextDecoder().decode(response.Payload));
171
+ if (parsed && typeof parsed === 'object' && 'statusCode' in parsed) {
172
+ return { statusCode: parsed.statusCode, body: parsed.body };
173
+ }
174
+ return null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Drive one real table read through the api function: GET / (the public table listing)
182
+ * names an exposed table, GET /<table>?limit=1 exercises role → schema → RLS. Best-effort:
183
+ * anything unprobeable stays neutral — only a positively classified failure is fatal.
184
+ */
185
+ async function probeApiRead(region: string, functionName: string): Promise<ReadProbeVerdict | null> {
186
+ const listing = await invokeHttpGet(region, functionName, '/');
187
+ if (!listing || listing.statusCode !== 200 || !listing.body) return null;
188
+ let tables: string[];
189
+ try {
190
+ tables = (JSON.parse(listing.body) as { tables?: string[] }).tables ?? [];
191
+ } catch {
192
+ return null;
193
+ }
194
+ if (tables.length === 0) return null;
195
+ const read = await invokeHttpGet(region, functionName, `/${tables[0]}`, 'limit=1');
196
+ if (!read) return null;
197
+ return classifyReadProbe(read);
198
+ }
199
+
102
200
  /**
103
201
  * Probe the stage's deployed functions and report. Returns whether deploy must fail: a function
104
202
  * that cannot boot (or answers unhealthy) is fatal; a probe that couldn't run is a warning.
@@ -137,6 +235,27 @@ export async function probeDeployedFunctions(
137
235
  info(`Could not probe ${target.label} function (${target.functionName}): ${result.detail}`);
138
236
  break;
139
237
  }
238
+
239
+ // Boot is necessary, not sufficient: drive one table read through the api function so a
240
+ // schema-invisible role (the dark-site shape) fails HERE, named, not on the first user.
241
+ if (target.label === 'api' && (result.verdict === 'healthy' || result.verdict === 'boot-ok')) {
242
+ const read = await probeApiRead(config.region, target.functionName);
243
+ if (read === null) {
244
+ // Nothing conclusive to probe (listing empty/unreadable) — stay neutral.
245
+ } else if (read.verdict === 'reads-ok') {
246
+ success('api read path OK (table read through the handler)');
247
+ } else if (read.verdict === 'auth-gated') {
248
+ info('api read path is auth-gated — boot verified, deeper read needs credentials');
249
+ } else if (read.verdict === 'no-surface') {
250
+ info('api exposes no tables to probe — skipped the read check');
251
+ } else if (read.verdict === 'dark-site') {
252
+ fatal = true;
253
+ fail(`api function is DARK: ${read.detail}`);
254
+ } else {
255
+ fatal = true;
256
+ fail(`api read probe failed: ${read.detail}`);
257
+ }
258
+ }
140
259
  }
141
260
  return { fatal };
142
261
  }