fix(scripts): repair onboarding-bulk-reset with A2A user-service endpoint
Rewire Phase 1 to call the user-service POST /api/v1/users/onboarding-reset A2A endpoint (targeting by clerk_id) instead of the per-user authenticated DELETE route, which could not reach all users. A 404 from user-service means the user has no profile (backend-only) — a clean skip that does not block Phase 2. Phase 2 (backend ledger reset via resetOnboardingLedger) now runs ONLY after Phase 1 succeeds or is skipped; a non-404 pref error blocks the ledger for that user to avoid inconsistent state. Triple guard (NODE_ENV=staging, ONBOARDING_BULK_RESET_ALLOWED=true, --confirm) and aggregate-only output are preserved. Add source-structure assertions for the CLI contract.
This commit is contained in:
@@ -2,11 +2,25 @@
|
||||
/**
|
||||
* Staging-only bulk onboarding reset CLI.
|
||||
*
|
||||
* Enumerates every user in the backend `users` table (the Clerk-mirrored
|
||||
* enrollment source) and resets each user's onboarding by calling the
|
||||
* authenticated DELETE /users/onboarding route via the service-token path.
|
||||
* This reuses the exact per-user reset logic end-to-end (preferences reset +
|
||||
* backend ledger deletion) — the CLI never re-implements it.
|
||||
* Resets onboarding for EVERY user in the backend `users` table (the
|
||||
* Clerk-mirrored enrollment source) in two phases:
|
||||
*
|
||||
* Phase 1 — user-service preferences reset (A2A).
|
||||
* Calls POST /api/v1/users/onboarding-reset on user-service with the A2A
|
||||
* key. This resets preferences.onboarding to the default in-progress v3
|
||||
* doc, preserving all unrelated preference keys. It targets users by
|
||||
* clerk_id WITHOUT impersonating a Clerk session. A 404 means the user has
|
||||
* no user-service profile (backend-only user) — a documented skip that
|
||||
* never blocks Phase 2.
|
||||
*
|
||||
* Phase 2 — backend completion ledger reset (direct, idempotent).
|
||||
* Calls resetOnboardingLedger(userId) for every backend user whose Phase 1
|
||||
* succeeded OR was skipped (404 backend-only). A Phase 1 error (non-404
|
||||
* failure) blocks Phase 2 for that user to avoid inconsistent state
|
||||
* (preferences say "completed" but ledger says not). This deletes only
|
||||
* completion-type ledger rows (dotted AND underscore aliases) plus
|
||||
* completion-bearing snapshots, preserving intermediate snapshots,
|
||||
* missions, curator context, and qscore baselines.
|
||||
*
|
||||
* GUARDS — all three must hold or the script aborts before any network call:
|
||||
* 1. NODE_ENV=staging (refuses production/development)
|
||||
@@ -17,11 +31,11 @@
|
||||
* NODE_ENV=staging ONBOARDING_BULK_RESET_ALLOWED=true \
|
||||
* npx tsx scripts/onboarding-bulk-reset.ts --confirm
|
||||
*
|
||||
* Output: one audit line per user (ok / error / skipped) and a summary.
|
||||
* Output: aggregate-only audit counts (no per-user PII) and a summary.
|
||||
*
|
||||
* This script is INTENTIONALLY not wired to any HTTP surface. It is a
|
||||
* staging-only operations tool; bulk reset must never be exposed as a public
|
||||
* endpoint. Per-user reset is the authenticated DELETE route.
|
||||
* endpoint. Per-user reset is the authenticated DELETE /users/onboarding route.
|
||||
*/
|
||||
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
@@ -29,10 +43,16 @@ import { db } from "../src/db/client.js";
|
||||
import { users } from "../src/db/schema.js";
|
||||
import { config } from "../src/config.js";
|
||||
import { log } from "../src/log.js";
|
||||
import { resetOnboardingLedger } from "../src/events/onboarding-ledger.js";
|
||||
|
||||
type ResetOutcome =
|
||||
type PrefOutcome =
|
||||
| { userId: string; ok: true }
|
||||
| { userId: string; ok: false; skipped: true; reason: string; status: number }
|
||||
| { userId: string; ok: false; skipped: false; error: string; status: number };
|
||||
|
||||
type LedgerOutcome =
|
||||
| { userId: string; ok: true; ledgerRowsDeleted: number }
|
||||
| { userId: string; ok: false; error: string; status: number };
|
||||
| { userId: string; ok: false; error: string };
|
||||
|
||||
function assertStagingGuard(argv: string[]): void {
|
||||
const isStaging = config.nodeEnv === "staging";
|
||||
@@ -54,70 +74,118 @@ function assertStagingGuard(argv: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
function backendUrl(): string {
|
||||
const host = process.env.BACKEND_HOST ?? "127.0.0.1";
|
||||
const port = config.port;
|
||||
return `http://${host}:${port}`;
|
||||
function userServiceUrl(): string {
|
||||
return config.userServiceUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
async function resetOne(baseUrl: string, serviceToken: string, userId: string): Promise<ResetOutcome> {
|
||||
const res = await fetch(`${baseUrl}/users/onboarding`, {
|
||||
method: "DELETE",
|
||||
/**
|
||||
* Phase 1: reset preferences.onboarding via the user-service A2A endpoint.
|
||||
* A 404 means the user has no user-service profile (backend-only) — a clean
|
||||
* skip that does NOT block the backend ledger reset.
|
||||
*/
|
||||
async function resetPreferences(clerkId: string, a2aKey: string): Promise<PrefOutcome> {
|
||||
const res = await fetch(`${userServiceUrl()}/api/v1/users/onboarding-reset`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${serviceToken}`,
|
||||
"x-growqr-user": userId,
|
||||
authorization: `Bearer ${a2aKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ clerk_id: clerkId }),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
return { userId: clerkId, ok: false, skipped: true, reason: "no user-service profile (backend-only)", status: 404 };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return { userId, ok: false, error: text || res.statusText, status: res.status };
|
||||
return { userId: clerkId, ok: false, skipped: false, error: text || res.statusText, status: res.status };
|
||||
}
|
||||
return { userId: clerkId, ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: directly reset the backend completion ledger for a user.
|
||||
* Always runs after Phase 1 (success or skip) — backend-only users still need
|
||||
* their ledger cleared. Idempotent.
|
||||
*/
|
||||
async function resetLedger(userId: string): Promise<LedgerOutcome> {
|
||||
try {
|
||||
const ledgerRowsDeleted = await resetOnboardingLedger(userId);
|
||||
return { userId, ok: true, ledgerRowsDeleted };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { userId, ok: false, error: message };
|
||||
}
|
||||
const body = (await res.json()) as { ledgerRowsDeleted?: number };
|
||||
return { userId, ok: true, ledgerRowsDeleted: body.ledgerRowsDeleted ?? 0 };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertStagingGuard(process.argv.slice(2));
|
||||
|
||||
const serviceToken = config.serviceToken;
|
||||
if (!serviceToken) {
|
||||
console.error("onboarding-bulk-reset: ABORTED — SERVICE_TOKEN is not configured");
|
||||
const a2aKey = config.a2aAllowedKey;
|
||||
if (!a2aKey) {
|
||||
console.error("onboarding-bulk-reset: ABORTED — A2A_ALLOWED_KEY is not configured");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const baseUrl = backendUrl();
|
||||
const allUsers = await db.select({ id: users.id, email: users.email }).from(users).orderBy(asc(users.id));
|
||||
const allUsers = await db.select({ id: users.id }).from(users).orderBy(asc(users.id));
|
||||
const total = allUsers.length;
|
||||
|
||||
console.log(`onboarding-bulk-reset: targeting ${allUsers.length} user(s) at ${baseUrl}`);
|
||||
console.log(`onboarding-bulk-reset: targeting ${total} user(s)`);
|
||||
console.log(`onboarding-bulk-reset: NODE_ENV=${config.nodeEnv}, guard=on`);
|
||||
console.log(`onboarding-bulk-reset: user-service=${userServiceUrl()}`);
|
||||
|
||||
let prefOk = 0;
|
||||
let prefSkipped = 0;
|
||||
let prefErrors = 0;
|
||||
|
||||
let ledgerOk = 0;
|
||||
let ledgerErrors = 0;
|
||||
let ledgerTotal = 0;
|
||||
|
||||
let ledgerBlocked = 0;
|
||||
|
||||
const results: ResetOutcome[] = [];
|
||||
for (const u of allUsers) {
|
||||
try {
|
||||
const outcome = await resetOne(baseUrl, serviceToken, u.id);
|
||||
results.push(outcome);
|
||||
if (outcome.ok) {
|
||||
console.log(` ok ${u.id} (${u.email}) — ${outcome.ledgerRowsDeleted} ledger row(s) deleted`);
|
||||
} else {
|
||||
console.log(` ERROR ${u.id} (${u.email}) — status ${outcome.status}: ${outcome.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
results.push({ userId: u.id, ok: false, error: message, status: 0 });
|
||||
console.log(` ERROR ${u.id} (${u.email}) — threw: ${message}`);
|
||||
// Phase 1: user-service preferences reset (A2A).
|
||||
const pref = await resetPreferences(u.id, a2aKey);
|
||||
if (pref.ok) {
|
||||
prefOk += 1;
|
||||
} else if (pref.skipped) {
|
||||
prefSkipped += 1;
|
||||
} else {
|
||||
prefErrors += 1;
|
||||
}
|
||||
|
||||
// Phase 2: backend ledger reset — ONLY after Phase 1 succeeds or is
|
||||
// skipped (404 / backend-only). A pref error (non-404 failure) leaves
|
||||
// preferences.onboarding untouched, so clearing the ledger here would
|
||||
// create an inconsistent state (prefs say "completed", ledger says not).
|
||||
// Skip the ledger for errored users so an operator can retry after fix.
|
||||
if (!pref.ok && !pref.skipped) {
|
||||
ledgerBlocked += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ledger = await resetLedger(u.id);
|
||||
if (ledger.ok) {
|
||||
ledgerOk += 1;
|
||||
ledgerTotal += ledger.ledgerRowsDeleted;
|
||||
} else {
|
||||
ledgerErrors += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const okCount = results.filter((r) => r.ok).length;
|
||||
const errorCount = results.length - okCount;
|
||||
const ledgerTotal = results.reduce((sum, r) => (r.ok ? sum + r.ledgerRowsDeleted : sum), 0);
|
||||
|
||||
console.log("");
|
||||
console.log(`onboarding-bulk-reset: complete — ${okCount} ok, ${errorCount} error(s), ${ledgerTotal} ledger row(s) deleted`);
|
||||
console.log("onboarding-bulk-reset: summary (aggregate only — no per-user PII)");
|
||||
console.log(` total users: ${total}`);
|
||||
console.log(` preferences reset: ${prefOk} ok, ${prefSkipped} skipped (backend-only), ${prefErrors} error(s)`);
|
||||
console.log(` ledger reset: ${ledgerOk} ok, ${ledgerErrors} error(s), ${ledgerBlocked} blocked by pref error, ${ledgerTotal} row(s) deleted`);
|
||||
|
||||
log.info({ okCount, errorCount, ledgerTotal, total: results.length }, "onboarding bulk reset complete");
|
||||
process.exit(errorCount > 0 ? 1 : 0);
|
||||
const hadErrors = prefErrors > 0 || ledgerErrors > 0;
|
||||
|
||||
log.info(
|
||||
{ total, prefOk, prefSkipped, prefErrors, ledgerOk, ledgerErrors, ledgerBlocked, ledgerTotal },
|
||||
"onboarding bulk reset complete",
|
||||
);
|
||||
process.exit(hadErrors ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
COMPLETION_EVENT_TYPES,
|
||||
COMPLETION_EVENT_TYPE_ALIASES,
|
||||
@@ -134,5 +137,40 @@ import {
|
||||
assert.equal(noFlag.status, 2, "staging without opt-in flag must abort");
|
||||
assert.match(noFlag.stderr, /ONBOARDING_BULK_RESET_ALLOWED/, "missing-flag abort names the flag failure");
|
||||
}
|
||||
|
||||
// ── 5. Bulk CLI structure: A2A endpoint + Phase 2 gating (source read) ──────
|
||||
// The CLI's network/DB behavior is exercised in integration. Here we assert
|
||||
// the structural contract by reading the script source directly: (a) it calls
|
||||
// the user-service A2A onboarding-reset endpoint (not the backend DELETE
|
||||
// route), (b) it authenticates with a2aAllowedKey (not serviceToken), (c) it
|
||||
// treats 404 as a skip, and (d) it gates the backend ledger reset on Phase 1
|
||||
// success-or-skip so a pref error never leaves inconsistent state.
|
||||
{
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, "onboarding-bulk-reset.ts"), "utf8");
|
||||
|
||||
// (a) Calls the user-service A2A onboarding-reset endpoint by path.
|
||||
assert.match(src, /\/api\/v1\/users\/onboarding-reset/, "CLI must call the user-service A2A reset endpoint");
|
||||
// Must NOT call the backend DELETE /users/onboarding route (old impersonation path).
|
||||
assert.doesNotMatch(src, /method:\s*["']DELETE["']/, "CLI must not use the backend DELETE route");
|
||||
|
||||
// (b) Authenticates with the A2A key, not the per-user service token.
|
||||
assert.match(src, /a2aAllowedKey/, "CLI must use config.a2aAllowedKey for A2A auth");
|
||||
assert.doesNotMatch(src, /serviceToken/, "CLI must not reference serviceToken (per-user impersonation path)");
|
||||
|
||||
// (c) Treats a 404 from user-service as a documented skip (backend-only user).
|
||||
assert.match(src, /status === 404/, "CLI must detect 404 from user-service");
|
||||
assert.match(src, /backend-only/, "CLI must document 404 as backend-only skip");
|
||||
|
||||
// (d) Phase 2 ledger reset is gated on Phase 1 success-or-skip.
|
||||
assert.match(src, /!pref\.ok && !pref\.skipped/, "CLI must skip ledger when pref errored (not ok, not skipped)");
|
||||
assert.match(src, /ledgerBlocked/, "CLI must count pref-blocked ledger skips");
|
||||
|
||||
// (e) Calls resetOnboardingLedger directly (not via HTTP).
|
||||
assert.match(src, /import \{[^}]*resetOnboardingLedger[^}]*\} from/, "CLI must import resetOnboardingLedger directly");
|
||||
|
||||
// (f) Aggregate-only output — no per-user email/PII in the loop.
|
||||
assert.doesNotMatch(src, /u\.email/, "CLI must not emit per-user email (PII)");
|
||||
}
|
||||
console.log("onboarding-reset tests passed");
|
||||
process.exit(0);
|
||||
|
||||
Reference in New Issue
Block a user