@cardstack/boxel-cli 0.2.0-unstable.327 → 0.2.0-unstable.446

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": "@cardstack/boxel-cli",
3
- "version": "0.2.0-unstable.327",
3
+ "version": "0.2.0-unstable.446",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
@@ -54,8 +54,8 @@
54
54
  "vite": "^6.3.2",
55
55
  "vitest": "^2.1.9",
56
56
  "@cardstack/postgres": "0.0.0",
57
- "@cardstack/local-types": "0.0.0",
58
- "@cardstack/runtime-common": "1.0.0"
57
+ "@cardstack/runtime-common": "1.0.0",
58
+ "@cardstack/local-types": "0.0.0"
59
59
  },
60
60
  "publishConfig": {
61
61
  "access": "public",
@@ -80,7 +80,7 @@ function validateUrl(input: string, label: string): string {
80
80
 
81
81
  // Matches scripts/env-slug.sh: lowercase, "/" -> "-", strip chars outside
82
82
  // [a-z0-9-], collapse runs of "-", trim leading/trailing "-".
83
- function computeEnvSlug(name: string): string {
83
+ export function computeEnvSlug(name: string): string {
84
84
  return name
85
85
  .toLowerCase()
86
86
  .replace(/\//g, '-')
@@ -91,7 +91,7 @@ function computeEnvSlug(name: string): string {
91
91
 
92
92
  // Derive URLs from BOXEL_ENVIRONMENT using the same ".${slug}.localhost"
93
93
  // pattern that mise-tasks/lib/env-vars.sh produces for env-mode local dev.
94
- function resolveBoxelEnvironment(): EnvironmentDefaults | null {
94
+ export function resolveBoxelEnvironment(): EnvironmentDefaults | null {
95
95
  const raw = process.env.BOXEL_ENVIRONMENT;
96
96
  if (!raw || !raw.trim()) return null;
97
97
  const slug = computeEnvSlug(raw);
@@ -458,14 +458,28 @@ async function addProfileNonInteractive(
458
458
  process.exit(1);
459
459
  }
460
460
 
461
- if (manager.getProfile(matrixId)) {
462
- console.log(
463
- `${FG_YELLOW}Profile ${matrixId} already exists. Updating password.${RESET}`,
461
+ const isUpdate = Boolean(manager.getProfile(matrixId));
462
+
463
+ // addProfile performs a real matrixLogin and persists the resulting
464
+ // access token (the password never lands on disk). It also handles the
465
+ // create-vs-reauth split uniformly: re-running it on an existing profile
466
+ // refreshes the stored token while preserving cached realm tokens.
467
+ try {
468
+ await manager.addProfile(
469
+ matrixId,
470
+ password,
471
+ displayName,
472
+ matrixUrl,
473
+ realmServerUrl,
464
474
  );
465
- await manager.updatePassword(matrixId, password);
466
- if (displayName) {
467
- manager.updateDisplayName(matrixId, displayName);
468
- }
475
+ } catch (err) {
476
+ console.error(
477
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
478
+ );
479
+ process.exit(1);
480
+ }
481
+
482
+ if (isUpdate) {
469
483
  if (matrixUrl || realmServerUrl) {
470
484
  const urlsChanged = manager.updateUrls(matrixId, {
471
485
  matrixUrl,
@@ -483,20 +497,6 @@ async function addProfileNonInteractive(
483
497
  return;
484
498
  }
485
499
 
486
- try {
487
- await manager.addProfile(
488
- matrixId,
489
- password,
490
- displayName,
491
- matrixUrl,
492
- realmServerUrl,
493
- );
494
- } catch (err) {
495
- console.error(
496
- `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
497
- );
498
- process.exit(1);
499
- }
500
500
  console.log(
501
501
  `${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`,
502
502
  );
@@ -538,7 +538,7 @@ async function migrateFromEnv(manager: ProfileManager): Promise<void> {
538
538
  );
539
539
  } else {
540
540
  console.log(
541
- `${FG_YELLOW}Profile ${formatProfileBadge(result.profileId)} already exists.${RESET} Password has been updated if it changed.`,
541
+ `${FG_GREEN}\u2713${RESET} Refreshed profile: ${formatProfileBadge(result.profileId)}`,
542
542
  );
543
543
  console.log(
544
544
  `\n${DIM}Use 'boxel profile add -u ${result.profileId} -p <password>' to update other fields.${RESET}`,
@@ -4,11 +4,13 @@ import { registerCreateCommand } from './create';
4
4
  import { registerHistoryCommand } from './history';
5
5
  import { registerListCommand } from './list';
6
6
  import { registerMilestoneCommand } from './milestone';
7
+ import { registerPublishCommand } from './publish';
7
8
  import { registerPullCommand } from './pull';
8
9
  import { registerPushCommand } from './push';
9
10
  import { registerRemoveCommand } from './remove';
10
11
  import { registerStatusCommand } from './status';
11
12
  import { registerSyncCommand } from './sync';
13
+ import { registerUnpublishCommand } from './unpublish';
12
14
  import { registerWaitForReadyCommand } from './wait-for-ready';
13
15
  import { registerWatchCommand } from './watch';
14
16
 
@@ -22,11 +24,13 @@ export function registerRealmCommand(program: Command): void {
22
24
  registerHistoryCommand(realm);
23
25
  registerListCommand(realm);
24
26
  registerMilestoneCommand(realm);
27
+ registerPublishCommand(realm);
25
28
  registerPullCommand(realm);
26
29
  registerPushCommand(realm);
27
30
  registerRemoveCommand(realm);
28
31
  const sync = registerSyncCommand(realm);
29
32
  registerStatusCommand(sync);
33
+ registerUnpublishCommand(realm);
30
34
  registerWaitForReadyCommand(realm);
31
35
  registerWatchCommand(realm);
32
36
  }
@@ -0,0 +1,291 @@
1
+ import type { Command } from 'commander';
2
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
3
+ import {
4
+ getProfileManager,
5
+ NO_ACTIVE_PROFILE_ERROR,
6
+ type ProfileManager,
7
+ } from '../../lib/profile-manager';
8
+ import { unpublishRealm } from './unpublish';
9
+ import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors';
10
+
11
+ const DEFAULT_TIMEOUT_MS = 300_000;
12
+ const READINESS_POLL_INTERVAL_MS = 1000;
13
+
14
+ export interface PublishOptions {
15
+ /** Wait for the published realm to pass readiness check (default: true). */
16
+ waitForReady?: boolean;
17
+ /** Readiness-poll timeout in milliseconds (default: 300_000). */
18
+ timeoutMs?: number;
19
+ /**
20
+ * When the server returns 400/409 (e.g. an existing publication conflicts),
21
+ * unpublish the target URL first and retry once. Default: true.
22
+ */
23
+ republish?: boolean;
24
+ profileManager?: ProfileManager;
25
+ }
26
+
27
+ export interface PublishRealmResult {
28
+ publishedRealmURL: string;
29
+ publishedRealmId: string;
30
+ lastPublishedAt: string;
31
+ status: string;
32
+ }
33
+
34
+ /**
35
+ * Publish a source realm to a published-realm URL.
36
+ *
37
+ * Speaks the contract documented at
38
+ * `packages/realm-server/handlers/handle-publish-realm.ts`: the server
39
+ * accepts the publish, returns `202 Accepted` with `status: "pending"`,
40
+ * and the client polls `/<publishedRealmURL>/_readiness-check` until
41
+ * the realm is mounted and indexed. 200/201 are accepted too so this
42
+ * function survives any future move back to a synchronous handler.
43
+ */
44
+ export async function publishRealm(
45
+ sourceRealmURL: string,
46
+ publishedRealmURL: string,
47
+ options: PublishOptions = {},
48
+ ): Promise<PublishRealmResult> {
49
+ let pm = options.profileManager ?? getProfileManager();
50
+ let active = pm.getActiveProfile();
51
+ if (!active) {
52
+ throw new Error(NO_ACTIVE_PROFILE_ERROR);
53
+ }
54
+
55
+ let normalizedSource = ensureTrailingSlash(sourceRealmURL);
56
+ let normalizedPublished = ensureTrailingSlash(publishedRealmURL);
57
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
58
+
59
+ let response = await postPublish(
60
+ pm,
61
+ realmServerUrl,
62
+ normalizedSource,
63
+ normalizedPublished,
64
+ );
65
+
66
+ if (
67
+ (response.status === 400 || response.status === 409) &&
68
+ options.republish !== false
69
+ ) {
70
+ let conflictBody = await safeReadResponseText(response);
71
+ console.log(
72
+ `Publish returned ${response.status} (${conflictBody.slice(0, 200)}). Unpublishing and retrying.`,
73
+ );
74
+ let unpublishResult = await unpublishRealm(normalizedPublished, {
75
+ profileManager: pm,
76
+ tolerateMissing: true,
77
+ });
78
+ if (!unpublishResult.unpublished && !unpublishResult.notFound) {
79
+ throw new Error(
80
+ `Conflict on publish; unpublish-then-retry also failed: ${
81
+ unpublishResult.error ?? 'unknown'
82
+ }`,
83
+ );
84
+ }
85
+ response = await postPublish(
86
+ pm,
87
+ realmServerUrl,
88
+ normalizedSource,
89
+ normalizedPublished,
90
+ );
91
+ }
92
+
93
+ if (
94
+ response.status !== 200 &&
95
+ response.status !== 201 &&
96
+ response.status !== 202
97
+ ) {
98
+ let body = await safeReadResponseText(response);
99
+ throw new Error(
100
+ `Publish failed: HTTP ${response.status}: ${body.slice(0, 1000)}`,
101
+ );
102
+ }
103
+
104
+ let body = (await response.json()) as PublishResponseBody;
105
+ let attrs = body?.data?.attributes;
106
+ if (!attrs?.publishedRealmURL) {
107
+ throw new Error(
108
+ `Publish response missing data.attributes.publishedRealmURL: ${JSON.stringify(
109
+ body,
110
+ ).slice(0, 500)}`,
111
+ );
112
+ }
113
+
114
+ let result: PublishRealmResult = {
115
+ publishedRealmURL: ensureTrailingSlash(attrs.publishedRealmURL),
116
+ publishedRealmId: body.data.id,
117
+ lastPublishedAt: attrs.lastPublishedAt,
118
+ status: attrs.status,
119
+ };
120
+
121
+ if (options.waitForReady !== false) {
122
+ let timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
123
+ let realmToken: string | undefined;
124
+ try {
125
+ let serverToken = await pm.getOrRefreshServerToken();
126
+ realmToken = await pm.fetchAndStoreRealmToken(
127
+ result.publishedRealmURL,
128
+ serverToken,
129
+ );
130
+ } catch {
131
+ // The published realm is permission-public-read; fall through to
132
+ // poll without an Authorization header.
133
+ }
134
+ await waitForPublishedRealmReady(
135
+ result.publishedRealmURL,
136
+ realmToken,
137
+ timeoutMs,
138
+ );
139
+ }
140
+
141
+ return result;
142
+ }
143
+
144
+ interface PublishResponseBody {
145
+ data: {
146
+ type: 'published_realm';
147
+ id: string;
148
+ attributes: {
149
+ sourceRealmURL: string;
150
+ publishedRealmURL: string;
151
+ lastPublishedAt: string;
152
+ status: string;
153
+ };
154
+ };
155
+ }
156
+
157
+ async function postPublish(
158
+ pm: ProfileManager,
159
+ realmServerUrl: string,
160
+ sourceRealmURL: string,
161
+ publishedRealmURL: string,
162
+ ): Promise<Response> {
163
+ return pm.authedRealmServerFetch(`${realmServerUrl}/_publish-realm`, {
164
+ method: 'POST',
165
+ headers: {
166
+ Accept: 'application/vnd.api+json',
167
+ 'Content-Type': 'application/json',
168
+ },
169
+ body: JSON.stringify({ sourceRealmURL, publishedRealmURL }),
170
+ });
171
+ }
172
+
173
+ async function waitForPublishedRealmReady(
174
+ publishedRealmURL: string,
175
+ realmToken: string | undefined,
176
+ timeoutMs: number,
177
+ ): Promise<void> {
178
+ let readinessUrl = new URL('_readiness-check', publishedRealmURL).href;
179
+ let startedAt = Date.now();
180
+ let lastError: string | undefined;
181
+
182
+ while (Date.now() - startedAt < timeoutMs) {
183
+ try {
184
+ let headers: Record<string, string> = {
185
+ Accept: 'application/vnd.api+json',
186
+ };
187
+ if (realmToken) {
188
+ headers.Authorization = realmToken;
189
+ }
190
+ let response = await fetch(readinessUrl, { headers });
191
+ if (response.ok) {
192
+ return;
193
+ }
194
+ lastError = `HTTP ${response.status}`;
195
+ } catch (error) {
196
+ lastError = error instanceof Error ? error.message : String(error);
197
+ }
198
+ let remaining = timeoutMs - (Date.now() - startedAt);
199
+ if (remaining <= 0) break;
200
+ await new Promise((r) =>
201
+ setTimeout(r, Math.min(READINESS_POLL_INTERVAL_MS, remaining)),
202
+ );
203
+ }
204
+
205
+ throw new Error(
206
+ `Timed out after ${timeoutMs}ms waiting for ${publishedRealmURL} to pass readiness check${
207
+ lastError ? `: ${lastError}` : ''
208
+ }`,
209
+ );
210
+ }
211
+
212
+ async function safeReadResponseText(response: Response): Promise<string> {
213
+ try {
214
+ return await response.text();
215
+ } catch {
216
+ return '<no response body>';
217
+ }
218
+ }
219
+
220
+ export interface PublishCliOptions {
221
+ // Commander exposes `--no-wait` / `--no-republish` on the positive
222
+ // keys (`wait` / `republish`), defaulting to `true` and flipping to
223
+ // `false` when the negated flag is passed.
224
+ wait?: boolean;
225
+ timeout?: number;
226
+ republish?: boolean;
227
+ }
228
+
229
+ export function publishCliOptsToOptions(
230
+ opts: PublishCliOptions,
231
+ ): PublishOptions {
232
+ return {
233
+ waitForReady: opts.wait !== false,
234
+ timeoutMs: opts.timeout,
235
+ republish: opts.republish !== false,
236
+ };
237
+ }
238
+
239
+ export function registerPublishCommand(realm: Command): void {
240
+ realm
241
+ .command('publish')
242
+ .description(
243
+ 'Publish a source realm to a published-realm URL, polling readiness until ready',
244
+ )
245
+ .argument('<source-realm-url>', 'URL of the source realm to publish')
246
+ .argument(
247
+ '<published-realm-url>',
248
+ 'Public-facing URL the published copy will serve at',
249
+ )
250
+ .option('--no-wait', 'Return as soon as the server accepts the publish')
251
+ .option(
252
+ '--timeout <ms>',
253
+ `Readiness-poll timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`,
254
+ parseTimeoutOption,
255
+ )
256
+ .option(
257
+ '--no-republish',
258
+ 'Do not auto-unpublish + retry when the server returns 400/409',
259
+ )
260
+ .action(
261
+ async (
262
+ sourceRealmURL: string,
263
+ publishedRealmURL: string,
264
+ opts: PublishCliOptions,
265
+ ) => {
266
+ try {
267
+ let result = await publishRealm(
268
+ sourceRealmURL,
269
+ publishedRealmURL,
270
+ publishCliOptsToOptions(opts),
271
+ );
272
+ console.log(
273
+ `${FG_GREEN}Published:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`,
274
+ );
275
+ } catch (err) {
276
+ console.error(
277
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
278
+ );
279
+ process.exit(1);
280
+ }
281
+ },
282
+ );
283
+ }
284
+
285
+ function parseTimeoutOption(value: string): number {
286
+ let n = Number.parseInt(value, 10);
287
+ if (!Number.isFinite(n) || n < 0 || String(n) !== value.trim()) {
288
+ throw new Error('--timeout must be a non-negative integer (milliseconds).');
289
+ }
290
+ return n;
291
+ }
@@ -0,0 +1,150 @@
1
+ import type { Command } from 'commander';
2
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
3
+ import {
4
+ getProfileManager,
5
+ NO_ACTIVE_PROFILE_ERROR,
6
+ type ProfileManager,
7
+ } from '../../lib/profile-manager';
8
+ import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors';
9
+
10
+ export interface UnpublishOptions {
11
+ /**
12
+ * When true, do not fail if the server reports the realm was already
13
+ * unpublished. Useful for cleanup paths that must be idempotent (e.g.
14
+ * a PR-close hook that runs even if a previous close already unpublished).
15
+ * Default: false.
16
+ */
17
+ tolerateMissing?: boolean;
18
+ profileManager?: ProfileManager;
19
+ }
20
+
21
+ export interface UnpublishRealmResult {
22
+ publishedRealmURL: string;
23
+ unpublished: boolean;
24
+ notFound?: boolean;
25
+ error?: string;
26
+ }
27
+
28
+ /**
29
+ * Unpublish a published realm. Mirrors `boxel realm publish`'s contract
30
+ * with `/_unpublish-realm`.
31
+ *
32
+ * The realm-server returns 200 on success and 422 with a "not found" body
33
+ * when the URL isn't currently published. We special-case the latter (and
34
+ * 404, defensively) so cleanup callers can run unconditionally.
35
+ */
36
+ export async function unpublishRealm(
37
+ publishedRealmURL: string,
38
+ options: UnpublishOptions = {},
39
+ ): Promise<UnpublishRealmResult> {
40
+ let normalized = ensureTrailingSlash(publishedRealmURL);
41
+ let pm = options.profileManager ?? getProfileManager();
42
+ let active = pm.getActiveProfile();
43
+ if (!active) {
44
+ return {
45
+ publishedRealmURL: normalized,
46
+ unpublished: false,
47
+ error: NO_ACTIVE_PROFILE_ERROR,
48
+ };
49
+ }
50
+
51
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
52
+
53
+ let response: Response;
54
+ try {
55
+ response = await pm.authedRealmServerFetch(
56
+ `${realmServerUrl}/_unpublish-realm`,
57
+ {
58
+ method: 'POST',
59
+ headers: {
60
+ Accept: 'application/vnd.api+json',
61
+ 'Content-Type': 'application/json',
62
+ },
63
+ body: JSON.stringify({ publishedRealmURL: normalized }),
64
+ },
65
+ );
66
+ } catch (err) {
67
+ return {
68
+ publishedRealmURL: normalized,
69
+ unpublished: false,
70
+ error: `Failed to reach realm server: ${
71
+ err instanceof Error ? err.message : String(err)
72
+ }`,
73
+ };
74
+ }
75
+
76
+ if (response.ok) {
77
+ return { publishedRealmURL: normalized, unpublished: true };
78
+ }
79
+
80
+ let body = await safeReadResponseText(response);
81
+ let looksLikeNotFound =
82
+ response.status === 404 ||
83
+ (response.status === 422 && /not found/i.test(body));
84
+
85
+ if (looksLikeNotFound) {
86
+ if (options.tolerateMissing) {
87
+ return {
88
+ publishedRealmURL: normalized,
89
+ unpublished: false,
90
+ notFound: true,
91
+ };
92
+ }
93
+ return {
94
+ publishedRealmURL: normalized,
95
+ unpublished: false,
96
+ notFound: true,
97
+ error: `Published realm ${normalized} is not currently published`,
98
+ };
99
+ }
100
+
101
+ return {
102
+ publishedRealmURL: normalized,
103
+ unpublished: false,
104
+ error: `Realm server returned ${response.status}: ${body.slice(0, 500)}`,
105
+ };
106
+ }
107
+
108
+ async function safeReadResponseText(response: Response): Promise<string> {
109
+ try {
110
+ return await response.text();
111
+ } catch {
112
+ return '<no response body>';
113
+ }
114
+ }
115
+
116
+ interface UnpublishCliOptions {
117
+ tolerateMissing?: boolean;
118
+ }
119
+
120
+ export function registerUnpublishCommand(realm: Command): void {
121
+ realm
122
+ .command('unpublish')
123
+ .description('Unpublish a published realm by its public-facing URL')
124
+ .argument('<published-realm-url>', 'URL of the published realm to remove')
125
+ .option(
126
+ '--tolerate-missing',
127
+ 'Exit successfully when the realm is already unpublished',
128
+ )
129
+ .action(async (publishedRealmURL: string, opts: UnpublishCliOptions) => {
130
+ let result = await unpublishRealm(publishedRealmURL, {
131
+ tolerateMissing: opts.tolerateMissing === true,
132
+ });
133
+
134
+ if (result.error) {
135
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
136
+ process.exit(1);
137
+ }
138
+
139
+ if (result.notFound) {
140
+ console.log(
141
+ `Already unpublished: ${FG_CYAN}${result.publishedRealmURL}${RESET}`,
142
+ );
143
+ return;
144
+ }
145
+
146
+ console.log(
147
+ `${FG_GREEN}Unpublished:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`,
148
+ );
149
+ });
150
+ }
package/src/lib/auth.ts CHANGED
@@ -7,6 +7,17 @@ export interface MatrixAuth {
7
7
 
8
8
  export type RealmTokens = Record<string, string>;
9
9
 
10
+ // Thrown when Matrix rejects an access token (401/403). Callers can catch
11
+ // this specifically to drive interactive re-auth without parsing messages.
12
+ export class MatrixAuthError extends Error {
13
+ status: number;
14
+ constructor(status: number, message: string) {
15
+ super(message);
16
+ this.name = 'MatrixAuthError';
17
+ this.status = status;
18
+ }
19
+ }
20
+
10
21
  interface MatrixLoginResponse {
11
22
  access_token: string;
12
23
  device_id: string;
@@ -69,6 +80,12 @@ async function getOpenIdToken(
69
80
 
70
81
  if (!response.ok) {
71
82
  let text = await response.text();
83
+ if (response.status === 401 || response.status === 403) {
84
+ throw new MatrixAuthError(
85
+ response.status,
86
+ `OpenID token request failed: ${response.status} ${text}`,
87
+ );
88
+ }
72
89
  throw new Error(`OpenID token request failed: ${response.status} ${text}`);
73
90
  }
74
91
 
@@ -138,17 +155,30 @@ function userRealmsAccountDataUrl(matrixAuth: MatrixAuth): string {
138
155
  export async function getUserRealmsFromMatrixAccountData(
139
156
  matrixAuth: MatrixAuth,
140
157
  ): Promise<string[]> {
158
+ let response: Response;
141
159
  try {
142
- let response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
160
+ response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
143
161
  headers: { Authorization: `Bearer ${matrixAuth.accessToken}` },
144
162
  });
145
- if (!response.ok) {
146
- return [];
147
- }
163
+ } catch {
164
+ // Network unreachable / DNS / similar — treat as empty (best-effort).
165
+ return [];
166
+ }
167
+ if (response.status === 401 || response.status === 403) {
168
+ let text = await response.text();
169
+ throw new MatrixAuthError(
170
+ response.status,
171
+ `Matrix account_data fetch failed: ${response.status} ${text}`,
172
+ );
173
+ }
174
+ if (!response.ok) {
175
+ // 404 just means the event has never been set — return empty list.
176
+ return [];
177
+ }
178
+ try {
148
179
  let data = (await response.json()) as { realms?: string[] };
149
180
  return Array.isArray(data.realms) ? [...data.realms] : [];
150
181
  } catch {
151
- // Best-effort — treat unreachable account data as an empty list
152
182
  return [];
153
183
  }
154
184
  }
@@ -171,6 +201,12 @@ export async function addRealmToMatrixAccountData(
171
201
  });
172
202
  if (!putResponse.ok) {
173
203
  let text = await putResponse.text();
204
+ if (putResponse.status === 401 || putResponse.status === 403) {
205
+ throw new MatrixAuthError(
206
+ putResponse.status,
207
+ `Failed to update Matrix account data: ${putResponse.status} ${text}`,
208
+ );
209
+ }
174
210
  throw new Error(
175
211
  `Failed to update Matrix account data: ${putResponse.status} ${text}`,
176
212
  );
@@ -205,6 +241,12 @@ export async function removeRealmFromMatrixAccountData(
205
241
  });
206
242
  if (!putResponse.ok) {
207
243
  let text = await putResponse.text();
244
+ if (putResponse.status === 401 || putResponse.status === 403) {
245
+ throw new MatrixAuthError(
246
+ putResponse.status,
247
+ `Failed to update Matrix account data: ${putResponse.status} ${text}`,
248
+ );
249
+ }
208
250
  throw new Error(
209
251
  `Failed to update Matrix account data: ${putResponse.status} ${text}`,
210
252
  );