fix(web-app-bluesky): derive handles without prefix and let over-long names pick one

The broker built every handle as kc-<username>. The PDS (0.4.66) caps the
first handle label at 18 characters, so kc-kevinveenbirkenbach (22) failed
createAccount with InvalidHandle: Handle too long, and the prefix only
existed to dodge the PDS's reserved names.

handle.js derives the label from the Keycloak username without a prefix and
adds i8- only after the PDS answers Reserved handle (1029 names, among them
administrator). A label that fits neither way leads to a form at /sso/handle
where the user picks 3 to 18 characters of [a-z0-9-] that contain a hyphen,
which no Keycloak username holds, and never contain i8-. The POST must come
from the proxied host's origin; the hostname is compared, since nginx passes
Host without its port.

store.js keeps username -> {handle, did, encrypted app-password} in a JSON
file (0600, written through a rename) in the new broker_data volume, so a
broker restart neither creates a second account nor resets a password. A
stored entry whose login the PDS refuses gets its password reset by DID; when
that login still fails the account is gone (a single-volume PDS rollback) and
the user is provisioned again. Concurrent first visits share one
provisioning. A derived handle that is taken is only recovered when no other
user holds it in the store, and an e-mail address a legacy kc- account still
holds ends in a 409 page instead of a retry loop.

Without the broker, 04_native_admin.yml follows the same rule: the plain
handle first, i8- on Reserved handle. The native Playwright tests sign in by
e-mail, which the PDS resolves, and accept either handle.

Evidence: the PDS rules and messages were read from the 0.4.66 image.
server.test.js runs the broker as a child process against a PDS stub that
answers with those messages: plain, i8-, form, 403/400/409, the takeover
guard, restart, lost account, reset and the concurrent visits, whose test
fails without provisionOnce. Unit tests 21/21, JavaScript suite 102/102,
make test-lint and make lint pass. The image builds and the node user writes
the store into a fresh volume.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-25 13:36:51 +01:00
1 parent 754d35c4de
commit aadc96561b
18 files changed
+748 -82

No files matched your search

