PocketBase on Labs crash-looped since the SSO deploy (PR #17): mounting --migrationsDir for the first time replayed the entire migration history against a database that was provisioned out-of-band (empty _migrations ledger) and died on 1778948471_created_content.js. On top of that the team_members->auth migration had its own crash paths and trapped OAuth2 config inside a one-shot, env-dependent migration. - pb_migrations/1000000000_baseline_ledger_sync.js: detects an out-of-band provisioned DB (schema exists, ledger empty) and marks the 79 historical migrations as applied; no-op on fresh or already-synced DBs - pb_migrations/1781000000_team_members_to_auth.js: idempotency guard, relation->text conversion WITH data preservation (PocketBase diffs fields by id, so the column is backed up and restored via SQL), unique index rebuild, no silent catches, env only as fast-path - pb_hooks/entra_oidc.pb.js + pb_hooks/utils.js: reconcile the Entra OIDC provider from ENTRA_* env on every bootstrap + cron tick (compare-before-save, warn-once); heals environments that migrated without secrets and supports secret rotation without re-apply - pb_hooks/team_members.pb.js: require() pattern — JSVM runs callbacks as isolated programs, top-level helpers are not in scope (adopts Leroy's fix from the fix-sso branch) - infra/*/site/deploy-playbook.yml: health-gate after compose up — the deploy fails loudly with container logs when PocketBase does not become healthy (runs #83-#88 were green while PB crash-looped) - docker-compose.yml: .env.local is optional again - docs/auth-spec.md + AI_AGENT.md: ledger/reconciler documentation, ADRs 006-008, never-rename-applied-migrations warning Verified locally against PocketBase v0.30.4 with a 7-scenario DoD matrix (fresh DB +/- env, out-of-band DB with data incl. data preservation, populated-ledger upgrade, late-secrets healing, re-run guard, hook provisioning): 15/15 pass. npm test 112/112. Closes #18 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
105 lines
4.2 KiB
JavaScript
105 lines
4.2 KiB
JavaScript
/// <reference path="../pb_data/types.d.ts" />
|
|
//
|
|
// Shared helpers for pb_hooks/*.pb.js callbacks.
|
|
//
|
|
// PocketBase's JSVM serializes and runs each registered hook callback as its
|
|
// own isolated program, so a plain top-level function declared in a *.pb.js
|
|
// file is NOT in scope inside a callback at call time. Reusable helpers must
|
|
// instead live in a plain (non *.pb.js, so it isn't auto-loaded as a hook)
|
|
// module and be pulled in per-callback via require(`${__hooks}/utils.js`).
|
|
// See: https://github.com/pocketbase/pocketbase/discussions/3599
|
|
//
|
|
// Loaded modules use a shared registry across callback invocations, so keep
|
|
// this file stateless. (Deliberate exception: the warn-once latch below, which
|
|
// exists precisely BECAUSE the registry is shared — it dedupes the env-missing
|
|
// warning across the bootstrap hook and the per-minute cron tick.)
|
|
//
|
|
// Keep resolveRole in sync with src/lib/azureAuth.js's resolveRole (the
|
|
// canonical, unit-tested version used by the frontend).
|
|
|
|
let warnedEnvMissing = false;
|
|
|
|
module.exports = {
|
|
/**
|
|
* Resolve a user's role from their e-mail and the ENTRA_ADMIN_EMAILS allow-list.
|
|
* @param {string} email
|
|
* @returns {"admin"|"user"}
|
|
*/
|
|
resolveRole: function (email) {
|
|
const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase();
|
|
const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
return allow.indexOf(String(email || "").toLowerCase()) !== -1 ? "admin" : "user";
|
|
},
|
|
|
|
/**
|
|
* Reconcile the Entra (Azure) OIDC provider on the team_members auth
|
|
* collection from the ENTRA_* environment variables (issue #18).
|
|
*
|
|
* Idempotent: compares the desired provider config against the stored one
|
|
* and only saves when something actually changed, so it is safe to call on
|
|
* every bootstrap and cron tick. Keeping this OUT of the one-shot migration
|
|
* means late or rotated secrets are picked up on the next start/tick without
|
|
* any manual re-apply.
|
|
*
|
|
* @param {core.App} app
|
|
* @returns {"collection-missing"|"not-auth-yet"|"env-missing"|"in-sync"|"updated"}
|
|
*/
|
|
reconcileEntraOidc: function (app) {
|
|
let col;
|
|
try {
|
|
col = app.findCollectionByNameOrId("team_members");
|
|
} catch (_) {
|
|
return "collection-missing"; // migration has not created it yet
|
|
}
|
|
if (col.type !== "auth") {
|
|
return "not-auth-yet"; // pre-conversion PIN-era collection
|
|
}
|
|
|
|
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
|
|
const clientId = $os.getenv("ENTRA_CLIENT_ID") || "";
|
|
const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET") || "";
|
|
if (!clientId || !clientSecret) {
|
|
if (!warnedEnvMissing) {
|
|
warnedEnvMissing = true;
|
|
console.log(
|
|
"WARN entra_oidc: ENTRA_CLIENT_ID / ENTRA_CLIENT_SECRET not set — Microsoft login stays " +
|
|
"disabled until the env vars are provided (they are reconciled automatically on startup)."
|
|
);
|
|
}
|
|
return "env-missing";
|
|
}
|
|
warnedEnvMissing = false; // env restored — re-arm the warning for future rotations
|
|
|
|
const desired = {
|
|
name: "oidc",
|
|
clientId: clientId,
|
|
clientSecret: clientSecret,
|
|
authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
|
|
tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
|
|
userInfoURL: "https://graph.microsoft.com/oidc/userinfo",
|
|
displayName: "Microsoft",
|
|
pkce: true,
|
|
};
|
|
|
|
const providers = (col.oauth2 && col.oauth2.providers) || [];
|
|
const current = providers.length === 1 ? providers[0] : null;
|
|
const inSync = !!current &&
|
|
col.oauth2.enabled === true &&
|
|
current.name === desired.name &&
|
|
current.clientId === desired.clientId &&
|
|
current.clientSecret === desired.clientSecret &&
|
|
current.authURL === desired.authURL &&
|
|
current.tokenURL === desired.tokenURL &&
|
|
current.userInfoURL === desired.userInfoURL &&
|
|
current.displayName === desired.displayName;
|
|
if (inSync) {
|
|
return "in-sync";
|
|
}
|
|
|
|
col.oauth2.enabled = true;
|
|
col.oauth2.providers = [desired];
|
|
app.save(col);
|
|
return "updated";
|
|
},
|
|
};
|