@cardstack/boxel-cli 0.0.1 → 0.1.0

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.
Files changed (41) hide show
  1. package/README.md +124 -0
  2. package/api.ts +3 -0
  3. package/bin/boxel.js +15 -0
  4. package/dist/index.js +107 -66
  5. package/package.json +31 -24
  6. package/src/commands/file/delete.ts +110 -0
  7. package/src/commands/file/index.ts +20 -0
  8. package/src/commands/file/lint.ts +235 -0
  9. package/src/commands/file/list.ts +121 -0
  10. package/src/commands/file/read.ts +113 -0
  11. package/src/commands/file/touch.ts +222 -0
  12. package/src/commands/file/write.ts +152 -0
  13. package/src/commands/profile.ts +199 -106
  14. package/src/commands/read-transpiled.ts +120 -0
  15. package/src/commands/realm/cancel-indexing.ts +113 -0
  16. package/src/commands/realm/create.ts +1 -4
  17. package/src/commands/realm/history.ts +388 -0
  18. package/src/commands/realm/index.ts +12 -0
  19. package/src/commands/realm/list.ts +156 -0
  20. package/src/commands/realm/pull.ts +51 -17
  21. package/src/commands/realm/push.ts +52 -16
  22. package/src/commands/realm/remove.ts +281 -0
  23. package/src/commands/realm/sync.ts +153 -60
  24. package/src/commands/realm/wait-for-ready.ts +120 -0
  25. package/src/commands/realm/watch.ts +626 -0
  26. package/src/commands/run-command.ts +4 -3
  27. package/src/commands/search.ts +160 -0
  28. package/src/index.ts +60 -2
  29. package/src/lib/auth-resolver.ts +58 -0
  30. package/src/lib/auth.ts +56 -12
  31. package/src/lib/boxel-cli-client.ts +135 -279
  32. package/src/lib/cli-log.ts +132 -0
  33. package/src/lib/colors.ts +14 -9
  34. package/src/lib/find-checkpoint.ts +65 -0
  35. package/src/lib/profile-manager.ts +49 -4
  36. package/src/lib/prompt.ts +133 -0
  37. package/src/lib/realm-authenticator.ts +12 -0
  38. package/src/lib/realm-sync-base.ts +47 -10
  39. package/src/lib/seed-auth.ts +214 -0
  40. package/src/lib/watch-lock.ts +81 -0
  41. package/LICENSE +0 -21