+1 -1
View File
@@ -114,7 +114,7 @@ docker run --rm -it \
OIDC integration follows the variant A+ pattern documented in requirement 013: `web-app-keycloak`'s SSO-proxy sidecar is the gate, the in-role login-broker is the session-handoff agent, and the official `social-app` web client is the user-facing UI.
The broker MUST receive `X-Forwarded-User`, `X-Forwarded-Preferred-Username`, and `X-Forwarded-Email` from `oauth2-proxy`. On a user's first authenticated visit the broker calls the PDS `com.atproto.server.createAccount` endpoint with a synthesised app-password, encrypts that password with AES-256-GCM under a key from the project's credentials vault, and caches the ciphertext in the broker's in-process `sessionCache` keyed by Keycloak username. Subsequent visits decrypt the cached password in-broker, exchange it for a PDS session via `com.atproto.server.createSession`, and seed `localStorage["BSKY_STORAGE"]` so the social-app finds an authenticated session at boot. A broker restart drops the cache; the next SSO visit triggers a fresh `createAccount` and the previous app-password remains valid in PDS until rotated.
The broker MUST receive `X-Forwarded-User`, `X-Forwarded-Preferred-Username`, and `X-Forwarded-Email` from `oauth2-proxy`. On a user's first authenticated visit the broker calls the PDS `com.atproto.server.createAccount` endpoint with a synthesised app-password, encrypts that password with AES-256-GCM under a key from the project's credentials vault, and records handle, DID and ciphertext in the broker volume keyed by Keycloak username. Subsequent visits, also after a broker restart, decrypt the stored password in-broker, exchange it for a PDS session via `com.atproto.server.createSession`, and seed `localStorage["BSKY_STORAGE"]` so the social-app finds an authenticated session at boot. The handle is the username without prefix, `i8-<username>` for a name the PDS reserves, and a handle the user picks in a form when the username exceeds the PDS limit of 18 characters.
LDAP rides the same broker path: Keycloak federates LDAP via `svc-db-openldap` and the broker reads the resulting Keycloak headers via `oauth2-proxy`. Direct LDAP-to-PDS sync is NOT supported, the Keycloak federation layer is the single authority for user identity.
@@ -10,7 +10,9 @@ RUN apk add --no-cache ca-certificates && update-ca-certificates
WORKDIR /app
COPY package.json server.js ./
COPY package.json server.js handle.js store.js ./
RUN mkdir -p /data && chown node:node /data
USER node
EXPOSE 8080
@@ -7,13 +7,17 @@ The broker sits behind `web-app-keycloak`'s SSO-proxy sidecar and in front of th
official `@bluesky-social/social-app` web client. On a user's first
OIDC-authenticated visit, the broker:
1. Looks up the Keycloak user via the Admin REST API. The
service-account credentials are mounted as env, see `KC_ADMIN_*`.
1. Derives the handle `<username>.<PDS_HANDLE_DOMAIN>` from the
Keycloak username, or `i8-<username>.<PDS_HANDLE_DOMAIN>` when the
PDS answers `Reserved handle`. A first label longer than 18
characters cannot be a handle; the broker then shows a form at
`/sso/handle` where the user picks one of 3 to 18 characters from
`[a-z0-9-]` that contains a hyphen and never contains `i8-`.
2. Provisions a Bluesky PDS account via
`com.atproto.server.createAccount` if no encrypted app-password is
recorded on the user yet, encrypts the synthesised app-password
with **AES-256-GCM**, and stores the ciphertext as the
`bluesky_app_password_enc` user attribute.
`com.atproto.server.createAccount` if `BROKER_STORE_PATH` holds no
entry for the username yet, encrypts the synthesised app-password
with **AES-256-GCM**, and records the handle, the DID and the
ciphertext there, keyed by the Keycloak username.
3. Decrypts the stored app-password in-process and exchanges it for
a PDS session via `com.atproto.server.createSession`.
4. Renders an HTML handoff page that drops the resulting session
@@ -35,6 +39,7 @@ synthesised app-password.
| `PDS_URL` | Public Bluesky PDS base URL (`https://api.bluesky.<domain>`), written into the browser `service` field. |
| `PDS_INTERNAL_URL` | In-stack PDS URL for server-side XRPC calls (`http://pds:3000`); defaults to `PDS_URL` when unset. |
| `PDS_HANDLE_DOMAIN` | Suffix for synthesised handles (`<DOMAIN_PRIMARY>`). |
| `BROKER_STORE_PATH` | JSON file in the broker volume that maps usernames to their PDS account. |
| `PDS_INVITE_CODE` | Optional invite code for gated PDS deployments. |
| `KC_ADMIN_BASE_URL` | Keycloak base URL (`https://auth.<domain>`). |
| `KC_ADMIN_REALM` | Realm of the broker's service account (typically the same as user realm).|
@@ -0,0 +1,64 @@
"use strict";
const PDS_HANDLE_LABEL_MIN = 3;
const PDS_HANDLE_LABEL_MAX = 18;
const RESERVED_PREFIX = "i8-";
/**
* First handle label derived from a Keycloak username.
*
* @param {string} username Keycloak username
* @returns {string} lowercase label of [a-z0-9-], without prefix
*/
function deriveLabel(username) {
const mapped = String(username || "")
.toLowerCase()
.slice(0, 256)
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
return mapped || "user";
}
/**
* Label for a name the PDS reserves ("admin", "administrator", ...).
*
* @param {string} label derived label
* @returns {string} label carrying the reserved-name prefix
*/
function prefixedLabel(label) {
return `${RESERVED_PREFIX}${label}`;
}
/**
* @param {string} label first handle label
* @returns {boolean} whether the PDS accepts its length
*/
function fitsPds(label) {
return label.length >= PDS_HANDLE_LABEL_MIN && label.length <= PDS_HANDLE_LABEL_MAX;
}
/**
* @param {string} label label the user typed
* @returns {string|null} reason shown in the form, null when acceptable
*/
function chosenLabelError(label) {
if (!/^[a-z0-9-]+$/.test(label)) return "Use lowercase letters, digits and hyphens only.";
if (!fitsPds(label)) {
return `Use ${PDS_HANDLE_LABEL_MIN} to ${PDS_HANDLE_LABEL_MAX} characters.`;
}
if (!label.includes("-")) return "Include at least one hyphen (-).";
if (label.includes(RESERVED_PREFIX)) return `Do not use "${RESERVED_PREFIX}" anywhere in the handle.`;
if (label.startsWith("-") || label.endsWith("-")) return "Do not start or end the handle with a hyphen.";
return null;
}
module.exports = {
PDS_HANDLE_LABEL_MAX,
PDS_HANDLE_LABEL_MIN,
RESERVED_PREFIX,
chosenLabelError,
deriveLabel,
fitsPds,
prefixedLabel,
};
@@ -1,4 +1,3 @@
// nocheck: mirrored-unit-test - a standalone Express server whose module body binds a port and reads the container environment at import
/*
* Bluesky login-broker.
*
@@ -49,6 +48,16 @@ const http = require("node:http");
const https = require("node:https");
const crypto = require("node:crypto");
const { URL } = require("node:url");
const {
PDS_HANDLE_LABEL_MAX,
PDS_HANDLE_LABEL_MIN,
RESERVED_PREFIX,
chosenLabelError,
deriveLabel,
fitsPds,
prefixedLabel
} = require("./handle");
const { openStore } = require("./store");
// --- Configuration --------------------------------------------------
@@ -72,7 +81,9 @@ const CONFIG = {
// nocheck: env-default the broker owns its own cookie, no role renders it
handoffCookieMaxAgeSec: parseInt(process.env.HANDOFF_COOKIE_MAX_AGE || "3300", 10),
insecureTls: (process.env.INSECURE_TLS || "").toLowerCase() === "true",
logoutPath: "/sso/logout"
storePath: requireEnv("BROKER_STORE_PATH"),
logoutPath: "/sso/logout",
chooseHandlePath: "/sso/handle"
};
function requireEnv(name) {
@@ -163,18 +174,6 @@ function fetchJson(method, urlString, opts = {}) {
// --- PDS client -----------------------------------------------------
function sanitiseHandle(username) {
if (!username) return "kc-user";
const lower = username.toLowerCase().slice(0, 256);
const mapped = lower.replace(/[^a-z0-9-]+/g, "-").replace(/^-+/, "").replace(/-+$/, "");
// PDS reserves common service handles ("administrator", "admin", "api",
// "support", "www", "bsky", "atproto", etc.) and rejects createAccount
// with `HandleNotAvailable: Reserved handle`. Always prefix with `kc-`
// so Keycloak-derived handles cannot collide with that reserved list,
// independent of which usernames the realm carries.
return `kc-${mapped || "user"}`;
}
async function pdsCreateAccount({ handle, email, password }) {
const url = `${CONFIG.pdsInternalUrl}/xrpc/com.atproto.server.createAccount`;
const body = { handle, email, password };
@@ -195,7 +194,10 @@ async function pdsCreateSession({ handle, password }) {
const url = `${CONFIG.pdsInternalUrl}/xrpc/com.atproto.server.createSession`;
const res = await fetchJson("POST", url, { body: { identifier: handle, password } });
if (res.status < 200 || res.status >= 300) {
throw new Error(`PDS createSession failed: status=${res.status} body=${res.raw}`);
const err = new Error(`PDS createSession failed: status=${res.status} body=${res.raw}`);
err.pdsStatus = res.status;
err.pdsBody = res.body;
throw err;
}
return res.body;
}
@@ -232,61 +234,109 @@ async function pdsAdminUpdatePassword(did, newPassword) {
// --- High-level orchestration --------------------------------------
// Per-user PDS app-password / DID / handle cache, keyed by Keycloak
// username. In-memory, AES-256-GCM-encrypted at rest.
//
// We deliberately do NOT write the encrypted blob into Keycloak user
// attributes (the previous design): on a realm with LDAP federation
// in WRITABLE mode the bluesky_* attribute write triggers an LDAP
// schema-violating sync push and leaves the federated user in a
// partially-synced state, which then breaks subsequent direct OIDC
// logins for that user (every other realm-OIDC test in the matrix
// would regress as a side-effect of running the broker once). The
// broker is a single-instance sidecar so an in-memory map is enough;
// on broker restart the user re-creates a PDS app-password on first
// SSO visit. The previous app-password remains valid in PDS until
// rotated explicitly — acceptable for this auto-provision flow.
const sessionCache = new Map();
// Exception: the account map lives in the broker's own volume, never in a
// Keycloak user attribute; a bluesky_* attribute write under LDAP federation
// in WRITABLE mode triggers a schema-violating sync push that breaks later
// OIDC logins of that user.
const store = openStore(CONFIG.storePath);
async function ensurePdsSession({ kcUsername, kcEmail }) {
const fullHandle = `${sanitiseHandle(kcUsername)}.${CONFIG.pdsHandleDomain}`;
const cached = sessionCache.get(kcUsername);
let appPasswordEnc = cached ? cached.appPasswordEnc : null;
let did = cached ? cached.did : null;
if (!appPasswordEnc) {
const synthesised = crypto.randomBytes(18).toString("base64url");
class HandleChoiceRequired extends Error {}
function pdsMessageIncludes(err, text) {
return Boolean(
err.pdsBody && typeof err.pdsBody.message === "string" && err.pdsBody.message.includes(text)
);
}
function accountEmail(kcUsername, kcEmail) {
return kcEmail || `${kcUsername}@bridge.local`;
}
async function createAccountWithLabel({ label, kcUsername, kcEmail, password, recoverTaken }) {
const handle = `${label}.${CONFIG.pdsHandleDomain}`;
try {
const created = await pdsCreateAccount({ handle, email: accountEmail(kcUsername, kcEmail), password });
return { handle, did: created.did };
} catch (err) {
if (!recoverTaken || !pdsMessageIncludes(err, "Handle already taken")) throw err;
const owner = store.ownerOf(handle);
if (owner && owner !== kcUsername) throw err;
const did = await pdsResolveHandle(handle);
await pdsAdminUpdatePassword(did, password);
return { handle, did };
}
}
async function provisionAccount({ kcUsername, kcEmail, chosenLabel }) {
const password = crypto.randomBytes(18).toString("base64url");
const request = { kcUsername, kcEmail, password };
let account;
if (chosenLabel) {
account = await createAccountWithLabel(Object.assign({ label: chosenLabel, recoverTaken: false }, request));
} else {
const label = deriveLabel(kcUsername);
if (!fitsPds(label)) throw new HandleChoiceRequired();
try {
const created = await pdsCreateAccount({
handle: fullHandle,
email: kcEmail || `${kcUsername}@bridge.local`,
password: synthesised
});
did = created.did || did;
account = await createAccountWithLabel(Object.assign({ label, recoverTaken: true }, request));
} catch (err) {
// Recover from `Handle already taken` — happens when the broker's
// in-memory cache has been lost (container restart, cross-variant
// deploy that shares the PDS data volume) but the PDS still has
// the account from a previous broker session. Use the PDS admin
// API to reset the password to a fresh value we control, so the
// subsequent createSession works again.
const isHandleTaken =
err.pdsBody && err.pdsBody.error === "InvalidRequest" &&
typeof err.pdsBody.message === "string" &&
err.pdsBody.message.includes("Handle already taken");
if (!isHandleTaken) throw err;
did = await pdsResolveHandle(fullHandle);
await pdsAdminUpdatePassword(did, synthesised);
if (!pdsMessageIncludes(err, "Reserved handle")) throw err;
const reservedLabel = prefixedLabel(label);
if (!fitsPds(reservedLabel)) throw new HandleChoiceRequired();
account = await createAccountWithLabel(Object.assign({ label: reservedLabel, recoverTaken: true }, request));
}
appPasswordEnc = encrypt(synthesised);
sessionCache.set(kcUsername, { appPasswordEnc, did, handle: fullHandle });
}
const entry = { handle: account.handle, did: account.did, appPasswordEnc: encrypt(password) };
store.set(kcUsername, entry);
return entry;
}
const provisioning = new Map();
function provisionOnce(request) {
const pending = provisioning.get(request.kcUsername);
if (pending) return pending;
const run = provisionAccount(request).finally(() => provisioning.delete(request.kcUsername));
provisioning.set(request.kcUsername, run);
return run;
}
function sessionFor(entry) {
return pdsCreateSession({ handle: entry.handle, password: decrypt(entry.appPasswordEnc) });
}
async function storedSession(kcUsername, entry) {
try {
return await sessionFor(entry);
} catch (err) {
if (!pdsMessageIncludes(err, "Invalid identifier or password")) throw err;
}
const password = crypto.randomBytes(18).toString("base64url");
await pdsAdminUpdatePassword(entry.did, password);
const repaired = Object.assign({}, entry, { appPasswordEnc: encrypt(password) });
try {
const session = await sessionFor(repaired);
store.set(kcUsername, repaired);
return session;
} catch (err) {
if (!pdsMessageIncludes(err, "Invalid identifier or password")) throw err;
store.delete(kcUsername);
return null;
}
}
async function ensurePdsSession({ kcUsername, kcEmail, chosenLabel }) {
const stored = store.get(kcUsername);
let session = stored ? await storedSession(kcUsername, stored) : null;
let entry = stored;
if (!session) {
entry = await provisionOnce({ kcUsername, kcEmail, chosenLabel });
session = await sessionFor(entry);
}
const password = decrypt(appPasswordEnc);
const session = await pdsCreateSession({ handle: fullHandle, password });
return {
service: CONFIG.pdsUrl,
did: session.did || did,
handle: session.handle || fullHandle,
email: kcEmail || `${kcUsername}@bridge.local`,
did: session.did || entry.did,
handle: session.handle || entry.handle,
email: accountEmail(kcUsername, kcEmail),
emailConfirmed: true,
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt
@@ -394,6 +444,59 @@ function renderHandoff(session, redirectTo) {
</html>`;
}
function renderHandleForm({ error, value }) {
const errorHtml = error ? `<p class="error">${escapeHtml(error)}</p>` : "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Choose your Bluesky handle</title>
<meta name="referrer" content="no-referrer" />
<style>body{font-family:sans-serif;background:#001a33;color:#eef;margin:0;padding:2em;text-align:center}input,button{font-size:1em;padding:.4em}.error{color:#f99}</style>
</head>
<body>
<h1>Choose your Bluesky handle</h1>
<p>Your username is longer than the ${PDS_HANDLE_LABEL_MAX} characters a Bluesky handle allows.</p>
<p>Pick ${PDS_HANDLE_LABEL_MIN} to ${PDS_HANDLE_LABEL_MAX} lowercase letters, digits and hyphens. The handle must contain a hyphen (-) and must not contain "${RESERVED_PREFIX}".</p>
${errorHtml}
<form method="post" action="${CONFIG.chooseHandlePath}">
<input name="handle" value="${escapeHtml(value)}" required minlength="${PDS_HANDLE_LABEL_MIN}" maxlength="${PDS_HANDLE_LABEL_MAX}" pattern="[a-z0-9-]+" autocomplete="off" autofocus />
<span>.${escapeHtml(CONFIG.pdsHandleDomain)}</span>
<button type="submit">Create account</button>
</form>
</body>
</html>`;
}
function readBody(req, limitBytes) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on("data", (chunk) => {
size += chunk.length;
if (size > limitBytes) {
reject(new Error("request body too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function isSameOrigin(req) {
const origin = req.headers["origin"];
const host = (req.headers["x-forwarded-host"] || req.headers["host"] || "").toString().split(",")[0].trim();
if (!origin || !host) return false;
try {
return new URL(origin).hostname === new URL(`http://${host}`).hostname;
} catch {
return false;
}
}
function endHtml(res, status, html) {
res.writeHead(status, {
"Content-Type": "text/html; charset=utf-8",
@@ -471,13 +574,49 @@ const server = http.createServer(async (req, res) => {
return;
}
const session = await ensurePdsSession({ kcUsername, kcEmail });
if (path === CONFIG.chooseHandlePath && req.method === "POST") {
if (!isSameOrigin(req)) {
endText(res, 403, "Cross-origin handle submission refused.");
return;
}
const chosenLabel = (new URLSearchParams(await readBody(req, 1024)).get("handle") || "").trim();
const error = chosenLabelError(chosenLabel);
if (error) {
endHtml(res, 400, renderHandleForm({ error, value: chosenLabel }));
return;
}
let chosenSession;
try {
chosenSession = await ensurePdsSession({ kcUsername, kcEmail, chosenLabel });
} catch (err) {
if (!pdsMessageIncludes(err, "Handle already taken")) throw err;
endHtml(res, 409, renderHandleForm({ error: "This handle is already taken.", value: chosenLabel }));
return;
}
console.log(`[broker:${reqId}] chosen handle ${logSafe(chosenSession.handle)} ready (${Date.now() - reqStart}ms)`);
endHtml(res, 200, renderHandoff(chosenSession, "/"));
return;
}
let session;
try {
session = await ensurePdsSession({ kcUsername, kcEmail });
} catch (err) {
if (!(err instanceof HandleChoiceRequired)) throw err;
console.log(`[broker:${reqId}] username too long for a handle → handle form (${Date.now() - reqStart}ms)`);
endHtml(res, 200, renderHandleForm({ error: null, value: "" }));
return;
}
console.log(`[broker:${reqId}] PDS session ready did=${logSafe(session.did)} handle=${logSafe(session.handle)} hasAccessJwt=${!!session.accessJwt} (${Date.now() - reqStart}ms)`);
const html = renderHandoff(session, requestUrl.pathname + (requestUrl.search || ""));
endHtml(res, 200, html);
console.log(`[broker:${reqId}] handoff HTML sent (${Date.now() - reqStart}ms)`);
} catch (err) {
console.error(`[broker:${reqId}] error:`, err.stack || err.message);
if (pdsMessageIncludes(err, "Email already taken")) {
endText(res, 409, "Another Bluesky account on this server already uses your e-mail address, most likely a legacy kc- account. Ask an administrator to delete it.");
return;
}
endText(res, 500, `Broker error: ${escapeHtml(err.message)}`);
}
});
@@ -0,0 +1,43 @@
"use strict";
const fs = require("node:fs");
/**
* Persistent map from Keycloak username to the broker's PDS account.
*
* @param {string} file JSON file inside the broker's volume
* @returns {{get: function(string): (object|null), set: function(string, object): void, delete: function(string): void, ownerOf: function(string): (string|null)}}
* get returns {handle, did, appPasswordEnc} or null; set and delete write
* the file through a rename; ownerOf names the username that holds a handle
*/
function openStore(file) {
const entries = new Map(
fs.existsSync(file) ? Object.entries(JSON.parse(fs.readFileSync(file, "utf8"))) : [],
);
function save() {
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(entries)), { mode: 0o600 });
fs.renameSync(tmp, file);
}
return {
get: (username) => entries.get(username) || null,
set: (username, entry) => {
entries.set(username, entry);
save();
},
delete: (username) => {
entries.delete(username);
save();
},
ownerOf: (handle) => {
for (const [username, entry] of entries) {
if (entry.handle === handle) return username;
}
return null;
},
};
}
module.exports = { openStore };
@@ -16,7 +16,9 @@ const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
const adminEmail = decodeDotenvQuotedValue(process.env.ADMIN_EMAIL || "");
const adminHandle = decodeDotenvQuotedValue(process.env.ADMIN_HANDLE || "");
const adminHandleReserved = decodeDotenvQuotedValue(process.env.ADMIN_HANDLE_RESERVED || "");
const pdsBaseUrl = decodeDotenvQuotedValue(process.env.PDS_BASE_URL || "");
async function beforeEach({ page }) {
@@ -62,7 +64,9 @@ module.exports = {
canonicalDomain,
adminUsername,
adminPassword,
adminEmail,
adminHandle,
adminHandleReserved,
pdsBaseUrl,
},
skipUnlessServiceEnabled,
@@ -7,14 +7,14 @@ const { expectPdsProxiesAppView } = require("./_appview");
exports.register = function (shared) {
test("native: PDS proxies Following and Notifications to its configured AppView with an lxm-bound service JWT (no oauth2)", async ({ page }) => {
skipUnlessServiceDisabled("sso");
const { adminHandle, adminPassword, pdsBaseUrl } = shared.env;
expect(adminHandle, "ADMIN_HANDLE must be set when oauth2 is disabled").toBeTruthy();
const { adminEmail, adminPassword, pdsBaseUrl } = shared.env;
expect(adminEmail, "ADMIN_EMAIL must be set when oauth2 is disabled").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set when oauth2 is disabled").toBeTruthy();
expect(pdsBaseUrl, "PDS_BASE_URL must be set when oauth2 is disabled").toBeTruthy();
const session = await apiFetchOnion(page.request, `${pdsBaseUrl}/xrpc/com.atproto.server.createSession`, {
method: "POST",
data: { identifier: adminHandle, password: adminPassword },
data: { identifier: adminEmail, password: adminPassword },
timeout: resolveTimeout(30_000),
});
expect(session.status(), `PDS createSession must return 200 (got body: ${await session.text()})`).toBe(200);
@@ -7,8 +7,10 @@ exports.register = function (shared) {
process.env.SSO_SERVICE_ENABLED === "true",
"Native login path only exists when oauth2 is disabled — when oauth2 is on, the broker owns provisioning and the OIDC test covers the journey.",
);
const { baseUrl, adminHandle, adminPassword, pdsBaseUrl } = shared.env;
const { baseUrl, adminEmail, adminHandle, adminHandleReserved, adminPassword, pdsBaseUrl } = shared.env;
expect(adminEmail, "ADMIN_EMAIL must be set when oauth2 is disabled").toBeTruthy();
expect(adminHandle, "ADMIN_HANDLE must be set when oauth2 is disabled").toBeTruthy();
expect(adminHandleReserved, "ADMIN_HANDLE_RESERVED must be set when oauth2 is disabled").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set when oauth2 is disabled").toBeTruthy();
expect(pdsBaseUrl, "PDS_BASE_URL must be set when oauth2 is disabled").toBeTruthy();
@@ -30,13 +32,16 @@ exports.register = function (shared) {
`${pdsBaseUrl}/xrpc/com.atproto.server.createSession`,
{
headers: { "Content-Type": "application/json" },
data: { identifier: adminHandle, password: adminPassword },
data: { identifier: adminEmail, password: adminPassword },
},
);
const sessionText = await sessionResp.text();
expect(sessionResp.status(), `PDS createSession must return 200 for the deployed administrator (got body: ${sessionText})`).toBe(200);
const sessionJson = JSON.parse(sessionText);
expect(sessionJson.handle, "createSession response must echo the administrator handle").toBe(adminHandle);
expect(
[adminHandle, adminHandleReserved],
"createSession must echo the administrator handle, without prefix or with i8- for a reserved name",
).toContain(sessionJson.handle);
expect(typeof sessionJson.accessJwt, "createSession response must include an accessJwt").toBe("string");
await apiContext.dispose();
});
+2 -2
View File
@@ -12,8 +12,8 @@ credentials:
description: |-
AES-256-GCM symmetric key used by the login-broker to encrypt
the per-user Bluesky app-password held in the broker's
in-memory cache (see files/login-broker/server.js,
`sessionCache`). Format: `base64:<44-char-base64-of-32-random-bytes>`.
account store (see files/login-broker/store.js,
BROKER_STORE_PATH). Format: `base64:<44-char-base64-of-32-random-bytes>`.
The broker strips the `base64:` prefix at startup.
algorithm: base64_prefixed_32
validation: ^base64:[A-Za-z0-9+/]{43}=$
+10
View File
@@ -5,6 +5,16 @@ pds_data:
- service: pds
target: /opt/pds
broker_data:
type: volume
nfs:
uid: "1000"
gid: "1000"
mode: "0700"
mounts:
- service: broker
target: /data
pdsadmin:
type: bind
source: "{{ [lookup('container', 'web-app-bluesky', 'directories.volumes'), 'pdsadmin', 'pdsadmin'] | path_join }}"
@@ -30,4 +30,28 @@
failed_when:
- bluesky_admin_create.status != 200
- "'Handle already taken' not in (bluesky_admin_create.content | default(''))"
- "'Reserved handle' not in (bluesky_admin_create.content | default(''))"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "📁 Create the native PDS admin account with the i8- prefix (idempotent)"
uri_retry:
url: "{{ BLUESKY_PDS_INTERNAL_URL }}/xrpc/com.atproto.server.createAccount"
method: POST
headers:
Content-Type: application/json
body_format: json
body:
handle: "{{ BLUESKY_ADMIN_HANDLE_RESERVED }}"
email: "{{ BLUESKY_ADMIN_EMAIL }}"
password: "{{ BLUESKY_ADMIN_PASSWORD }}"
status_code: [200, 400]
validate_certs: false
return_content: true
register: bluesky_admin_create_reserved
changed_when:
- bluesky_admin_create_reserved.status == 200
failed_when:
- bluesky_admin_create_reserved.status != 200
- "'Handle already taken' not in (bluesky_admin_create_reserved.content | default(''))"
when: "'Reserved handle' in (bluesky_admin_create.content | default(''))"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -41,9 +41,11 @@
hostname: {{ service_name }}
{{ lookup('compose_only', 'pull_policy', 'never') }}
{{ lookup('compose_restart') }}
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_ports', [BLUESKY_BROKER_SERVICE, 'http', DOCKER_BIND_HOST]) | indent(4) }}
environment:
BROKER_PORT: "{{ BLUESKY_BROKER_INTERNAL_PORT }}"
BROKER_STORE_PATH: "{{ BLUESKY_BROKER_STORE_PATH }}"
SOCIAL_APP_URL: "http://web:{{ BLUESKY_WEB_INTERNAL_PORT }}"
PDS_URL: "{{ lookup('tls', application_id, 'protocols.web') }}://{{ BLUESKY_API_DOMAIN }}"
PDS_INTERNAL_URL: "http://pds:{{ BLUESKY_PDS_INTERNAL_PORT }}"
@@ -11,8 +11,12 @@
# therefore blocked at the generic dashboard → app → logout persona-flow level — the role's OIDC +
# LDAP tests carry the equivalent contract.
ADMIN_EMAIL={{ BLUESKY_ADMIN_EMAIL | dotenv_quote }}
ADMIN_HANDLE={{ BLUESKY_ADMIN_HANDLE | dotenv_quote }}
ADMIN_HANDLE_RESERVED={{ BLUESKY_ADMIN_HANDLE_RESERVED | dotenv_quote }}
ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }}
ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }}
@@ -42,7 +46,7 @@ OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
PDS_BASE_URL={{ lookup('canonical_url', application_id, 'api') | dotenv_quote }}
# nocheck: persona-block-rationale — the PDS knows no role beyond "account exists / does not exist" (README "Single Sign-On" records this as the documented RBAC exception), so there is no admin surface for the helper's adminInteraction; the administrator's PDS identity is the derived handle `kc-<username>.<domain>` (vars/main.yml BLUESKY_ADMIN_HANDLE) and is administered through the `pdsadmin` CLI (Administration.md), not a browser panel.
# nocheck: persona-block-rationale — the PDS knows no role beyond "account exists / does not exist" (README "Single Sign-On" records this as the documented RBAC exception), so there is no admin surface for the helper's adminInteraction; the administrator's PDS identity is the derived handle `<username>.<domain>`, or `i8-<username>.<domain>` for a name the PDS reserves (vars/main.yml BLUESKY_ADMIN_HANDLE, BLUESKY_ADMIN_HANDLE_RESERVED), and is administered through the `pdsadmin` CLI (Administration.md), not a browser panel.
PERSONA_ADMINISTRATOR_BLOCKED=true
# nocheck: persona-block-rationale — social-app is a mobile-first SPA whose logout control sits behind a profile menu the shared auth-surface check cannot reach; the role's own login-broker OIDC test and its LDAP counterpart already prove the Keycloak → broker → BSKY_STORAGE.accessJwt handoff (see TODO.md and the file header).
+3 -1
View File
@@ -44,7 +44,8 @@ BLUESKY_ROTATION_KEY: "{{ lookup('config', application_id, 'secrets.credentials.
BLUESKY_ADMIN_EMAIL: "{{ lookup('users', 'administrator').email }}"
BLUESKY_ADMIN_PASSWORD: "{{ lookup('users', 'administrator').password }}"
BLUESKY_ADMIN_HANDLE: "kc-{{ lookup('users', 'administrator').username }}.{{ BLUESKY_PDS_HANDLE_DOMAIN }}"
BLUESKY_ADMIN_HANDLE: "{{ lookup('users', 'administrator').username }}.{{ BLUESKY_PDS_HANDLE_DOMAIN }}"
BLUESKY_ADMIN_HANDLE_RESERVED: "i8-{{ BLUESKY_ADMIN_HANDLE }}"
BLUESKY_PDS_INTERNAL_URL: "http://127.0.0.1:{{ BLUESKY_API_PORT }}"
BLUESKY_FRONT_PROXY_CONTENT: "{{ lookup('template', 'extra_locations.conf.j2') }}"
@@ -56,6 +57,7 @@ BLUESKY_BROKER_IMAGE: "bluesky_broker_custom"
BLUESKY_BROKER_BASE_IMAGE: "{{ lookup('config', application_id, 'services.broker.image') }}"
BLUESKY_BROKER_BASE_VERSION: "{{ lookup('config', application_id, 'services.broker.version') }}"
BLUESKY_BROKER_SERVICE: "broker"
BLUESKY_BROKER_STORE_PATH: "/data/accounts.json"
BLUESKY_BROKER_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ BLUESKY_BROKER_SERVICE ~ '.name') }}"
BLUESKY_BROKER_PORT: "{{ lookup('config', application_id, 'services.broker.ports.local.http') }}"
BLUESKY_BROKER_INTERNAL_PORT: "{{ lookup('config', application_id, 'services.broker.ports.internal.http') }}"
@@ -0,0 +1,41 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const PROJECT_ROOT = path.resolve(__dirname, "../../../../../../..");
const {
PDS_HANDLE_LABEL_MAX,
chosenLabelError,
deriveLabel,
fitsPds,
prefixedLabel,
} = require(path.join(PROJECT_ROOT, "roles/web-app-bluesky/files/login-broker/handle.js"));
test("a username becomes its label without any prefix", () => {
assert.equal(deriveLabel("biber"), "biber");
assert.equal(deriveLabel("Biber.Test"), "biber-test");
assert.equal(deriveLabel(""), "user");
});
test("only a reserved name carries the i8- prefix", () => {
assert.equal(prefixedLabel("administrator"), "i8-administrator");
assert.ok(fitsPds(prefixedLabel("administrator")));
});
test("a label longer than the PDS limit needs a chosen handle", () => {
assert.equal(deriveLabel("kevinveenbirkenbach").length, PDS_HANDLE_LABEL_MAX + 1);
assert.equal(fitsPds(deriveLabel("kevinveenbirkenbach")), false);
assert.equal(fitsPds("a".repeat(PDS_HANDLE_LABEL_MAX)), true);
assert.equal(fitsPds("ab"), false);
});
test("a chosen handle must carry a hyphen and never i8-", () => {
assert.equal(chosenLabelError("kevin-veen"), null);
assert.match(chosenLabelError("kevinveen"), /hyphen/);
assert.match(chosenLabelError("i8-kevin"), /i8-/);
assert.match(chosenLabelError("kevin-i8-veen"), /i8-/);
assert.match(chosenLabelError("-kevin"), /start or end/);
assert.match(chosenLabelError("kevin-"), /start or end/);
assert.match(chosenLabelError("Kevin-Veen"), /lowercase/);
assert.match(chosenLabelError("kevin-veen-birkenbach"), /characters/);
});
@@ -0,0 +1,266 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const PROJECT_ROOT = path.resolve(__dirname, "../../../../../../..");
const SERVER = path.join(PROJECT_ROOT, "roles/web-app-bluesky/files/login-broker/server.js");
const DOMAIN = "example.test";
const PUBLIC_HOST = "web.bluesky.example.test";
const ADMIN_PASSWORD = "stub-admin-password";
function startPds() {
const accounts = new Map();
const calls = { createAccount: 0, createAccountDelayMs: 0, adminUpdatePassword: [] };
const reserved = new Set(["administrator", "admin"]);
const server = http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const url = new URL(req.url, "http://pds");
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
const send = (status, payload) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
};
const fail = (error, message) => send(400, { error, message });
if (url.pathname === "/xrpc/com.atproto.server.createAccount") {
calls.createAccount += 1;
const front = body.handle.slice(0, -(DOMAIN.length + 1));
if (front.length > 18) return fail("InvalidHandle", "Handle too long");
if (reserved.has(front)) return fail("HandleNotAvailable", "Reserved handle");
for (const account of accounts.values()) {
if (account.handle === body.handle) return fail("HandleNotAvailable", "Handle already taken");
if (account.email === body.email) return fail("InvalidRequest", "Email already taken");
}
const did = `did:plc:${front}`;
accounts.set(did, { handle: body.handle, email: body.email, password: body.password });
return setTimeout(() => send(200, { did, handle: body.handle }), calls.createAccountDelayMs);
}
if (url.pathname === "/xrpc/com.atproto.server.createSession") {
for (const [did, account] of accounts) {
const matches = account.handle === body.identifier || account.email === body.identifier;
if (matches && account.password === body.password) {
return send(200, { did, handle: account.handle, accessJwt: "a", refreshJwt: "r" });
}
}
return send(401, { error: "AuthenticationRequired", message: "Invalid identifier or password" });
}
if (url.pathname === "/xrpc/com.atproto.identity.resolveHandle") {
for (const [did, account] of accounts) {
if (account.handle === url.searchParams.get("handle")) return send(200, { did });
}
return fail("InvalidRequest", "Unable to resolve handle");
}
if (url.pathname === "/xrpc/com.atproto.admin.updateAccountPassword") {
const expected = `Basic ${Buffer.from(`admin:${ADMIN_PASSWORD}`).toString("base64")}`;
if (req.headers.authorization !== expected) return send(401, { error: "AuthMissing" });
calls.adminUpdatePassword.push(body.did);
if (accounts.has(body.did)) accounts.get(body.did).password = body.password;
return send(200, {});
}
return send(404, { error: "NotFound" });
});
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve({ server, accounts, calls, port: server.address().port }));
});
}
function freePort() {
return new Promise((resolve) => {
const probe = http.createServer().listen(0, "127.0.0.1", () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
});
}
async function startBroker(pdsPort, storePath) {
const port = await freePort();
const child = spawn(process.execPath, [SERVER], {
env: {
PATH: process.env.PATH,
BROKER_PORT: String(port),
SOCIAL_APP_URL: "http://127.0.0.1:9",
PDS_URL: "https://api.bluesky.example.test",
PDS_INTERNAL_URL: `http://127.0.0.1:${pdsPort}`,
PDS_HANDLE_DOMAIN: DOMAIN,
PDS_ADMIN_PASSWORD: ADMIN_PASSWORD,
BLUESKY_BRIDGE_ENCRYPTION_KEY: `base64:${Buffer.alloc(32, 7).toString("base64")}`,
BROKER_STORE_PATH: storePath,
},
stdio: ["ignore", "pipe", "pipe"],
});
await new Promise((resolve, reject) => {
child.stdout.on("data", (chunk) => {
if (chunk.toString().includes("listening")) resolve();
});
child.on("exit", (code) => reject(new Error(`broker exited with ${code}`)));
});
return { child, port };
}
function stopBroker(broker) {
return new Promise((resolve) => {
broker.child.once("exit", resolve);
broker.child.kill();
});
}
function request(broker, { method = "GET", requestPath = "/", user, email, origin, form }) {
const headers = { Host: PUBLIC_HOST };
if (user) headers["X-Forwarded-Preferred-Username"] = user;
if (email) headers["X-Forwarded-Email"] = email;
if (origin) headers.Origin = origin;
const body = form ? new URLSearchParams(form).toString() : null;
if (body) {
headers["Content-Type"] = "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(body);
}
return new Promise((resolve, reject) => {
const req = http.request({ host: "127.0.0.1", port: broker.port, method, path: requestPath, headers }, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString("utf8") }));
});
req.on("error", reject);
if (body) req.write(body);
req.end();
});
}
const ORIGIN = `https://${PUBLIC_HOST}`;
let pds;
let storePath;
let broker;
test.before(async () => {
pds = await startPds();
storePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "broker-server-")), "accounts.json");
broker = await startBroker(pds.port, storePath);
});
test.after(async () => {
await stopBroker(broker);
pds.server.close();
});
test("a short username gets a handle without prefix", async () => {
const res = await request(broker, { user: "biber", email: "biber@example.test" });
assert.equal(res.status, 200);
assert.match(res.body, /BSKY_STORAGE/);
assert.equal(pds.accounts.get("did:plc:biber").handle, `biber.${DOMAIN}`);
});
test("a reserved username falls back to the i8- prefix", async () => {
const res = await request(broker, { user: "administrator", email: "admin@example.test" });
assert.equal(res.status, 200);
assert.equal(pds.accounts.get("did:plc:i8-administrator").handle, `i8-administrator.${DOMAIN}`);
});
test("a username over 18 characters gets the handle form and no account", async () => {
const before = pds.calls.createAccount;
const res = await request(broker, { user: "kevinveenbirkenbach", email: "kevin@example.test" });
assert.equal(res.status, 200);
assert.match(res.body, /Choose your Bluesky handle/);
assert.equal(pds.calls.createAccount, before);
});
test("the handle form refuses a cross-origin or malformed submission", async () => {
const user = { user: "kevinveenbirkenbach", email: "kevin@example.test", method: "POST", requestPath: "/sso/handle" };
const noOrigin = await request(broker, Object.assign({ form: { handle: "kevin-veen" } }, user));
assert.equal(noOrigin.status, 403);
const foreign = await request(broker, Object.assign({ origin: "https://evil.example", form: { handle: "kevin-veen" } }, user));
assert.equal(foreign.status, 403);
const noHyphen = await request(broker, Object.assign({ origin: ORIGIN, form: { handle: "kevinveen" } }, user));
assert.equal(noHyphen.status, 400);
assert.match(noHyphen.body, /hyphen/);
const prefixed = await request(broker, Object.assign({ origin: ORIGIN, form: { handle: "i8-kevin" } }, user));
assert.equal(prefixed.status, 400);
});
test("an origin with a port still matches the proxied host", async () => {
const res = await request(broker, {
user: "kevinveenbirkenbach",
email: "kevin@example.test",
method: "POST",
requestPath: "/sso/handle",
origin: `${ORIGIN}:8443`,
form: { handle: "kevin-veen" },
});
assert.equal(res.status, 200);
assert.match(res.body, /BSKY_STORAGE/);
assert.equal(pds.accounts.get("did:plc:kevin-veen").handle, `kevin-veen.${DOMAIN}`);
});
test("a chosen handle another user holds is offered again, not taken over", async () => {
const res = await request(broker, {
user: "anotherverylongusername",
email: "other@example.test",
method: "POST",
requestPath: "/sso/handle",
origin: ORIGIN,
form: { handle: "kevin-veen" },
});
assert.equal(res.status, 409);
assert.match(res.body, /already taken/);
assert.equal(pds.calls.adminUpdatePassword.includes("did:plc:kevin-veen"), false);
});
test("a derived handle whose name another user chose is never taken over", async () => {
const res = await request(broker, { user: "kevin.veen", email: "kv@example.test" });
assert.equal(res.status, 500);
assert.equal(pds.calls.adminUpdatePassword.includes("did:plc:kevin-veen"), false);
});
test("an e-mail address a legacy kc- account holds is reported, not retried", async () => {
pds.accounts.set("did:plc:legacy", { handle: `kc-legacy.${DOMAIN}`, email: "legacy@example.test", password: "x" });
const res = await request(broker, { user: "legacy", email: "legacy@example.test" });
assert.equal(res.status, 409);
assert.match(res.body, /legacy kc- account/);
});
test("concurrent first visits create one account", async () => {
const before = pds.calls.createAccount;
pds.calls.createAccountDelayMs = 200;
const visits = await Promise.all([
request(broker, { user: "mapache", email: "mapache@example.test" }),
request(broker, { user: "mapache", email: "mapache@example.test" }),
]);
pds.calls.createAccountDelayMs = 0;
assert.deepEqual(visits.map((v) => v.status), [200, 200]);
assert.equal(pds.calls.createAccount, before + 1);
});
test("a broker restart keeps every account", async () => {
await stopBroker(broker);
broker = await startBroker(pds.port, storePath);
const before = pds.calls.createAccount;
for (const [user, email] of [["biber", "biber@example.test"], ["kevinveenbirkenbach", "kevin@example.test"]]) {
const res = await request(broker, { user, email });
assert.equal(res.status, 200);
assert.match(res.body, /BSKY_STORAGE/);
}
assert.equal(pds.calls.createAccount, before);
});
test("an account the PDS lost is provisioned again", async () => {
pds.accounts.delete("did:plc:biber");
const res = await request(broker, { user: "biber", email: "biber@example.test" });
assert.equal(res.status, 200);
assert.match(res.body, /BSKY_STORAGE/);
assert.ok(pds.accounts.has("did:plc:biber"));
});
test("a password the PDS no longer accepts is reset in place", async () => {
pds.accounts.get("did:plc:i8-administrator").password = "changed-elsewhere";
const before = pds.calls.createAccount;
const res = await request(broker, { user: "administrator", email: "admin@example.test" });
assert.equal(res.status, 200);
assert.equal(pds.calls.createAccount, before);
assert.ok(pds.calls.adminUpdatePassword.includes("did:plc:i8-administrator"));
});
@@ -0,0 +1,55 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const PROJECT_ROOT = path.resolve(__dirname, "../../../../../../..");
const { openStore } = require(
path.join(PROJECT_ROOT, "roles/web-app-bluesky/files/login-broker/store.js"),
);
function tempFile() {
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "broker-store-")), "accounts.json");
}
test("an entry survives a reopen, as after a broker restart", () => {
const file = tempFile();
openStore(file).set("biber", { handle: "biber.example", did: "did:plc:b", appPasswordEnc: "x" });
const reopened = openStore(file);
assert.deepEqual(reopened.get("biber"), {
handle: "biber.example",
did: "did:plc:b",
appPasswordEnc: "x",
});
assert.equal(reopened.get("mapache"), null);
});
test("ownerOf names the username that holds a handle", () => {
const store = openStore(tempFile());
store.set("kevinveenbirkenbach", { handle: "kevin-veen.example", did: "did:plc:k", appPasswordEnc: "y" });
assert.equal(store.ownerOf("kevin-veen.example"), "kevinveenbirkenbach");
assert.equal(store.ownerOf("other.example"), null);
});
test("a deleted entry stays gone after a reopen", () => {
const file = tempFile();
const store = openStore(file);
store.set("biber", { handle: "biber.example", did: "did:plc:b", appPasswordEnc: "x" });
store.delete("biber");
assert.equal(openStore(file).get("biber"), null);
assert.equal(openStore(file).ownerOf("biber.example"), null);
});
test("the file is written owner-only", () => {
const file = tempFile();
openStore(file).set("biber", { handle: "biber.example", did: "did:plc:b", appPasswordEnc: "x" });
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
});
test("a username named like an object key stays a plain entry", () => {
const file = tempFile();
openStore(file).set("__proto__", { handle: "p.example", did: "did:plc:p", appPasswordEnc: "z" });
assert.equal(openStore(file).get("__proto__").handle, "p.example");
assert.equal({}.handle, undefined);
});