@everystack/cli 0.2.37 → 0.2.40

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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `everystack db:authz:pull` / `db:authz:diff` — the Authorization Contract commands.
3
+ *
4
+ * db:authz:pull --stage <name> introspect the live authz → write authz/*.contract.json
5
+ * db:authz:diff --stage <name> validate the live DB against the committed contract
6
+ *
7
+ * The contract is the brownfield slice of the Model (docs/plans/v3-authz-contract.md):
8
+ * authorization that lived only in scattered migrations becomes a reviewable file you
9
+ * can diff a deployed database against. `pull` is the export + on-ramp; `diff` is the
10
+ * audit and the CI gate (non-zero exit on any drift).
11
+ *
12
+ * Both introspect through the ops Lambda `db:query` action (read-only SQL), the same
13
+ * path security:audit uses. The core (introspect/assemble/diff) is backend-agnostic and
14
+ * pure; only the runner here is AWS-bound.
15
+ */
16
+
17
+ import path from 'node:path';
18
+ import {
19
+ introspectContract,
20
+ diffContracts,
21
+ type QueryRunner,
22
+ type AuthzContract,
23
+ } from '../authz-contract.js';
24
+ import {
25
+ buildProbeSql,
26
+ PROBE_SELECT_SQL,
27
+ probeRoles,
28
+ toProbeResult,
29
+ evaluateRedTeam,
30
+ GRANT_COMPLETENESS_SQL,
31
+ toGrantGap,
32
+ } from '../authz-redteam.js';
33
+ import { writeContract, loadContract } from '../authz-contract-io.js';
34
+ import { renderContractMarkdown } from '../authz-render.js';
35
+ import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
36
+ import { resolveConfig, opsFunction } from '../config.js';
37
+ import { invokeAction } from '../aws.js';
38
+ import { step, success, fail, info, warn } from '../output.js';
39
+
40
+ const DEFAULT_DIR = 'authz';
41
+
42
+ /** Map a FUNCTIONS_SQL row to the minimal shape the contract records. */
43
+ const mapFunctionRow = (row: any) => {
44
+ const d = catalogFunctionToDescriptor(row);
45
+ return {
46
+ schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath,
47
+ owner: d.owner, ownerBypassesRls: d.ownerBypassesRls,
48
+ };
49
+ };
50
+
51
+ /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
52
+ function lambdaRunner(region: string, fn: string): QueryRunner {
53
+ return async (sql: string) => {
54
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
55
+ if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
56
+ return result?.rows ?? [];
57
+ };
58
+ }
59
+
60
+ async function introspectStage(flags: Record<string, string>): Promise<AuthzContract> {
61
+ step('Resolving deployed config...');
62
+ const config = await resolveConfig(flags.stage);
63
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
64
+ step('Introspecting authorization (rls + grants + policies + secdef)...');
65
+ const runner = lambdaRunner(config.region, opsFunction(config));
66
+ return introspectContract(runner, mapFunctionRow, FUNCTIONS_SQL);
67
+ }
68
+
69
+ export async function dbAuthzPullCommand(flags: Record<string, string>): Promise<void> {
70
+ const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
71
+ let contract: AuthzContract;
72
+ try {
73
+ contract = await introspectStage(flags);
74
+ } catch (err: any) {
75
+ fail(err.message);
76
+ info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
77
+ process.exit(1);
78
+ }
79
+
80
+ const files = await writeContract(dir, contract);
81
+ const policies = contract.tables.reduce((n, t) => n + t.policies.length, 0);
82
+ console.log('');
83
+ success(`Wrote ${files.length} file(s) to ${dir}`);
84
+ info(`${contract.tables.length} table(s), ${policies} policy(ies), ${contract.functions.length} SECDEF function(s).`);
85
+ console.log('');
86
+ warn('pull OVERWROTE the committed contract with the live state — `git diff` IS your drift report.');
87
+ warn('Do not run `db:authz:diff` to find drift right after a pull: it compares the live DB to what');
88
+ warn('you just wrote, so it is always clean. Review `git diff`, then commit only if the change is intended.');
89
+ info('Note: column exposure (hidden/protected) is handler-layer and not part of the DB pull.');
90
+ process.exit(0);
91
+ }
92
+
93
+ export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise<void> {
94
+ const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
95
+
96
+ let declared: AuthzContract;
97
+ let live: AuthzContract;
98
+ try {
99
+ declared = await loadContract(dir);
100
+ live = await introspectStage(flags);
101
+ } catch (err: any) {
102
+ fail(err.message);
103
+ process.exit(1);
104
+ }
105
+
106
+ if (declared.tables.length === 0 && declared.functions.length === 0) {
107
+ fail(`No committed contract found in ${dir}. Run \`everystack db:authz:pull --stage <name>\` first.`);
108
+ process.exit(1);
109
+ }
110
+
111
+ const findings = diffContracts(declared, live);
112
+ console.log('');
113
+ if (findings.length === 0) {
114
+ success(`db:authz:diff — live database matches the declared contract (${declared.tables.length} tables)`);
115
+ process.exit(0);
116
+ }
117
+
118
+ fail(`db:authz:diff — ${findings.length} drift finding(s): the live database does NOT match the declared contract`);
119
+ console.log('');
120
+ for (const f of findings) {
121
+ warn(`[${f.kind}] ${f.subject} — ${f.detail}`);
122
+ }
123
+ console.log('');
124
+ info('Either the live DB drifted (reconcile it) or the change is intended (re-pull + commit the contract delta).');
125
+ process.exit(1);
126
+ }
127
+
128
+ export async function dbAuthzReportCommand(flags: Record<string, string>): Promise<void> {
129
+ const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
130
+ let contract: AuthzContract;
131
+ try {
132
+ contract = await loadContract(dir);
133
+ } catch (err: any) {
134
+ fail(err.message);
135
+ process.exit(1);
136
+ }
137
+ if (contract.tables.length === 0) {
138
+ fail(`No committed contract found in ${dir}. Run \`everystack db:authz:pull --stage <name>\` first.`);
139
+ process.exit(1);
140
+ }
141
+ // Render from the committed contract — no database needed. Print to stdout so it can
142
+ // be piped or redirected; `pull` writes the same content to authz/AUTHORIZATION.md.
143
+ console.log(renderContractMarkdown(contract));
144
+ process.exit(0);
145
+ }
146
+
147
+ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise<void> {
148
+ const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
149
+
150
+ let contract: AuthzContract;
151
+ try {
152
+ contract = await loadContract(dir);
153
+ } catch (err: any) {
154
+ fail(err.message);
155
+ process.exit(1);
156
+ }
157
+ if (contract.tables.length === 0) {
158
+ fail(`No committed contract found in ${dir}. Run \`everystack db:authz:pull --stage <name>\` first.`);
159
+ process.exit(1);
160
+ }
161
+
162
+ let rows: any[];
163
+ let gapRows: any[];
164
+ try {
165
+ step('Resolving deployed config...');
166
+ const config = await resolveConfig(flags.stage);
167
+ const fn = opsFunction(config);
168
+ info(`Region: ${config.region}, Function: ${fn}`);
169
+ step('Red-teaming enforcement (SET ROLE + attempt per role/table/command, rolled back)...');
170
+ const setup = buildProbeSql(probeRoles(contract), contract.tables.map((t) => t.table));
171
+ const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read: PROBE_SELECT_SQL });
172
+ if (result?.error) throw new Error(result.error);
173
+ rows = result?.rows ?? [];
174
+ step('Checking SECDEF grant-completeness (owner can EXECUTE every helper it calls)...');
175
+ const gc: any = await invokeAction(config.region, fn, 'db:query', { sql: GRANT_COMPLETENESS_SQL });
176
+ if (gc?.error) throw new Error(gc.error);
177
+ gapRows = gc?.rows ?? [];
178
+ } catch (err: any) {
179
+ fail(`db:authz:test failed: ${err.message}`);
180
+ info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe.');
181
+ process.exit(1);
182
+ }
183
+
184
+ const findings = evaluateRedTeam(contract, rows.map(toProbeResult));
185
+ const holes = findings.filter((f) => f.severity === 'hole');
186
+ const broken = findings.filter((f) => f.severity === 'broken');
187
+ const unprobed = findings.filter((f) => f.severity === 'unprobed');
188
+ const gaps = gapRows.map(toGrantGap);
189
+
190
+ console.log('');
191
+ for (const f of holes) fail(`[HOLE] ${f.detail}`);
192
+ for (const f of broken) warn(`[BROKEN] ${f.detail}`);
193
+ for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
194
+ if (unprobed.length) {
195
+ const roles = [...new Set(unprobed.map((f) => f.role))].join(', ');
196
+ info(`[unprobed] could not SET ROLE into: ${roles} — grant the operator membership to cover them.`);
197
+ }
198
+ console.log('');
199
+
200
+ if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
201
+ success(`db:authz:test — enforcement matches the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
202
+ process.exit(0);
203
+ }
204
+ fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). The database does not enforce the contract.`);
205
+ process.exit(1);
206
+ }
@@ -9,6 +9,34 @@ import { resolveConfig, opsFunction } from '../config.js';
9
9
  import { invokeAction } from '../aws.js';