@@ -0,0 +1,65 @@
1
+ import type { Checkpoint } from './checkpoint-manager';
2
+
3
+ /**
4
+ * Length of a checkpoint's `shortHash`. SHA-1's first 7 hex chars, set in
5
+ * `CheckpointManager.createCheckpoint`. Used to short-circuit the exact
6
+ * short-hash scan for refs that can't possibly be one.
7
+ */
8
+ const SHORT_HASH_LENGTH = 7;
9
+
10
+ export type FindResult =
11
+ | { kind: 'found'; target: Checkpoint }
12
+ | { kind: 'none' }
13
+ | { kind: 'ambiguous'; matches: Checkpoint[] };
14
+
15
+ /**
16
+ * Resolve a `--restore` ref against a list of checkpoints. The ref may be a
17
+ * 1-based index (`'2'`), an exact short hash (`'6701186'`), or a hex prefix
18
+ * of a full hash (`'abc'`).
19
+ *
20
+ * Resolution order:
21
+ * 1. Exact short-hash match. SHA-1 short hashes are all-digits ~5.9% of
22
+ * the time, so digit-only refs that match a short hash exactly must
23
+ * win before the index branch.
24
+ * 2. Digit-only refs → 1-based index. Out-of-range returns `none` rather
25
+ * than falling through to hash-prefix matching, since silently matching
26
+ * a hash whose prefix happens to be digits would surprise users typing
27
+ * what they think is an index.
28
+ * 3. Hex-prefix match against the full hash.
29
+ */
30
+ export function findCheckpoint(
31
+ ref: string,
32
+ checkpoints: Checkpoint[],
33
+ ): FindResult {
34
+ const trimmed = ref.trim();
35
+ // Empty refs would `startsWith('')`-match every hash and silently restore
36
+ // the newest checkpoint — guard explicitly.
37
+ if (trimmed === '') return { kind: 'none' };
38
+
39
+ // Exact short-hash match wins before the digit-only branch.
40
+ if (trimmed.length === SHORT_HASH_LENGTH) {
41
+ const exactShort = checkpoints.filter((cp) => cp.shortHash === trimmed);
42
+ if (exactShort.length === 1) {
43
+ return { kind: 'found', target: exactShort[0] };
44
+ }
45
+ if (exactShort.length > 1) {
46
+ return { kind: 'ambiguous', matches: exactShort };
47
+ }
48
+ }
49
+
50
+ // Digit-only input is an index lookup. Falling through to hash-prefix
51
+ // matching when out of range would silently match short hashes whose prefix
52
+ // happens to be digits.
53
+ if (/^\d+$/.test(trimmed)) {
54
+ const num = parseInt(trimmed, 10);
55
+ if (num >= 1 && num <= checkpoints.length) {
56
+ return { kind: 'found', target: checkpoints[num - 1] };
57
+ }
58
+ return { kind: 'none' };
59
+ }
60
+
61
+ const matches = checkpoints.filter((cp) => cp.hash.startsWith(trimmed));
62
+ if (matches.length === 0) return { kind: 'none' };
63
+ if (matches.length === 1) return { kind: 'found', target: matches[0] };
64
+ return { kind: 'ambiguous', matches };
65
+ }
@@ -7,12 +7,18 @@ import {
7
7
  getRealmServerToken as fetchRealmServerToken,
8
8
  getRealmTokens,
9
9
  addRealmToMatrixAccountData,
10
+ removeRealmFromMatrixAccountData,
11
+ getUserRealmsFromMatrixAccountData,
10
12
  type MatrixAuth,
11
13
  } from './auth';
14
+ import type { RealmAuthenticator } from './realm-authenticator';
12
15
 
13
16
  const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.boxel-cli');
14
17
  const PROFILES_FILENAME = 'profiles.json';
15
18
 
