@openparachute/hub 0.6.4-rc.2 → 0.6.4-rc.3

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": "@openparachute/hub",
3
- "version": "0.6.4-rc.2",
3
+ "version": "0.6.4-rc.3",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -304,6 +304,45 @@ describe("deleteUser", () => {
304
304
  cleanup();
305
305
  }
306
306
  });
307
+
308
+ test("deletes a user holding an auth_codes row (hub#559 — OAuth-authorize FK regression)", async () => {
309
+ // A user who completed an OAuth authorize has an `auth_codes` row whose
310
+ // NOT-NULL, non-cascading FK to users(id) outlives its 60s TTL. Before the
311
+ // fix, that pinned the FK and `DELETE FROM users` threw
312
+ // SQLITE_CONSTRAINT_FOREIGNKEY → a 500 on the admin "delete user" action.
313
+ const { db, cleanup } = makeDb();
314
+ try {
315
+ const u = await createUser(db, "ag", "ag-strong-passphrase");
316
+ // auth_codes.client_id FKs to clients — seed a minimal client first.
317
+ db.prepare(
318
+ "INSERT INTO clients (client_id, redirect_uris, scopes, registered_at) VALUES (?, ?, ?, ?)",
319
+ ).run("client-x", "https://app.example/cb", "vault:default:read", "2026-06-04T00:00:00.000Z");
320
+ db.prepare(
321
+ `INSERT INTO auth_codes
322
+ (code, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at, created_at)
323
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
324
+ ).run(
325
+ "dead-code",
326
+ "client-x",
327
+ u.id,
328
+ "https://app.example/cb",
329
+ "vault:default:read",
330
+ "challenge",
331
+ "S256",
332
+ "2026-06-04T00:00:00.000Z", // long-expired
333
+ "2026-06-04T00:00:00.000Z", // already used
334
+ "2026-06-04T00:00:00.000Z",
335
+ );
336
+ expect(deleteUser(db, u.id)).toBe(true);
337
+ expect(getUserById(db, u.id)).toBeNull();
338
+ // The dead auth_code is gone too (hard-deleted with the user).
339
+ expect(db.query("SELECT COUNT(*) c FROM auth_codes WHERE user_id = ?").get(u.id)).toEqual({
340
+ c: 0,
341
+ });
342
+ } finally {
343
+ cleanup();
344
+ }
345
+ });
307
346
  });
308
347
 
309
348
  describe("validateUsername", () => {
package/src/users.ts CHANGED
@@ -601,8 +601,10 @@ export async function resetUserPassword(
601
601
  * parent users row. The audit trail survives via the `subject`
602
602
  * column we backfill from the username plus the existing
603
603
  * `created_at`, `scopes`, `client_id`, `revoked_at` fields.
604
- * - `sessions.user_id` and `grants.user_id` are NOT NULL with a
605
- * non-cascading FK. Both are deleted before the users row drops.
604
+ * - `sessions.user_id`, `grants.user_id`, and `auth_codes.user_id` are
605
+ * NOT NULL with a non-cascading (RESTRICT) FK. All three are deleted
606
+ * before the users row drops — auth_codes are ephemeral OAuth codes
607
+ * (60s TTL, no audit value), so a hard-delete is correct (hub#559).
606
608
  * - `user_vaults.user_id` has `ON DELETE CASCADE` (migration v10), so
607
609
  * vault assignments are dropped automatically when the parent row
608
610
  * goes. No explicit cleanup needed.
@@ -630,10 +632,16 @@ export function deleteUser(db: Database, userId: string): boolean {
630
632
  db.prepare(
631
633
  "UPDATE tokens SET subject = COALESCE(subject, ?), user_id = NULL WHERE user_id = ?",
632
634
  ).run(row.username, userId);
633
- // 2. Drop sessions + grants. Both have non-cascading FKs on user_id;
634
- // leaving rows behind would RESTRICT the users delete below.
635
+ // 2. Drop sessions + grants + auth_codes. All have NOT-NULL, non-cascading
636
+ // (RESTRICT) FKs on user_id; leaving rows behind blocks the users delete
637
+ // below with SQLITE_CONSTRAINT_FOREIGNKEY. auth_codes are short-lived
638
+ // (60s TTL) OAuth authorization codes with no audit value — hard-delete,
639
+ // same as sessions. (Omitting this 500'd a real delete of a user who had
640
+ // completed an OAuth authorize: the code row outlived its TTL but still
641
+ // pinned the FK. hub#559.)
635
642
  db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
636
643
  db.prepare("DELETE FROM grants WHERE user_id = ?").run(userId);
644
+ db.prepare("DELETE FROM auth_codes WHERE user_id = ?").run(userId);
637
645
  // 3. Drop the user row itself.
638
646
  db.prepare("DELETE FROM users WHERE id = ?").run(userId);
639
647
  })();