10
10
  import { step, success, fail, info, warn } from '../output.js';
11
11
 
12
+ /** Secret-store keys that may hold the privileged (operator) connection URL,
13
+ * in precedence order. Names vary by deployment; --secret-name overrides. */
14
+ const ADMIN_URL_KEYS = ['ADMIN_DATABASE_URL', 'AdminDatabaseUrl', 'ADMIN_URL'];
15
+
16
+ /**
17
+ * Parse a postgres connection URL into psql's PG* environment variables.
18
+ *
19
+ * Passing the credential via env (not argv) keeps the password out of the
20
+ * process table and shell history. Pure and side-effect free — it never logs;
21
+ * callers must never log the result either (it contains PGPASSWORD).
22
+ */
23
+ export function pgEnvFromUrl(url: string): Record<string, string> {
24
+ const u = new URL(url);
25
+ if (!/^postgres(ql)?:$/.test(u.protocol)) {
26
+ throw new Error(`Not a postgres connection URL (protocol: ${u.protocol})`);
27
+ }
28
+ const env: Record<string, string> = {};
29
+ if (u.hostname) env.PGHOST = decodeURIComponent(u.hostname);
30
+ if (u.port) env.PGPORT = u.port;
31
+ if (u.username) env.PGUSER = decodeURIComponent(u.username);
32
+ if (u.password) env.PGPASSWORD = decodeURIComponent(u.password);
33
+ const database = u.pathname.replace(/^\//, '');
34
+ if (database) env.PGDATABASE = decodeURIComponent(database);
35
+ const sslmode = u.searchParams.get('sslmode');
36
+ if (sslmode) env.PGSSLMODE = sslmode;
37
+ return env;
38
+ }
39
+
12
40
  export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
13
41
  step('Resolving deployed config...');
14
42
  let config;
@@ -115,6 +143,130 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
115
143
  }
