Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 24 additions & 24 deletions packages/boxel-cli/src/commands/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function validateUrl(input: string, label: string): string {

// Matches scripts/env-slug.sh: lowercase, "/" -> "-", strip chars outside
// [a-z0-9-], collapse runs of "-", trim leading/trailing "-".
function computeEnvSlug(name: string): string {
export function computeEnvSlug(name: string): string {
return name
.toLowerCase()
.replace(/\//g, '-')
Expand All @@ -91,7 +91,7 @@ function computeEnvSlug(name: string): string {

// Derive URLs from BOXEL_ENVIRONMENT using the same ".${slug}.localhost"
// pattern that mise-tasks/lib/env-vars.sh produces for env-mode local dev.
function resolveBoxelEnvironment(): EnvironmentDefaults | null {
export function resolveBoxelEnvironment(): EnvironmentDefaults | null {
const raw = process.env.BOXEL_ENVIRONMENT;
if (!raw || !raw.trim()) return null;
const slug = computeEnvSlug(raw);
Expand Down Expand Up @@ -458,14 +458,28 @@ async function addProfileNonInteractive(
process.exit(1);
}

if (manager.getProfile(matrixId)) {
console.log(
`${FG_YELLOW}Profile ${matrixId} already exists. Updating password.${RESET}`,
const isUpdate = Boolean(manager.getProfile(matrixId));

// addProfile performs a real matrixLogin and persists the resulting
// access token (the password never lands on disk). It also handles the
// create-vs-reauth split uniformly: re-running it on an existing profile
// refreshes the stored token while preserving cached realm tokens.
try {
await manager.addProfile(
matrixId,
password,
displayName,
matrixUrl,
Comment on lines +468 to +472
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate cached tokens before rewriting profile URLs

When profile add updates an existing profile, this now calls manager.addProfile(...) before updateUrls(...). addProfileWithAuth preserves the previous realmTokens and realmServerToken, and because URLs have already been rewritten by the time updateUrls runs, it sees no change and does not clear those caches. That leaves tokens minted for the old server in the profile; code paths like realm create that use getOrRefreshServerToken() + fetchAndStoreRealmToken() without an automatic 401 retry can fail or silently skip token acquisition after a URL change.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e830ad1. addProfileWithAuth now compares the resolved URLs against the existing profile and clears realmTokens/realmServerToken when either URL changes. Independently, addProfile defaults omitted URL args to the existing profile so the no-flag re-auth path no longer rewrites URLs at all.

realmServerUrl,
);
Comment on lines +461 to 474
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e830ad1. addProfile now defaults omitted matrixUrl/realmServerUrl to the existing profile's stored values before calling resolveProfileSlots, so re-running profile add -u <id> -p <pw> against an existing custom-domain profile no longer throws "Unknown domain" or resets the URLs to defaults. Covered by a new test.

await manager.updatePassword(matrixId, password);
if (displayName) {
manager.updateDisplayName(matrixId, displayName);
}
} catch (err) {
console.error(
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}

if (isUpdate) {
if (matrixUrl || realmServerUrl) {
const urlsChanged = manager.updateUrls(matrixId, {
matrixUrl,
Expand All @@ -483,20 +497,6 @@ async function addProfileNonInteractive(
return;
}

try {
await manager.addProfile(
matrixId,
password,
displayName,
matrixUrl,
realmServerUrl,
);
} catch (err) {
console.error(
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
console.log(
`${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`,
);
Expand Down Expand Up @@ -538,7 +538,7 @@ async function migrateFromEnv(manager: ProfileManager): Promise<void> {
);
} else {
console.log(
`${FG_YELLOW}Profile ${formatProfileBadge(result.profileId)} already exists.${RESET} Password has been updated if it changed.`,
`${FG_GREEN}\u2713${RESET} Refreshed profile: ${formatProfileBadge(result.profileId)}`,
);
console.log(
`\n${DIM}Use 'boxel profile add -u ${result.profileId} -p <password>' to update other fields.${RESET}`,
Expand Down
52 changes: 47 additions & 5 deletions packages/boxel-cli/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ export interface MatrixAuth {

export type RealmTokens = Record<string, string>;

// Thrown when Matrix rejects an access token (401/403). Callers can catch
// this specifically to drive interactive re-auth without parsing messages.
export class MatrixAuthError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'MatrixAuthError';
this.status = status;
}
}

interface MatrixLoginResponse {
access_token: string;
device_id: string;
Expand Down Expand Up @@ -69,6 +80,12 @@ async function getOpenIdToken(

if (!response.ok) {
let text = await response.text();
if (response.status === 401 || response.status === 403) {
throw new MatrixAuthError(
response.status,
`OpenID token request failed: ${response.status} ${text}`,
);
}
throw new Error(`OpenID token request failed: ${response.status} ${text}`);
}

Expand Down Expand Up @@ -138,17 +155,30 @@ function userRealmsAccountDataUrl(matrixAuth: MatrixAuth): string {
export async function getUserRealmsFromMatrixAccountData(
matrixAuth: MatrixAuth,
): Promise<string[]> {
let response: Response;
try {
let response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
headers: { Authorization: `Bearer ${matrixAuth.accessToken}` },
});
if (!response.ok) {
return [];
}
} catch {
// Network unreachable / DNS / similar — treat as empty (best-effort).
return [];
}
if (response.status === 401 || response.status === 403) {
let text = await response.text();
throw new MatrixAuthError(
response.status,
`Matrix account_data fetch failed: ${response.status} ${text}`,
);
}
if (!response.ok) {
// 404 just means the event has never been set — return empty list.
return [];
}
try {
let data = (await response.json()) as { realms?: string[] };
return Array.isArray(data.realms) ? [...data.realms] : [];
} catch {
// Best-effort — treat unreachable account data as an empty list
return [];
}
}
Expand All @@ -171,6 +201,12 @@ export async function addRealmToMatrixAccountData(
});
if (!putResponse.ok) {
let text = await putResponse.text();
if (putResponse.status === 401 || putResponse.status === 403) {
throw new MatrixAuthError(
putResponse.status,
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
);
}
throw new Error(
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
);
Expand Down Expand Up @@ -205,6 +241,12 @@ export async function removeRealmFromMatrixAccountData(
});
if (!putResponse.ok) {
let text = await putResponse.text();
if (putResponse.status === 401 || putResponse.status === 403) {
throw new MatrixAuthError(
putResponse.status,
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
);
}
throw new Error(
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
);
Expand Down
Loading
Loading