diff --git a/packages/boxel-cli/src/commands/file/read.ts b/packages/boxel-cli/src/commands/file/read.ts index 96fe5ed15f5..ad811e92258 100644 --- a/packages/boxel-cli/src/commands/file/read.ts +++ b/packages/boxel-cli/src/commands/file/read.ts @@ -1,9 +1,8 @@ import type { Command } from 'commander'; -import { - getProfileManager, - NO_ACTIVE_PROFILE_ERROR, - type ProfileManager, -} from '../../lib/profile-manager.ts'; +import type { ProfileManager } from '../../lib/profile-manager.ts'; +import { resolveRealmAuthenticator } from '../../lib/auth-resolver.ts'; +import { resolveRealmSecretSeed } from '../../lib/prompt.ts'; +import type { RealmAuthenticator } from '../../lib/realm-authenticator.ts'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type'; @@ -26,11 +25,16 @@ export interface ReadResult { export interface ReadCommandOptions { profileManager?: ProfileManager; + /** Pre-resolved realm secret seed for administrative (seed) auth. */ + realmSecretSeed?: string; + /** @internal Test hook: supply an already-constructed authenticator. */ + authenticator?: RealmAuthenticator; } interface ReadCliOptions { realm: string; json?: boolean; + realmSecretSeed?: boolean; } /** @@ -39,27 +43,31 @@ interface ReadCliOptions { * per `isBinaryFilename`). Callers should parse the content themselves * if needed (e.g. JSON). * - * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`. + * Auth is resolved via `resolveRealmAuthenticator`: a realm secret seed (when + * supplied) mints a JWT locally as the realm-server bot; otherwise the active + * Matrix profile's per-realm JWT is used. */ export async function read( realmUrl: string, path: string, options?: ReadCommandOptions, ): Promise { - let pm = options?.profileManager ?? getProfileManager(); - let active = pm.getActiveProfile(); - if (!active) { - return { - ok: false, - error: NO_ACTIVE_PROFILE_ERROR, - }; + let resolution = resolveRealmAuthenticator({ + realmUrl, + realmSecretSeed: options?.realmSecretSeed, + profileManager: options?.profileManager, + authenticator: options?.authenticator, + }); + if (!resolution.ok) { + return { ok: false, error: resolution.error }; } + let authenticator = resolution.authenticator; let url = new URL(path, ensureTrailingSlash(realmUrl)).href; let response: Response; try { - response = await pm.authedRealmFetch(url, { + response = await authenticator.authedRealmFetch(url, { method: 'GET', headers: { Accept: SupportedMimeType.CardSource }, }); @@ -97,11 +105,20 @@ export function registerReadCommand(parent: Command): void { 'Realm-relative file path (e.g., hello-world.json, Cards/my-card.gts)', ) .requiredOption('--realm ', 'The realm URL to read from') + .option( + '--realm-secret-seed', + 'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)', + ) .option('--json', 'Output raw JSON response') .action(async (filePath: string, opts: ReadCliOptions) => { let result: ReadResult; try { - result = await read(opts.realm, filePath); + // Inside the try so a seed-resolution throw (e.g. --realm-secret-seed + // with non-TTY stdin) surfaces as a clean error, not an unhandled one. + let realmSecretSeed = await resolveRealmSecretSeed( + opts.realmSecretSeed === true, + ); + result = await read(opts.realm, filePath, { realmSecretSeed }); } catch (err) { console.error( `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/boxel-cli/src/commands/file/write.ts b/packages/boxel-cli/src/commands/file/write.ts index 66fbd2f82f5..af585cb3453 100644 --- a/packages/boxel-cli/src/commands/file/write.ts +++ b/packages/boxel-cli/src/commands/file/write.ts @@ -1,10 +1,9 @@ import type { Command } from 'commander'; import { readFileSync } from 'fs'; -import { - getProfileManager, - NO_ACTIVE_PROFILE_ERROR, - type ProfileManager, -} from '../../lib/profile-manager.ts'; +import type { ProfileManager } from '../../lib/profile-manager.ts'; +import { resolveRealmAuthenticator } from '../../lib/auth-resolver.ts'; +import { resolveRealmSecretSeed } from '../../lib/prompt.ts'; +import type { RealmAuthenticator } from '../../lib/realm-authenticator.ts'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type'; @@ -18,12 +17,17 @@ export interface WriteResult { export interface WriteCommandOptions { profileManager?: ProfileManager; + /** Pre-resolved realm secret seed for administrative (seed) auth. */ + realmSecretSeed?: string; + /** @internal Test hook: supply an already-constructed authenticator. */ + authenticator?: RealmAuthenticator; } interface WriteCliOptions { realm: string; file?: string; json?: boolean; + realmSecretSeed?: boolean; } /** @@ -34,7 +38,9 @@ interface WriteCliOptions { * including the `Buffer` subclass) is sent with `application/octet-stream`, * which the realm-server routes to `upsertBinaryFile` and writes verbatim. * - * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`. + * Auth is resolved via `resolveRealmAuthenticator`: a realm secret seed (when + * supplied) mints a JWT locally as the realm-server bot; otherwise the active + * Matrix profile's per-realm JWT is used. */ export async function write( realmUrl: string, @@ -42,14 +48,16 @@ export async function write( content: string | Uint8Array, options?: WriteCommandOptions, ): Promise { - let pm = options?.profileManager ?? getProfileManager(); - let active = pm.getActiveProfile(); - if (!active) { - return { - ok: false, - error: NO_ACTIVE_PROFILE_ERROR, - }; + let resolution = resolveRealmAuthenticator({ + realmUrl, + realmSecretSeed: options?.realmSecretSeed, + profileManager: options?.profileManager, + authenticator: options?.authenticator, + }); + if (!resolution.ok) { + return { ok: false, error: resolution.error }; } + let authenticator = resolution.authenticator; let url = new URL(path, ensureTrailingSlash(realmUrl)).href; let isBinary = typeof content !== 'string'; @@ -72,7 +80,7 @@ export async function write( } try { - let response = await pm.authedRealmFetch(url, { + let response = await authenticator.authedRealmFetch(url, { method: 'POST', headers: isBinary ? { 'Content-Type': SupportedMimeType.OctetStream } @@ -129,8 +137,27 @@ export function registerWriteCommand(parent: Command): void { '--file ', 'Read content from a local file instead of STDIN', ) + .option( + '--realm-secret-seed', + 'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)', + ) .option('--json', 'Output raw JSON response') .action(async (filePath: string, opts: WriteCliOptions) => { + // Resolve the seed before consuming stdin: when content arrives on stdin + // and --realm-secret-seed prompts, both would contend for stdin. Wrapped + // so a seed-resolution throw (e.g. non-TTY stdin) is a clean error. + let realmSecretSeed: string | undefined; + try { + realmSecretSeed = await resolveRealmSecretSeed( + opts.realmSecretSeed === true, + ); + } catch (err) { + stderr( + `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + let content: string | Uint8Array; if (opts.file) { // Refuse a source/destination binary-classification mismatch @@ -175,7 +202,9 @@ export function registerWriteCommand(parent: Command): void { let result: WriteResult; try { - result = await write(opts.realm, filePath, content); + result = await write(opts.realm, filePath, content, { + realmSecretSeed, + }); } catch (err) { stderr( `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/boxel-cli/src/commands/realm/publish.ts b/packages/boxel-cli/src/commands/realm/publish.ts index f299b7a980f..00ce530de33 100644 --- a/packages/boxel-cli/src/commands/realm/publish.ts +++ b/packages/boxel-cli/src/commands/realm/publish.ts @@ -13,6 +13,11 @@ import { getProfileManager, type ProfileManager, } from '../../lib/profile-manager.ts'; +import { + deriveOwnerUserId, + deriveRealmServerUrl, +} from '../../lib/seed-auth.ts'; +import { resolveRealmSecretSeed } from '../../lib/prompt.ts'; import { unpublishRealm } from './unpublish.ts'; import { cliLog } from '../../lib/cli-log.ts'; import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors.ts'; @@ -35,6 +40,13 @@ export interface PublishOptions { */ force?: boolean; profileManager?: ProfileManager; + /** Seed-mode admin auth — mints an owner-scoped realm-server token. */ + realmSecretSeed?: string; + /** + * Owner Matrix id for the seed-minted server token. Defaults to the owner + * derived from the source realm URL (`@:`). + */ + asUser?: string; } export interface PublishRealmResult { @@ -61,12 +73,27 @@ export async function publishRealm( publishedRealmURL: string, options: PublishOptions = {}, ): Promise { - let pm = options.profileManager ?? getProfileManager(); - let client = buildCliRealmClient(pm); - let normalizedSource = ensureTrailingSlash(sourceRealmURL); let normalizedPublished = ensureTrailingSlash(publishedRealmURL); + // Seed mode mints an owner-scoped realm-server token; the owner defaults to + // the one derived from the source realm URL. `pm` stays defined for the + // profile path (and threads into the conflict-retry unpublish below). + let realmServerURL = deriveRealmServerUrl(normalizedSource); + let asUser = + options.asUser ?? + (options.realmSecretSeed ? deriveOwnerUserId(normalizedSource) : undefined); + let pm = options.realmSecretSeed + ? undefined + : (options.profileManager ?? getProfileManager()); + let client = options.realmSecretSeed + ? buildCliRealmClient({ + realmSecretSeed: options.realmSecretSeed, + realmServerURL, + asUser: asUser!, + }) + : buildCliRealmClient(pm!); + // Pre-publish gate: refuse to publish a realm with private-dependency or // error-document violations (which would break the published site) unless // the caller forces it. @@ -101,6 +128,9 @@ export async function publishRealm( let unpublishResult = await unpublishRealm(normalizedPublished, { profileManager: pm, tolerateMissing: true, + realmSecretSeed: options.realmSecretSeed, + realmServerURL, + asUser, }); if (!unpublishResult.unpublished && !unpublishResult.notFound) { throw new Error( @@ -144,6 +174,8 @@ export interface PublishCliOptions { republish?: boolean; force?: boolean; json?: boolean; + realmSecretSeed?: boolean; + asUser?: string; } export function publishCliOptsToOptions( @@ -182,6 +214,14 @@ export function registerPublishCommand(realm: Command): void { '--force', 'Publish even if the realm has publishability violations (skips the gate)', ) + .option( + '--realm-secret-seed', + 'Administrative auth: prompt for a realm secret seed and mint an owner-scoped JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)', + ) + .option( + '--as-user ', + 'Owner Matrix id to authorize as in seed mode (defaults to the owner derived from the source realm URL)', + ) .option('--json', 'Output the result as JSON') .action( async ( @@ -190,11 +230,14 @@ export function registerPublishCommand(realm: Command): void { opts: PublishCliOptions, ) => { try { - let result = await publishRealm( - sourceRealmURL, - publishedRealmURL, - publishCliOptsToOptions(opts), + let realmSecretSeed = await resolveRealmSecretSeed( + opts.realmSecretSeed === true, ); + let result = await publishRealm(sourceRealmURL, publishedRealmURL, { + ...publishCliOptsToOptions(opts), + realmSecretSeed, + asUser: opts.asUser, + }); if (opts.json) { cliLog.output(JSON.stringify(result, null, 2)); } else { diff --git a/packages/boxel-cli/src/commands/realm/unpublish.ts b/packages/boxel-cli/src/commands/realm/unpublish.ts index d98225c0edf..2684ded3005 100644 --- a/packages/boxel-cli/src/commands/realm/unpublish.ts +++ b/packages/boxel-cli/src/commands/realm/unpublish.ts @@ -10,6 +10,8 @@ import { NO_ACTIVE_PROFILE_ERROR, type ProfileManager, } from '../../lib/profile-manager.ts'; +import { deriveRealmServerUrl } from '../../lib/seed-auth.ts'; +import { resolveRealmSecretSeed } from '../../lib/prompt.ts'; import { cliLog } from '../../lib/cli-log.ts'; import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors.ts'; import { describeFetchError } from '../../lib/describe-fetch-error.ts'; @@ -23,6 +25,12 @@ export interface UnpublishOptions { */ tolerateMissing?: boolean; profileManager?: ProfileManager; + /** Seed-mode admin auth — mints an owner-scoped realm-server token. */ + realmSecretSeed?: string; + /** Realm-server origin for seed mode; defaults to the published URL origin. */ + realmServerURL?: string; + /** Owner Matrix id for the seed-minted server token (required in seed mode). */ + asUser?: string; } export interface UnpublishRealmResult { @@ -45,18 +53,36 @@ export async function unpublishRealm( options: UnpublishOptions = {}, ): Promise { let normalized = ensureTrailingSlash(publishedRealmURL); - let pm = options.profileManager ?? getProfileManager(); - let active = pm.getActiveProfile(); - if (!active) { - return { - publishedRealmURL: normalized, - unpublished: false, - error: NO_ACTIVE_PROFILE_ERROR, - }; + let client: ReturnType; + if (options.realmSecretSeed) { + if (!options.asUser) { + return { + publishedRealmURL: normalized, + unpublished: false, + error: + 'Seed-mode unpublish requires asUser (the realm owner Matrix id).', + }; + } + client = buildCliRealmClient({ + realmSecretSeed: options.realmSecretSeed, + realmServerURL: + options.realmServerURL ?? deriveRealmServerUrl(normalized), + asUser: options.asUser, + }); + } else { + let pm = options.profileManager ?? getProfileManager(); + if (!pm.getActiveProfile()) { + return { + publishedRealmURL: normalized, + unpublished: false, + error: NO_ACTIVE_PROFILE_ERROR, + }; + } + client = buildCliRealmClient(pm); } try { - await unpublishRealmOperation(buildCliRealmClient(pm), { + await unpublishRealmOperation(client, { publishedRealmURL: normalized, }); return { publishedRealmURL: normalized, unpublished: true }; @@ -101,6 +127,8 @@ export async function unpublishRealm( interface UnpublishCliOptions { tolerateMissing?: boolean; json?: boolean; + realmSecretSeed?: boolean; + asUser?: string; } export function registerUnpublishCommand(realm: Command): void { @@ -112,10 +140,31 @@ export function registerUnpublishCommand(realm: Command): void { '--tolerate-missing', 'Exit successfully when the realm is already unpublished', ) + .option( + '--realm-secret-seed', + 'Administrative auth: prompt for a realm secret seed and mint an owner-scoped JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)', + ) + .option( + '--as-user ', + 'Owner Matrix id to authorize as (required with --realm-secret-seed)', + ) .option('--json', 'Output the result as JSON') .action(async (publishedRealmURL: string, opts: UnpublishCliOptions) => { + let realmSecretSeed: string | undefined; + try { + realmSecretSeed = await resolveRealmSecretSeed( + opts.realmSecretSeed === true, + ); + } catch (err) { + console.error( + `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } let result = await unpublishRealm(publishedRealmURL, { tolerateMissing: opts.tolerateMissing === true, + realmSecretSeed, + asUser: opts.asUser, }); if (opts.json) { diff --git a/packages/boxel-cli/src/lib/realm-client.ts b/packages/boxel-cli/src/lib/realm-client.ts index 4a4b5a946db..823fb931aa9 100644 --- a/packages/boxel-cli/src/lib/realm-client.ts +++ b/packages/boxel-cli/src/lib/realm-client.ts @@ -5,6 +5,7 @@ import { NO_ACTIVE_PROFILE_ERROR, type ProfileManager, } from './profile-manager.ts'; +import { SeedAuthenticator, mintRealmServerToken } from './seed-auth.ts'; // A realm-server endpoint is a single underscore-prefixed segment directly // under the server root (`_publish-realm`, `_unpublish-realm`, …). A per-realm @@ -36,9 +37,50 @@ function realmURLForEndpoint(url: string): string { // through `authedRealmServerFetch` (realm-server JWT, with its own 401-refresh), // while per-realm endpoints carry that realm's token — fetched lazily and // cached, and omitted when unavailable since published realms are public-read. +export interface SeedRealmClientConfig { + realmSecretSeed: string; + /** Realm-server origin (trailing slash), e.g. `https://host/`. */ + realmServerURL: string; + /** + * Matrix user id to put in the realm-server token. Owner-gated admin + * endpoints (realm publish) require the realm owner, so callers pass the + * source realm's owner. + */ + asUser: string; +} + +function isSeedConfig( + arg: ProfileManager | SeedRealmClientConfig, +): arg is SeedRealmClientConfig { + return (arg as SeedRealmClientConfig).realmSecretSeed !== undefined; +} + export function buildCliRealmClient( - profileManager: ProfileManager = getProfileManager(), + auth: ProfileManager | SeedRealmClientConfig = getProfileManager(), ): RealmClient { + // Seed mode: mint an owner-scoped realm-server token locally for realm-server + // endpoints, and use a seed-minted realm token for per-realm endpoints — no + // Matrix profile required. + if (isSeedConfig(auth)) { + let realmServerURL = ensureTrailingSlash(auth.realmServerURL); + let serverToken = mintRealmServerToken(auth.realmSecretSeed, auth.asUser); + let seedAuth = new SeedAuthenticator({ seed: auth.realmSecretSeed }); + return { + realmServerURL, + config: { spaceDomain: '', siteDomain: '' }, + authedFetch: async (url, init) => { + if (isRealmServerEndpoint(url, realmServerURL)) { + let headers = new Headers(init?.headers); + headers.set('Authorization', serverToken); + return fetch(url, { ...init, headers }); + } + // Per-realm endpoint (e.g. readiness on the published realm). + return seedAuth.authedRealmFetch(url, init); + }, + }; + } + + let profileManager = auth; let active = profileManager.getActiveProfile(); if (!active) { throw new Error(NO_ACTIVE_PROFILE_ERROR); diff --git a/packages/boxel-cli/src/lib/seed-auth.ts b/packages/boxel-cli/src/lib/seed-auth.ts index 88fc653ab3f..5aa0e469691 100644 --- a/packages/boxel-cli/src/lib/seed-auth.ts +++ b/packages/boxel-cli/src/lib/seed-auth.ts @@ -47,6 +47,42 @@ export function deriveRealmServerUrl(realmUrl: string): string { return new URL(realmUrl).origin + '/'; } +/** + * Derive the realm owner's Matrix user id from a realm URL. The CLI-visible + * realm URL convention is `https://///`, so the first path + * segment is the owner's username and the host maps to the Matrix domain (the + * same mapping `deriveHostFromRealmUrl` applies). Used to mint an owner-scoped + * realm-server token for owner-gated admin endpoints (e.g. realm publish). + */ +export function deriveOwnerUserId(realmUrl: string): string { + // A realm URL is `https://///` — two path segments, the + // first being the owner. Require both so a non-realm URL (a 1-segment path or + // the server root) doesn't silently yield a bogus owner. + const segments = new URL(realmUrl).pathname.split('/').filter(Boolean); + if (segments.length < 2) { + throw new Error( + `Cannot derive realm owner: ${realmUrl} is not a /// realm URL`, + ); + } + return `@${segments[0]}:${deriveHostFromRealmUrl(realmUrl)}`; +} + +/** + * Mint a realm-server (admin) token signed with the seed, for the given user. + * The realm server verifies it with the same seed and reads `{ user, + * sessionRoom }` (realm-server `utils/jwt.ts` `RealmServerTokenClaim`). This is + * the seed-mode counterpart to a Matrix-login `/_server-session` token. + */ +export function mintRealmServerToken( + seed: string, + user: string, + opts: { sessionRoom?: string; expiresIn?: jwt.SignOptions['expiresIn'] } = {}, +): string { + return jwt.sign({ user, sessionRoom: opts.sessionRoom ?? '' }, seed, { + expiresIn: opts.expiresIn ?? '7d', + }); +} + function normalizeRealmUrl(realmUrl: string): string { try { const u = new URL(realmUrl); diff --git a/packages/boxel-cli/tests/helpers/integration.ts b/packages/boxel-cli/tests/helpers/integration.ts index 4da437798b6..188a53a1918 100644 --- a/packages/boxel-cli/tests/helpers/integration.ts +++ b/packages/boxel-cli/tests/helpers/integration.ts @@ -19,6 +19,7 @@ export { registerUser } from '#realm-server/synapse'; export { matrixURL, matrixRegistrationSecret, + realmSecretSeed, } from '#realm-server/tests/helpers/index'; import { PgQueuePublisher, diff --git a/packages/boxel-cli/tests/integration/file-seed-auth.test.ts b/packages/boxel-cli/tests/integration/file-seed-auth.test.ts new file mode 100644 index 00000000000..86edef8157b --- /dev/null +++ b/packages/boxel-cli/tests/integration/file-seed-auth.test.ts @@ -0,0 +1,66 @@ +import '../helpers/setup-realm-server.ts'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { read } from '../../src/commands/file/read.ts'; +import { write } from '../../src/commands/file/write.ts'; +import { + startTestRealmServer, + stopTestRealmServer, + createTestProfileDir, + realmSecretSeed, + TEST_REALM_SERVER_URL, +} from '../helpers/integration.ts'; + +// The test realm grants `'*': ['read','write']`, so any token validly signed +// with `realmSecretSeed` may read and write — which is what the seed-auth path +// produces (a locally-minted JWT, no Matrix login). +let realmUrl: string; + +beforeAll(async () => { + await startTestRealmServer({ + fileSystem: { 'seed-existing.gts': 'export const existing = true;\n' }, + }); + realmUrl = `${TEST_REALM_SERVER_URL}/test/`; +}); + +afterAll(async () => { + await stopTestRealmServer(); +}); + +describe('file read/write with seed-based auth (integration)', () => { + it('writes then reads a file authenticating via the seed (no Matrix profile)', async () => { + // Empty profile: no Matrix login exists, so success proves the seed path. + let { profileManager, cleanup } = createTestProfileDir(); + try { + let writeResult = await write( + realmUrl, + 'seed-roundtrip.gts', + 'export const seeded = 42;\n', + { realmSecretSeed, profileManager }, + ); + expect(writeResult.error).toBeUndefined(); + expect(writeResult.ok).toBe(true); + + let readResult = await read(realmUrl, 'seed-roundtrip.gts', { + realmSecretSeed, + profileManager, + }); + expect(readResult.ok).toBe(true); + expect(readResult.content).toContain('seeded = 42'); + } finally { + cleanup(); + } + }); + + it('fails cleanly with "No active profile" when neither a seed nor a profile is configured', async () => { + let { profileManager, cleanup } = createTestProfileDir(); + try { + let result = await read(realmUrl, 'seed-existing.gts', { + profileManager, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain('No active profile'); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/boxel-cli/tests/integration/realm-publish-seed-auth.test.ts b/packages/boxel-cli/tests/integration/realm-publish-seed-auth.test.ts new file mode 100644 index 00000000000..bfced7839b4 --- /dev/null +++ b/packages/boxel-cli/tests/integration/realm-publish-seed-auth.test.ts @@ -0,0 +1,108 @@ +import '../helpers/setup-realm-server.ts'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRealm } from '../../src/commands/realm/create.ts'; +import { publishRealm } from '../../src/commands/realm/publish.ts'; +import { unpublishRealm } from '../../src/commands/realm/unpublish.ts'; +import { + startTestRealmServer, + stopTestRealmServer, + createTestProfileDir, + setupTestProfile, + uniqueRealmName, + realmSecretSeed, + TEST_REALM_SERVER_URL, + TEST_USERNAME, +} from '../helpers/integration.ts'; +import type { ProfileManager } from '../../src/lib/profile-manager.ts'; + +// The test realm server signs JWTs with `realmSecretSeed` and grants +// `@:localhost` the `realm-owner` permission. The publish +// endpoint authorizes the token's `user` as realm-owner, so a seed-minted +// server token impersonating that owner is what we assert works. +const OWNER_USER_ID = `@${TEST_USERNAME}:localhost`; + +let profileManager: ProfileManager; +let cleanup: () => void; + +beforeAll(async () => { + await startTestRealmServer(); + let testProfile = createTestProfileDir(); + profileManager = testProfile.profileManager; + cleanup = testProfile.cleanup; + // Used only to create the source realm (owned by OWNER_USER_ID); publishing + // below authenticates purely from the seed. + await setupTestProfile(profileManager); +}); + +afterAll(async () => { + cleanup?.(); + await stopTestRealmServer(); +}); + +function uniquePublishedUrl(): string { + let port = new URL(TEST_REALM_SERVER_URL).port; + return `http://published-${uniqueRealmName()}.localhost:${port}/`; +} + +describe('realm publish with seed-based auth (integration)', () => { + it('publishes using a seed-minted owner-scoped server token (no Matrix profile)', async () => { + let { realmUrl: sourceUrl } = await createRealm( + uniqueRealmName(), + 'Seed publish source', + { profileManager }, + ); + let publishedUrl = uniquePublishedUrl(); + + // Empty profile: no Matrix login exists, so a successful publish proves the + // realm server accepted the seed-minted owner-scoped server token. + let emptyProfile = createTestProfileDir(); + try { + let result = await publishRealm(sourceUrl, publishedUrl, { + realmSecretSeed: realmSecretSeed, + asUser: OWNER_USER_ID, + profileManager: emptyProfile.profileManager, + force: true, // skip the publishability gate (noop prerenderer → error docs) + waitForReady: false, // isolate the assertion to the /_publish-realm call + }); + + expect(result.publishedRealmURL).toBe(publishedUrl); + expect(result.publishedRealmId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + expect(result.lastPublishedAt).toBeTruthy(); + } finally { + // Clean up via the same seed path (also exercises /_unpublish-realm). + await unpublishRealm(publishedUrl, { + realmSecretSeed: realmSecretSeed, + asUser: OWNER_USER_ID, + tolerateMissing: true, + }); + emptyProfile.cleanup(); + } + }); + + it('rejects a seed publish whose impersonated user lacks realm-owner', async () => { + let { realmUrl: sourceUrl } = await createRealm( + uniqueRealmName(), + 'Seed publish non-owner', + { profileManager }, + ); + let publishedUrl = uniquePublishedUrl(); + let emptyProfile = createTestProfileDir(); + try { + // A validly-signed seed token, but for a user without realm-owner on the + // source realm — the server must refuse. + await expect( + publishRealm(sourceUrl, publishedUrl, { + realmSecretSeed: realmSecretSeed, + asUser: '@nobody-not-an-owner:localhost', + profileManager: emptyProfile.profileManager, + force: true, + waitForReady: false, + }), + ).rejects.toThrow(); + } finally { + emptyProfile.cleanup(); + } + }); +});