116
144
  }
117
145
 
146
+ export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
147
+ if (!flags.stage) {
148
+ fail('--stage is required for db:provision (it writes the DATABASE_URL secret for that stage)');
149
+ process.exit(1);
150
+ }
151
+
152
+ step('Resolving deployed config...');
153
+ let config;
154
+ try {
155
+ config = await resolveConfig(flags.stage);
156
+ } catch (err: any) {
157
+ fail(err.message);
158
+ process.exit(1);
159
+ }
160
+
161
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
162
+ info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
163
+ step('Provisioning roles...');
164
+
165
+ // Generate the login password and keep it in memory only — it is written straight into
166
+ // the secret store and is NEVER printed, logged, or returned to a human.
167
+ const { randomBytes } = await import('node:crypto');
168
+ const authPassword = randomBytes(24).toString('hex');
169
+
170
+ let result: any;
171
+ try {
172
+ result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword });
173
+ } catch (err: any) {
174
+ fail(`db:provision failed: ${err.message}`);
175
+ info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
176
+ process.exit(1);
177
+ }
178
+ if (result?.error) {
179
+ fail(`db:provision failed: ${result.error}`);
180
+ process.exit(1);
181
+ }
182
+ success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
183
+
184
+ // Resolve host/port/db (not secret) to build the connection string in memory.
185
+ let conn: any;
186
+ try {
187
+ conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
188
+ } catch {
189
+ /* handled below */
190
+ }
191
+ if (!conn?.host) {
192
+ fail('Could not resolve the database host to build DATABASE_URL.');
193
+ info('Provide a db:psql connectionInfo callback, or set the DATABASE_URL secret yourself.');
194
+ process.exit(1);
195
+ }
196
+ const url = `postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`;
197
+
198
+ // Write the secret directly to the SST secret store — never display it.
199
+ step('Writing the DATABASE_URL secret (not displayed)...');
200
+ try {
201
+ const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
202
+ const { parseAppName } = await import('../discover.js');
203
+ const ctx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
204
+ const secrets = await loadSstSecrets(ctx);
205
+ secrets.DATABASE_URL = url;
206
+ await putSstSecrets(ctx, secrets);
207
+ } catch (err: any) {
208
+ fail(`Failed to write the DATABASE_URL secret: ${err.message}`);
209
+ info('Ensure your IAM role can write the SST secret store (s3:PutObject, kms:Encrypt).');
210
+ process.exit(1);
211
+ }
212
+
213
+ success(`DATABASE_URL secret set for stage "${flags.stage}" → role ${result.loginRole}. No credential was displayed.`);
214
+ console.log('');
215
+ info('Finish the wiring in sst.config.ts (one-time): declare `new sst.Secret("DatabaseUrl")`,');
216
+ info('link it to the Api function, and remove the raw Postgres component from the Api link');
217
+ info('(keep it on the ops/worker functions). Then redeploy.');
218
+ info(`Verify: everystack db:doctor --stage ${flags.stage}`);
219
+ }
220
+
221
+ export async function dbDoctorCommand(flags: Record<string, string>): Promise<void> {
222
+ step('Resolving deployed config...');
223
+ let config;
224
+ try {
225
+ config = await resolveConfig(flags.stage);
226
+ } catch (err: any) {
227
+ fail(err.message);
228
+ process.exit(1);
229
+ }
230
+
231
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
232
+ step('Probing database connections (api + operator)...');
233
+
234
+ let report: any;
235
+ try {
236
+ report = await invokeAction(config.region, opsFunction(config), 'db:doctor', {});
237
+ } catch (err: any) {
238
+ fail(`db:doctor failed: ${err.message}`);
239
+ info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
240
+ process.exit(1);
241
+ }
242
+
243
+ if (report?.error) {
244
+ fail(`db:doctor failed: ${report.error}`);
245
+ process.exit(1);
246
+ }
247
+
248
+ console.log('');
249
+ info(`api connection (DATABASE_URL): role=${report.api.role} superuser=${report.api.isSuperuser} bypassrls=${report.api.bypassRls}`);
250
+ info(`operator connection (ADMIN_URL): role=${report.admin.role} superuser=${report.admin.isSuperuser} bypassrls=${report.admin.bypassRls}`);
251
+ console.log('');
252
+
253
+ for (const c of report.checks as Array<{ name: string; status: string; detail: string; remediation?: string }>) {
254
+ if (c.status === 'pass') success(`${c.name} — ${c.detail}`);
255
+ else if (c.status === 'warn') warn(`${c.name} — ${c.detail}`);
256
+ else fail(`${c.name} — ${c.detail}`);
257
+ if (c.status === 'fail' && c.remediation) info(` fix: ${c.remediation}`);
258
+ }
259
+
260
+ console.log('');
261
+ if (report.ok) {
262
+ success('db:doctor — database is least-privilege and RLS-subject');
263
+ process.exit(0);
264
+ } else {
265
+ fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
266
+ process.exit(1);
267
+ }
268
+ }
269
+
118
270
  export async function dbPsqlCommand(flags: Record<string, string>): Promise<void> {
119
271
  step('Resolving deployed config...');
120
272
  let config;
@@ -180,9 +332,67 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
180
332
  }