19
+ export const NO_ACTIVE_PROFILE_ERROR =
20
+ 'No active profile. Run `boxel profile add` to create one.';
21
+
16
22
  export interface Profile {
17
23
  displayName: string;
18
24
  matrixUrl: string;
@@ -77,15 +83,15 @@ export function getEnvironmentLabel(env: Environment): string {
77
83
 
78
84
  /**
79
85
  * Format profile for display in command output
80
- * @example [ctse · staging]
86
+ * @example [ctse · stack.cards]
81
87
  */
82
88
  export function formatProfileBadge(matrixId: string): string {
83
89
  const username = getUsernameFromMatrixId(matrixId);
84
- const env = getEnvironmentLabel(getEnvironmentFromMatrixId(matrixId));
85
- return `${DIM}[${RESET}${FG_CYAN}${username}${RESET} ${DIM}\u00b7${RESET} ${FG_MAGENTA}${env}${RESET}${DIM}]${RESET}`;
90
+ const domain = getDomainFromMatrixId(matrixId);
91
+ return `${DIM}[${RESET}${FG_CYAN}${username}${RESET} ${DIM}\u00b7${RESET} ${FG_MAGENTA}${domain}${RESET}${DIM}]${RESET}`;
86
92
  }
87
93
 
88
- export class ProfileManager {
94
+ export class ProfileManager implements RealmAuthenticator {
89
95
  private config: ProfilesConfig;
90
96
  private configDir: string;
91
97
  private profilesFile: string;
@@ -293,6 +299,35 @@ export class ProfileManager {
293
299
  return true;
294
300
  }
295
301
 
302
+ // Update one or both server URLs for an existing profile. Cached realm
303
+ // tokens (and the realm-server token) are tied to the previous servers,
304
+ // so they're cleared whenever URLs actually change.
305
+ // Returns true iff at least one URL changed.
306
+ updateUrls(
307
+ profileId: string,
308
+ urls: { matrixUrl?: string; realmServerUrl?: string },
309
+ ): boolean {
310
+ const profile = this.config.profiles[profileId];
311
+ if (!profile) {
312
+ return false;
313
+ }
314
+ let changed = false;
315
+ if (urls.matrixUrl && urls.matrixUrl !== profile.matrixUrl) {
316
+ profile.matrixUrl = urls.matrixUrl;
317
+ changed = true;
318
+ }
319
+ if (urls.realmServerUrl && urls.realmServerUrl !== profile.realmServerUrl) {
320
+ profile.realmServerUrl = urls.realmServerUrl;
321
+ changed = true;
322
+ }
323
+ if (changed) {
324
+ profile.realmTokens = undefined;
325
+ profile.realmServerToken = undefined;
326
+ this.saveConfig();
327
+ }
328
+ return changed;
329
+ }
330
+
296
331
  setRealmToken(realmUrl: string, token: string): void {
297
332
  let active = this.getActiveProfile();
298
333
  if (!active) {
@@ -490,6 +525,16 @@ export class ProfileManager {
490
525
  await addRealmToMatrixAccountData(matrixAuth, realmUrl);
491
526
  }
492
527
 
528
+ async removeFromUserRealms(realmUrl: string): Promise<boolean> {
529
+ let matrixAuth = await this.loginToMatrix();
530
+ return removeRealmFromMatrixAccountData(matrixAuth, realmUrl);
531
+ }
532
+
533
+ async getUserRealms(): Promise<string[]> {
534
+ let matrixAuth = await this.loginToMatrix();
535
+ return getUserRealmsFromMatrixAccountData(matrixAuth);
536
+ }
537
+
493
538
  async migrateFromEnv(): Promise<{
494
539
  profileId: string;
495
540
  created: boolean;
@@ -0,0 +1,133 @@
1
+ import * as readline from 'readline';
2
+ import { Writable } from 'stream';
3
+
4
+ export function prompt(question: string): Promise<string> {
5
+ const rl = readline.createInterface({
6
+ input: process.stdin,
7
+ output: process.stdout,
8
+ });
9
+
10
+ return new Promise((resolve) => {
11
+ rl.question(question, (answer) => {
12
+ rl.close();
13
+ resolve(answer.trim());
14
+ });
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Read a secret from stdin without echoing it to the TTY. Keystrokes are
20
+ * masked with `*` and Ctrl+C exits. Intended for seeds, passwords, and other
21
+ * sensitive CLI input that must not appear in shell history or `ps aux`.
22
+ */
23
+ export function promptPassword(question: string): Promise<string> {
24
+ const mutableOutput = new Writable({
25
+ write: (_chunk, _encoding, callback) => callback(),
26
+ });
27
+ const rl = readline.createInterface({
28
+ input: process.stdin,
29
+ output: mutableOutput,
30
+ terminal: true,
31
+ });
32
+
33
+ return new Promise((resolve, reject) => {
34
+ const stdin = process.stdin;
35
+ const wasFlowing = stdin.readableFlowing;
36
+
37
+ if (stdin.isTTY) {
38
+ stdin.setRawMode(true);
39
+ }
40
+
41
+ const cleanup = () => {
42
+ stdin.removeListener('data', onData);
43
+ if (stdin.isTTY) {
44
+ stdin.setRawMode(false);
45
+ }
46
+ rl.close();
47
+ if (!wasFlowing) {
48
+ stdin.pause();
49
+ }
50
+ };
51
+
52
+ const onData = (chunk: Buffer) => {
53
+ try {
54
+ // Pastes arrive as a single data event containing many characters.
55
+ // Strip bracketed-paste markers if the terminal sent them, then walk
56
+ // the chunk one code point at a time so newlines, backspace, and
57
+ // Ctrl+C inside a paste still work.
58
+ const raw = chunk
59
+ .toString()
60
+ .split('[200~')
61
+ .join('')
62
+ .split('[201~')
63
+ .join('');
64
+ for (const c of raw) {
65
+ if (c === '\n' || c === '\r') {
66
+ cleanup();
67
+ process.stdout.write('\n');
68
+ resolve(password);
69
+ return;
70
+ } else if (c === '\u0003') {
71
+ // Ctrl+C
72
+ cleanup();
73
+ process.exit();
74
+ } else if (c === '\u007F' || c === '\b') {
75
+ // Backspace
76
+ if (password.length > 0) {
77
+ password = password.slice(0, -1);
78
+ process.stdout.write('\b \b');
79
+ }
80
+ } else if (c >= ' ') {
81
+ // Printable character; suppress other control bytes entirely.
82
+ password += c;
83
+ process.stdout.write('*');
84
+ }
85
+ }
86
+ } catch (e) {
87
+ cleanup();
88
+ reject(e);
89
+ }
90
+ };
91
+
92
+ let password = '';
93
+ try {
94
+ process.stdout.write(question);
95
+ stdin.on('data', onData);
96
+ stdin.resume();
97
+ } catch (e) {
98
+ cleanup();
99
+ reject(e);
100
+ }
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Resolve a realm secret seed for administrative CLI operations.
106
+ *
107
+ * Precedence:
108
+ * 1. `BOXEL_REALM_SECRET_SEED` env var — used silently if set.
109
+ * 2. If `flagPresent` is true, prompt the user (no echo).
110
+ * 3. Otherwise return undefined — caller falls back to profile auth.
111
+ *
112
+ * Throws when `--realm-secret-seed` is requested but stdin is not a TTY
113
+ * (e.g. CI, piped shells) — otherwise `promptPassword` would hang
114
+ * indefinitely waiting for keypress input it can never receive.
115
+ */
116
+ export async function resolveRealmSecretSeed(
117
+ flagPresent: boolean,
118
+ ): Promise<string | undefined> {
119
+ const fromEnv = process.env.BOXEL_REALM_SECRET_SEED;
120
+ if (fromEnv) {
121
+ return fromEnv;
122
+ }
123
+ if (!flagPresent) {
124
+ return undefined;
125
+ }
126
+ if (!process.stdin.isTTY) {
127
+ throw new Error(
128
+ 'Cannot prompt for realm secret seed: stdin is not a TTY. ' +
129
+ 'Set BOXEL_REALM_SECRET_SEED in the environment instead.',
130
+ );
131
+ }
132
+ return promptPassword('Realm secret seed: ');
133
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Narrow interface over whatever strategy provides authenticated fetch to a
3
+ * realm. Both `ProfileManager` (Matrix login + per-realm JWT) and
4
+ * `SeedAuthenticator` (mint a JWT directly from a shared secret seed) satisfy
5
+ * this interface, so `RealmSyncBase` can accept either.
6
+ */
7
+ export interface RealmAuthenticator {
8
+ authedRealmFetch(
9
+ input: string | URL | Request,
10
+ init?: RequestInit,
11
+ ): Promise<Response>;
12
+ }
@@ -1,4 +1,4 @@
1
- import type { ProfileManager } from './profile-manager';
1
+ import type { RealmAuthenticator } from './realm-authenticator';
2
2
  import * as fs from 'fs/promises';
3
3
  import * as path from 'path';
4
4
  import ignoreModule from 'ignore';
@@ -24,6 +24,22 @@ async function pathExists(p: string): Promise<boolean> {
24
24
  }
25
25
  }
26
26
 
27
+ /**
28
+ * Decode an `atomic:results` `data.id` (or any href the realm echoes
29
+ * back with URL-encoded path segments). Used so paths that contain
30
+ * spaces or other characters that get percent-encoded on the wire
31
+ * round-trip to the same relative path the local listing uses.
32
+ * Falls back to the raw value on a malformed escape so a single bad
33
+ * entry can't kill the whole sync.
34
+ */
35
+ function decodeAtomicResultId(id: string): string {
36
+ try {
37
+ return decodeURIComponent(id);
38
+ } catch {
39
+ return id;
40
+ }
41
+ }
42
+
27
43
  export const SupportedMimeType = {
28
44
  CardSource: 'application/vnd.card+source',
29
45
  DirectoryListing: 'application/vnd.api+json',
@@ -49,7 +65,7 @@ export abstract class RealmSyncBase {
49
65
 
50
66
  constructor(
51
67
  protected options: SyncOptions,
52
- protected profileManager: ProfileManager,
68
+ protected authenticator: RealmAuthenticator,
53
69
  ) {
54
70
  this.normalizedRealmUrl = this.normalizeRealmUrl(options.realmUrl);
55
71
  }
@@ -98,7 +114,7 @@ export abstract class RealmSyncBase {
98
114
  try {
99
115
  const url = this.buildDirectoryUrl(dir);
100
116
 
101
- const response = await this.profileManager.authedRealmFetch(url, {
117
+ const response = await this.authenticator.authedRealmFetch(url, {
102
118
  headers: {
103
119
  Accept: 'application/vnd.api+json',
104
120
  },
@@ -178,7 +194,7 @@ export abstract class RealmSyncBase {
178
194
  try {
179
195
  const url = `${this.normalizedRealmUrl}_mtimes`;
180
196
 
181
- const response = await this.profileManager.authedRealmFetch(url, {
197
+ const response = await this.authenticator.authedRealmFetch(url, {
182
198
  headers: {
183
199
  Accept: SupportedMimeType.Mtimes,
184
200
  },
@@ -217,7 +233,22 @@ export abstract class RealmSyncBase {
217
233
  }
218
234
  }
219
235
  for (const [fileUrl, mtime] of remoteMtimeEntries) {
220
- const relativePath = fileUrl.replace(this.normalizedRealmUrl, '');
236
+ const rawRelativePath = fileUrl.replace(this.normalizedRealmUrl, '');
237
+ // Realm `_mtimes` keys are URL-encoded (e.g. spaces → %20).
238
+ // The local file listing uses decoded paths
239
+ // (`Knowledge Articles/foo.json`), so leaving the remote
240
+ // form encoded makes the diff treat the encoded and decoded
241
+ // variants as two separate files — sync then "downloads"
242
+ // the remote copy alongside the existing local one and
243
+ // duplicates the workspace.
244
+ let relativePath: string;
245
+ try {
246
+ relativePath = decodeURIComponent(rawRelativePath);
247
+ } catch {
248
+ // Malformed percent escape — fall back to the raw value
249
+ // so a single bad entry doesn't kill the whole sync.
250
+ relativePath = rawRelativePath;
251
+ }
221
252
  if (!this.shouldIgnoreRemoteFile(relativePath)) {
222
253
  mtimes.set(relativePath, mtime);
223
254
  }
@@ -355,7 +386,7 @@ export abstract class RealmSyncBase {
355
386
  const content = await fs.readFile(localPath, 'utf8');
356
387
  const url = this.buildFileUrl(relativePath);
357
388
 
358
- const response = await this.profileManager.authedRealmFetch(url, {
389
+ const response = await this.authenticator.authedRealmFetch(url, {
359
390
  method: 'POST',
360
391
  headers: {
361
392
  'Content-Type': 'text/plain;charset=UTF-8',
@@ -423,7 +454,7 @@ export abstract class RealmSyncBase {
423
454
  );
424
455
 
425
456
  const url = `${this.normalizedRealmUrl}_atomic`;
426
- const response = await this.profileManager.authedRealmFetch(url, {
457
+ const response = await this.authenticator.authedRealmFetch(url, {
427
458
  method: 'POST',
428
459
  headers: {
429
460
  'Content-Type': 'application/vnd.api+json',
@@ -439,9 +470,15 @@ export abstract class RealmSyncBase {
439
470
  const hrefToRelative = new Map(
440
471
  entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
441
472
  );
473
+ // The realm normalizes hrefs: a path with a space goes out as
474
+ // `Knowledge Articles/...` but comes back URL-encoded as
475
+ // `Knowledge%20Articles/...`. Decode the response id before the
476
+ // map lookup so we resolve back to the original relative path
477
+ // instead of falling through to the raw encoded URL.
442
478
  const succeeded = (body['atomic:results'] ?? [])
443
479
  .map((r) => r.data?.id)
444
480
  .filter((id): id is string => typeof id === 'string')
481
+ .map((id) => decodeAtomicResultId(id))
445
482
  .map((id) => hrefToRelative.get(id) ?? id);
446
483
  for (const rel of succeeded) {
447
484
  console.log(` Uploaded: ${rel}`);
@@ -461,7 +498,7 @@ export abstract class RealmSyncBase {
461
498
  const perFile = (errorBody.errors ?? []).map((e) => {
462
499
  const detail = e.detail ?? '';
463
500
  const match = detail.match(/Resource (\S+) /);
464
- const href = match ? match[1] : '';
501
+ const href = match ? decodeAtomicResultId(match[1]) : '';
465
502
  const relMap = new Map(
466
503
  entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
467
504
  );
@@ -495,7 +532,7 @@ export abstract class RealmSyncBase {
495
532
 
496
533
  const url = this.buildFileUrl(relativePath);
497
534
 
498
- const response = await this.profileManager.authedRealmFetch(url, {
535
+ const response = await this.authenticator.authedRealmFetch(url, {
499
536
  headers: {
500
537
  Accept: SupportedMimeType.CardSource,
501
538
  },
@@ -531,7 +568,7 @@ export abstract class RealmSyncBase {
531
568
 
532
569
  const url = this.buildFileUrl(relativePath);
533
570
 
534
- const response = await this.profileManager.authedRealmFetch(url, {
571
+ const response = await this.authenticator.authedRealmFetch(url, {
535
572
  method: 'DELETE',
536
573
  headers: {
537
574
  Accept: SupportedMimeType.CardSource,
@@ -0,0 +1,214 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import type { RealmAuthenticator } from './realm-authenticator';
3
+
4
+ /**
5
+ * The realm server's shared matrix-client username in every deployed
6
+ * environment (local, staging, production). Bot user ids are formed as
7
+ * `@realm_server:<host>` and the realm short-circuits authorization for that
8
+ * id — see packages/runtime-common/realm.ts:2221.
9
+ */
10
+ export const DEFAULT_REALM_BOT_USERNAME = 'realm_server';
11
+
12
+ /**
13
+ * Derive the Matrix host portion (`:<host>`) for a bot user id from a realm
14
+ * URL, mirroring `userIdFromUsername` in
15
+ * `packages/runtime-common/matrix-client.ts`:
16
+ * - hostname ending in `.localhost` (and bare `localhost`) collapses to `localhost`
17
+ * - otherwise the last two labels of the hostname are used
18
+ * So:
19
+ * - http://localhost:4201/… → localhost
20
+ * - https://realms-staging.stack.cards/… → stack.cards
21
+ * - https://app.boxel.ai/… → boxel.ai
22
+ */
23
+ export function deriveHostFromRealmUrl(realmUrl: string): string {
24
+ const { hostname } = new URL(realmUrl);
25
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
26
+ return 'localhost';
27
+ }
28
+ const labels = hostname.split('.');
29
+ if (labels.length <= 2) {
30
+ return hostname;
31
+ }
32
+ return labels.slice(-2).join('.');
33
+ }
34
+
35
+ export function deriveBotUserId(
36
+ realmUrl: string,
37
+ username: string = DEFAULT_REALM_BOT_USERNAME,
38
+ ): string {
39
+ return `@${username}:${deriveHostFromRealmUrl(realmUrl)}`;
40
+ }
41
+
42
+ /**
43
+ * Origin (with trailing slash) for the realm server hosting a given realm URL.
44
+ * This is what the realm embeds in JWT claims as `realmServerURL`.
45
+ */
46
+ export function deriveRealmServerUrl(realmUrl: string): string {
47
+ return new URL(realmUrl).origin + '/';
48
+ }
49
+
50
+ function normalizeRealmUrl(realmUrl: string): string {
51
+ try {
52
+ const u = new URL(realmUrl);
53
+ return u.href.replace(/\/+$/, '') + '/';
54
+ } catch {
55
+ throw new Error(`Invalid realm URL: ${realmUrl}`);
56
+ }
57
+ }
58
+
59
+ export interface SeedAuthenticatorOptions {
60
+ /** Raw realm secret seed used to sign JWTs (HS256). */
61
+ seed: string;
62
+ /**
63
+ * @internal Override the realm-server's matrix-client username. Real
64
+ * deployments all use `realm_server`; tests against a server with a
65
+ * different username inject their own.
66
+ */
67
+ botUsername?: string;
68
+ /**
69
+ * @internal Full override for the bot matrix user id (e.g.
70
+ * `@node-test_realm-server:localhost`). Used by integration tests that run
71
+ * against a realm on `127.0.0.1`, where the two-label host-derivation
72
+ * formula is nonsensical.
73
+ */
74
+ botUserId?: string;
75
+ /** @internal Override the 7-day JWT expiry used by real deployments. */
76
+ expiresIn?: jwt.SignOptions['expiresIn'];
77
+ }
78
+
79
+ export interface RealmJwtClaims {
80
+ user: string;
81
+ realm: string;
82
+ sessionRoom: undefined;
83
+ permissions: [];
84
+ realmServerURL: string;
85
+ }
86
+
87
+ /**
88
+ * `RealmAuthenticator` implementation that authenticates via a locally-minted
89
+ * JWT signed with the realm secret seed, bypassing Matrix login and the
90
+ * `/_server-session` + `/_realm-auth` handshake.
91
+ *
92
+ * How it works: the realm short-circuits authorization when the JWT's `user`
93
+ * claim equals the realm's own matrix-client user id
94
+ * (packages/runtime-common/realm.ts:2221). That id is stable per deployment —
95
+ * `@realm_server:<host>` in every real environment. So given the seed, we mint
96
+ * a token with `user = @realm_server:<derived-host>`, `realm = <normalized
97
+ * realm url>`, `realmServerURL = <origin>/`, `permissions = []`, and
98
+ * everything else is ignored by the short-circuit.
99
+ */
100
+ export class SeedAuthenticator implements RealmAuthenticator {
101
+ readonly #seed: string;
102
+ readonly #botUsername: string;
103
+ readonly #botUserIdOverride: string | undefined;
104
+ readonly #expiresIn: jwt.SignOptions['expiresIn'];
105
+ readonly #tokenCache = new Map<string, string>();
106
+
107
+ constructor(options: SeedAuthenticatorOptions) {
108
+ if (!options.seed) {
109
+ throw new Error('SeedAuthenticator requires a non-empty seed');
110
+ }
111
+ this.#seed = options.seed;
112
+ this.#botUsername = options.botUsername ?? DEFAULT_REALM_BOT_USERNAME;
113
+ this.#botUserIdOverride = options.botUserId;
114
+ this.#expiresIn = options.expiresIn ?? '7d';
115
+ }
116
+
117
+ /**
118
+ * Build the JWT claims for a given realm URL. Exposed for tests that need
119
+ * to inspect payload shape without decoding the signed token.
120
+ */
121
+ buildClaims(realmUrl: string): RealmJwtClaims {
122
+ const normalizedRealm = normalizeRealmUrl(realmUrl);
123
+ const user =
124
+ this.#botUserIdOverride ??
125
+ deriveBotUserId(normalizedRealm, this.#botUsername);
126
+ return {
127
+ user,
128
+ realm: normalizedRealm,
129
+ sessionRoom: undefined,
130
+ permissions: [],
131
+ realmServerURL: deriveRealmServerUrl(normalizedRealm),
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Mint (or return a cached) JWT for the given realm URL.
137
+ */
138
+ mintTokenForRealm(realmUrl: string): string {
139
+ const claims = this.buildClaims(realmUrl);
140
+ const cached = this.#tokenCache.get(claims.realm);
141
+ if (cached) {
142
+ return cached;
143
+ }
144
+ const token = jwt.sign(claims, this.#seed, {
145
+ expiresIn: this.#expiresIn,
146
+ });
147
+ this.#tokenCache.set(claims.realm, token);
148
+ return token;
149
+ }
150
+
151
+ /**
152
+ * Given any URL inside a realm (or the realm root itself), return the realm
153
+ * root URL we'll use to mint the token. We match against the set of realm
154
+ * URLs we've already minted tokens for; the fallback (when nothing is
155
+ * pre-registered) takes the request's origin + first two path segments
156
+ * with a trailing slash, which matches the CLI-visible realm URL
157
+ * convention `https://<host>/<owner>/<realm>/`.
158
+ */
159
+ #resolveRealmUrl(requestUrl: string): string {
160
+ for (const realmUrl of this.#tokenCache.keys()) {
161
+ if (requestUrl.startsWith(realmUrl)) {
162
+ return realmUrl;
163
+ }
164
+ }
165
+ const u = new URL(requestUrl);
166
+ const segments = u.pathname.split('/').filter(Boolean);
167
+ const realmRootPath =
168
+ segments.length > 0 ? `/${segments.slice(0, 2).join('/')}/` : '/';
169
+ return `${u.origin}${realmRootPath}`;
170
+ }
171
+
172
+ async authedRealmFetch(
173
+ input: string | URL | Request,
174
+ init?: RequestInit,
175
+ ): Promise<Response> {
176
+ const url =
177
+ input instanceof Request
178
+ ? input.url
179
+ : input instanceof URL
180
+ ? input.href
181
+ : input;
182
+
183
+ const realmUrl = this.#resolveRealmUrl(url);
184
+ const token = this.mintTokenForRealm(realmUrl);
185
+ const headers = this.#buildHeaders(input, init, token);
186
+ return fetch(input, { ...init, headers });
187
+ }
188
+
189
+ #buildHeaders(
190
+ input: string | URL | Request,
191
+ init: RequestInit | undefined,
192
+ token: string,
193
+ ): Headers {
194
+ const baseHeaders =
195
+ input instanceof Request ? new Headers(input.headers) : new Headers();
196
+ const initHeaders = new Headers(init?.headers);
197
+ for (const [key, value] of initHeaders) {
198
+ baseHeaders.set(key, value);
199
+ }
200
+ if (!baseHeaders.has('Authorization')) {
201
+ baseHeaders.set('Authorization', token);
202
+ }
203
+ return baseHeaders;
204
+ }
205
+
206
+ /**
207
+ * Pre-register a realm URL so that requests to sub-paths of it always use
208
+ * the exact realm URL for token minting. The CLI commands call this with
209
+ * the user-supplied realm URL before doing any fetches.
210
+ */
211
+ registerRealmUrl(realmUrl: string): void {
212
+ this.mintTokenForRealm(realmUrl);
213
+ }
214
+ }