181
333
  }
182
334
 
183
- // Interactive mode: not supported for private RDS
184
- fail('Interactive psql mode not supported for private RDS instances.');
185
- info('Use -c flag to execute SQL queries via Lambda: everystack db:psql -c "SELECT * FROM users;"');
186
- info('The Lambda function executes queries from within the VPC where it can reach the database.');
187
- process.exit(1);
335
+ // Interactive mode break-glass admin psql.
336
+ //
337
+ // Authorization IS your AWS identity: resolving the admin URL requires reading
338
+ // the SST secret store (SSM passphrase + S3 blob + KMS decrypt). If your
339
+ // profile can't, you don't get a session — the permission is the policy, no
340
+ // confirmation prompt. CloudTrail records the decrypt, so the access is
341
+ // audited for free. The URL is resolved in-process and handed to psql via PG*
342
+ // env vars: it never touches your terminal, argv, clipboard, or psql history.
343
+ if (!flags.stage) {
344
+ fail('--stage is required for an interactive session. Example: everystack db:psql --stage production');
345
+ info('Or run a one-off query via Lambda: everystack db:psql -c "SELECT 1" --stage production');
346
+ process.exit(1);
347
+ }
348
+
349
+ step('Resolving the admin connection from the secret store...');
350
+ let secrets: Record<string, string>;
351
+ try {
352
+ const { loadSstSecrets } = await import('../utils/secrets.js');
353
+ const { parseAppName } = await import('../discover.js');
354
+ const appName = await parseAppName();
355
+ secrets = await loadSstSecrets({ appName, stage: flags.stage, region: config.region });
356
+ } catch (err: any) {
357
+ fail('Could not read the secret store — your AWS profile lacks secrets access.');
358
+ info('db:psql opens an ADMIN session; it requires SSM + S3 + KMS access to the SST state.');
359
+ info('If you should have it, check your IAM permissions; otherwise you should not be in psql.');
360
+ process.exit(1);
361
+ }
362
+
363
+ const keyName = flags['secret-name'] || ADMIN_URL_KEYS.find((k) => secrets[k]);
364
+ const adminUrl = keyName ? secrets[keyName] : undefined;
365
+ if (!adminUrl) {
366
+ fail(`No admin database URL found for stage "${flags.stage}".`);
367
+ // Names only — never values.
368
+ info(`Looked for: ${ADMIN_URL_KEYS.join(', ')}. Available secret names: ${Object.keys(secrets).join(', ') || '(none)'}.`);
369
+ info('Provision one with `everystack db:provision`, or point at an existing key with --secret-name <KEY>.');
370
+ process.exit(1);
371
+ }
372
+
373
+ let pgEnv: Record<string, string>;
374
+ try {
375
+ pgEnv = pgEnvFromUrl(adminUrl);
376
+ } catch {
377
+ fail(`The resolved ${keyName} is not a valid postgres connection URL.`);
378
+ process.exit(1);
379
+ }
380
+
381
+ // Confirm the target (host/user/db are infra identifiers, not the credential);
382
+ // the password is in PGPASSWORD and is never displayed.
383
+ success(
384
+ `Admin psql → ${flags.stage} as ${pgEnv.PGUSER ?? '?'}@${pgEnv.PGHOST ?? '?'}/${pgEnv.PGDATABASE ?? ''} (credential not displayed)`,
385
+ );
386
+
387
+ const { spawn } = await import('node:child_process');
388
+ const child = spawn('psql', [], { stdio: 'inherit', env: { ...process.env, ...pgEnv } });
389
+ child.on('error', (err: any) => {
390
+ if (err.code === 'ENOENT') {
391
+ fail('psql not found on PATH. Install the PostgreSQL client to use interactive mode.');
392
+ } else {
393
+ fail(`Failed to launch psql: ${err.message}`);
394
+ }
395
+ process.exit(1);
396
+ });
397
+ child.on('close', (code) => process.exit(code ?? 0));
188
398
  }
@@ -133,6 +133,62 @@ function resolveFunctionForOrigin(
133
133
  }
134
134
  }
135
135
 
136
+ /** One FilterLogEvents page (the subset of the AWS shape this command reads). */
137
+ interface FilterLogEventsPage {
138
+ events?: Array<{ eventId?: string; timestamp?: number; message?: string }>;
139
+ nextToken?: string;
140
+ }
141
+
142
+ /** Carries the tail window across polls so a re-query can't reprint events. */
143
+ export interface TailState {
144
+ /** Highest event timestamp printed so far. Only moves forward. */
145
+ lastTimestamp: number;
146
+ /** eventId → timestamp for events at the boundary millisecond — the only ones
147
+ * an inclusive-startTime re-query can return again. Pruned each drain so it
148
+ * never grows past one millisecond's worth of events. */
149
+ seen: Map<string, number>;
150
+ }
151
+
152
+ /**
153
+ * Drain one poll's worth of pages, printing only events not seen before and
154
+ * advancing the window forward.
155
+ *
156
+ * The loop bug this fixes: FilterLogEvents `startTime` is INCLUSIVE, so a poll
157
+ * with `startTime = lastTimestamp` re-returns every event sharing that newest
158
+ * millisecond. The old code advanced `lastTimestamp` only on a strictly-greater
159
+ * comparison and never deduped, so the boundary batch reprinted on every 2s poll
160
+ * forever. Here we dedup by `eventId` and advance to the max timestamp, then
161
+ * prune `seen` to just the boundary millisecond (the only ids that can recur).
162
+ *
163
+ * Exported for unit testing without the AWS SDK or the setTimeout poll loop.
164
+ */
165
+ export async function drainLogEvents(
166
+ send: (params: { startTime: number; nextToken?: string }) => Promise<FilterLogEventsPage>,
167
+ state: TailState,
168
+ emit: (line: string) => void,
169
+ ): Promise<void> {
170
+ let nextToken: string | undefined;
171
+ do {
172
+ const page = await send({ startTime: state.lastTimestamp, nextToken });
173
+ for (const event of page.events ?? []) {
174
+ const id = event.eventId;
175
+ const ts = event.timestamp ?? state.lastTimestamp;
176
+ if (id && state.seen.has(id)) continue; // already printed this one
177
+ const timestamp = event.timestamp ? new Date(event.timestamp).toISOString() : '';
178
+ emit(`[${timestamp}] ${(event.message || '').trimEnd()}`);
179
+ if (id) state.seen.set(id, ts);
180
+ if (ts > state.lastTimestamp) state.lastTimestamp = ts;
181
+ }
182
+ nextToken = page.nextToken;
183
+ } while (nextToken);
184
+
185
+ // Only events at exactly lastTimestamp can be returned again (startTime is
186
+ // inclusive); drop everything older so `seen` stays bounded.
187
+ for (const [id, ts] of state.seen) {
188
+ if (ts < state.lastTimestamp) state.seen.delete(id);
189
+ }
190
+ }
191
+
136
192
  export async function logsTailCommand(flags: Record<string, string>): Promise<void> {
137
193
  step('Resolving deployed config...');
138
194
  let config;
@@ -174,42 +230,20 @@ export async function logsTailCommand(flags: Record<string, string>): Promise<vo
174
230
 
175
231
  success(`Streaming logs from ${logGroupName}...\n`);
176
232
 
177
- // Poll for new logs every 2 seconds
178
- let nextToken: string | undefined;
179
- let lastTimestamp = startTime;
233
+ // Poll for new logs every 2 seconds. State carries the window + dedup set
234
+ // across polls see drainLogEvents for why both are required.
235
+ const state: TailState = { lastTimestamp: startTime, seen: new Map() };
236
+ const send = (params: { startTime: number; nextToken?: string }) =>
237
+ client.send(new FilterLogEventsCommand({
238
+ logGroupName,
239
+ startTime: params.startTime,
240
+ filterPattern,
241
+ ...(params.nextToken ? { nextToken: params.nextToken } : {}),
242
+ }));
180
243
 
181
244
  const poll = async () => {
182
245
  try {
183
- const params: any = {
184
- logGroupName,
185
- startTime: lastTimestamp,
186
- filterPattern,
187
- };
188
-
189
- if (nextToken) {
190
- params.nextToken = nextToken;
191
- }
192
-
193
- const response = await client.send(new FilterLogEventsCommand(params));
194
-
195
- if (response.events && response.events.length > 0) {
196
- for (const event of response.events) {
197
- if (event.timestamp && event.timestamp > lastTimestamp) {
198
- lastTimestamp = event.timestamp;
199
- }
200
-
201
- const timestamp = event.timestamp
202
- ? new Date(event.timestamp).toISOString()
203
- : '';
204
- const message = event.message || '';
205
-
206
- console.log(`[${timestamp}] ${message.trimEnd()}`);
207
- }
208
- }
209
-
210
- nextToken = response.nextToken;
211
-
212
- // Continue polling
246
+ await drainLogEvents(send, state, (line) => console.log(line));
213
247
  setTimeout(poll, 2000);
214
248
  } catch (err: any) {
215
249
  if (err.name === 'ResourceNotFoundException') {