From 4d4bd82ef37253d9b042dfb31fa9d3d463f7c16d Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 16 Jun 2026 16:47:16 -0500 Subject: [PATCH 01/11] Add preliminary private link publishing --- .../operator-mode/publish-realm-modal.gts | 372 +++++++++++------- .../tests/resolve-published-realm-url-test.ts | 53 +++ .../runtime-common/published-realm-url.ts | 77 ++++ 3 files changed, 356 insertions(+), 146 deletions(-) diff --git a/packages/host/app/components/operator-mode/publish-realm-modal.gts b/packages/host/app/components/operator-mode/publish-realm-modal.gts index af8aeeff1a..d9232ceac7 100644 --- a/packages/host/app/components/operator-mode/publish-realm-modal.gts +++ b/packages/host/app/components/operator-mode/publish-realm-modal.gts @@ -25,10 +25,11 @@ import { not } from '@cardstack/boxel-ui/helpers'; import { IconX, Warning as WarningIcon } from '@cardstack/boxel-ui/icons'; import { - deriveRealmName, ensureTrailingSlash, + generateObscureSubdomain, isCardErrorJSONAPI, isCardInstance, + isGeneratedSubdomain, resolvePublishedRealmUrl, } from '@cardstack/runtime-common'; import { getPublishedRealmDomainOverrides } from '@cardstack/runtime-common/constants'; @@ -43,7 +44,6 @@ import config from '@cardstack/host/config/environment'; import type CommandService from '@cardstack/host/services/command-service'; import type HostModeService from '@cardstack/host/services/host-mode-service'; import type LoaderService from '@cardstack/host/services/loader-service'; -import type MatrixService from '@cardstack/host/services/matrix-service'; import type RealmService from '@cardstack/host/services/realm'; import type { @@ -82,7 +82,6 @@ interface Signature { export default class PublishRealmModal extends Component { @service declare private hostModeService: HostModeService; @service declare private loaderService: LoaderService; - @service declare private matrixService: MatrixService; @service declare private realm: RealmService; @service declare private realmServer: RealmServerService; @service declare private commandService: CommandService; @@ -101,6 +100,7 @@ export default class PublishRealmModal extends Component { @tracked private customSubdomainError: string | null = null; @tracked private isCheckingCustomSubdomain = false; @tracked private claimedDomain: ClaimedDomain | null = null; + @tracked private unlistedLinkError: string | null = null; @tracked private privateDependencyCheckError: string | null = null; @tracked private privateDependencyViolations: @@ -132,10 +132,6 @@ export default class PublishRealmModal extends Component { return this.#cardAPI; } - get isSubdirectoryRealmPublished() { - return this.hostModeService.isPublished(this.subdirectoryRealmUrl); - } - get isPublishDisabled() { return ( !this.hasSelectedPublishedRealmURLs || @@ -182,10 +178,6 @@ export default class PublishRealmModal extends Component { ); }; - get lastPublishedTime() { - return this.getFormattedLastPublishedTime(this.subdirectoryRealmUrl); - } - get claimedDomainPublishedUrl() { if (!this.claimedDomain) { return null; @@ -214,6 +206,39 @@ export default class PublishRealmModal extends Component { return !!this.claimedDomain && !this.isClaimedDomainPublished; } + // A realm holds a single claimed boxel.site domain. We tell the auto-generated + // "unlisted link" apart from a user-chosen "custom site name" by the shape of + // the subdomain, so each can live in its own card while sharing the one claim. + get claimedDomainIsGenerated() { + return ( + !!this.claimedDomain && isGeneratedSubdomain(this.claimedDomain.subdomain) + ); + } + + get claimedDomainIsCustom() { + return !!this.claimedDomain && !this.claimedDomainIsGenerated; + } + + get unlistedLinkSubdomain() { + return this.claimedDomain?.subdomain ?? ''; + } + + get isUnlistedLinkSelected() { + return this.claimedDomainIsGenerated && this.isCustomSubdomainSelected; + } + + get isCustomNameSelected() { + return this.claimedDomainIsCustom && this.isCustomSubdomainSelected; + } + + get isCustomNamePublished() { + return this.claimedDomainIsCustom && this.isClaimedDomainPublished; + } + + get isGeneratingUnlistedLink() { + return this.generateUnlistedLinkTask.isRunning; + } + get isUnclaimDomainButtonDisabled() { return ( this.handleUnclaimCustomSubdomainTask.isRunning || @@ -241,10 +266,6 @@ export default class PublishRealmModal extends Component { } } - get isSubdirectoryRealmSelected() { - return this.selectedPublishedRealmURLs.includes(this.subdirectoryRealmUrl); - } - get isCustomSubdomainSelected() { if (!this.claimedDomainPublishedUrl) { return false; @@ -322,7 +343,7 @@ export default class PublishRealmModal extends Component { } get customSubdomainDisplay() { - if (this.claimedDomain) { + if (this.claimedDomainIsCustom && this.claimedDomain) { return this.claimedDomain.subdomain; } @@ -356,29 +377,6 @@ export default class PublishRealmModal extends Component { return this.hostModeService.realmURL; } - get subdirectoryRealmUrl() { - return resolvePublishedRealmUrl( - { type: 'subdirectory', name: this.getRealmName() }, - { - protocol: this.getProtocol(), - matrixUsername: this.getMatrixUsername(), - spaceDomain: this.getDefaultPublishedRealmDomain(), - }, - ); - } - - get subdirectoryRealmParts() { - const protocol = this.getProtocol(); - const matrixUsername = this.getMatrixUsername(); - const domain = this.getDefaultPublishedRealmDomain(); - const realmName = this.getRealmName(); - - return { - baseUrl: `${protocol}://${matrixUsername}.${domain}/`, - realmName: realmName, - }; - } - private getProtocol(): string { // The local dev stack speaks HTTPS+HTTP/2 across the board now (the // realm-server reads the mkcert leaf via REALM_SERVER_TLS_CERT_FILE @@ -388,23 +386,6 @@ export default class PublishRealmModal extends Component { return 'https'; } - private getMatrixUsername(): string { - const userName = this.matrixService.userName; - if (!userName) { - throw new Error('Matrix username is not available'); - } - return userName; - } - - private getDefaultPublishedRealmDomain(): string { - // publishedRealmBoxelSpaceDomain is the domain that is used to form urls like "mike.boxel.space/game-mechanics" - // which are used to create Boxel Spaces (we will also have Boxel Sites, which is a different published realm) - - // TODO: since we currently only have Boxel Spaces, we can default to that domain. When we add Boxel Sites, - // adjust this component to know which published realm domain to use. - return config.publishedRealmBoxelSpaceDomain; - } - private buildPublishedRealmUrl(hostname: string): string { return resolvePublishedRealmUrl( { type: 'custom', name: hostname }, @@ -539,6 +520,56 @@ export default class PublishRealmModal extends Component { } }); + // Generates an unguessable subdomain and claims it through the same + // availability + claim path as a custom site name, so the realm's single + // boxel.site claim ends up holding a hard-to-guess host. Retries on the + // (astronomically unlikely) chance a generated name is already taken. + private generateUnlistedLinkTask = restartableTask(async () => { + this.unlistedLinkError = null; + let baseDomain = this.customSubdomainBase; + try { + let command = new CheckDomainAvailabilityCommand( + this.commandService.commandContext, + ); + for (let attempt = 0; attempt < 5; attempt++) { + let subdomain = generateObscureSubdomain(); + let result = await command.execute({ type: 'custom', name: subdomain }); + if (!result.available) { + continue; + } + let hostname = `${subdomain}.${baseDomain}`; + let claimResult = (await this.realmServer.claimBoxelDomain( + this.currentRealmURL, + hostname, + )) as { + data: { + id: string; + attributes: { + subdomain: string; + hostname: string; + sourceRealmURL: string; + }; + }; + }; + this.applyClaimedDomain( + { + id: claimResult.data.id, + subdomain: claimResult.data.attributes.subdomain, + hostname: claimResult.data.attributes.hostname, + sourceRealmURL: claimResult.data.attributes.sourceRealmURL, + }, + { select: true }, + ); + return; + } + this.unlistedLinkError = + 'Could not generate an available link. Please try again.'; + } catch (error) { + this.unlistedLinkError = + error instanceof Error ? error.message : 'Failed to generate link'; + } + }); + private handleUnclaimCustomSubdomainTask = restartableTask(async () => { if (!this.claimedDomain) { return; @@ -563,25 +594,6 @@ export default class PublishRealmModal extends Component { this.customSubdomainSelection = selection; } - private getRealmName(): string { - const realmUrl = this.currentRealmURL; - if (!realmUrl) { - throw new Error('Current realm URL is not available'); - } - return deriveRealmName(realmUrl); - } - - @action - toggleDefaultDomain(event: Event) { - const defaultUrl = this.subdirectoryRealmUrl; - const input = event.target as HTMLInputElement; - if (input.checked) { - this.addPublishedRealmUrl(defaultUrl); - } else { - this.removePublishedRealmUrl(defaultUrl); - } - } - @action toggleCustomSubdomain(event: Event) { if (this.claimedDomain) { @@ -774,7 +786,14 @@ export default class PublishRealmModal extends Component { }; get publishErrorForCustomSubdomain() { - if (!this.claimedDomainPublishedUrl) { + if (!this.claimedDomainIsCustom || !this.claimedDomainPublishedUrl) { + return null; + } + return this.getPublishErrorForUrl(this.claimedDomainPublishedUrl); + } + + get publishErrorForUnlistedLink() { + if (!this.claimedDomainIsGenerated || !this.claimedDomainPublishedUrl) { return null; } return this.getPublishErrorForUrl(this.claimedDomainPublishedUrl); @@ -936,87 +955,148 @@ export default class PublishRealmModal extends Component {
- - -
- - - -
- - {{this.subdirectoryRealmParts.baseUrl}}{{this.subdirectoryRealmParts.realmName}}/ - - {{#if this.isSubdirectoryRealmPublished}} + + +
+ {{#if this.claimedDomainIsGenerated}} + + + +
+ + {{this.getProtocol}}://{{this.unlistedLinkSubdomain}}.{{this.customSubdomainBase}}/ +
- Published - {{this.lastPublishedTime}} - - {{#if - (this.isUnpublishingRealm this.subdirectoryRealmUrl) - }} - - Unpublishing… - {{else}} - - Unpublish + {{#if this.claimedDomainLastPublishedTime}} + + Published + {{this.claimedDomainLastPublishedTime}} + {{#if this.claimedDomainPublishedUrl}} + + {{#if + (this.isUnpublishingRealm + this.claimedDomainPublishedUrl + ) + }} + + Unpublishing… + {{else}} + + Unpublish + {{/if}} + {{/if}} - - + {{else}} + Not published yet + {{/if}} + {{#if this.shouldShowUnclaimDomainButton}} + + {{#if this.handleUnclaimCustomSubdomainTask.isRunning}} + + Removing… + {{else}} + Remove link + {{/if}} + + {{/if}}
- {{/if}} -
+
+ {{else}} + + {{/if}}
- {{#if this.isSubdirectoryRealmPublished}} + {{#if this.claimedDomainIsGenerated}} + {{#if this.isClaimedDomainPublished}} + + + Open Site + + {{/if}} + {{else if (not this.claimedDomain)}} - - Open Site + {{#if this.isGeneratingUnlistedLink}} + + Generating… + {{else}} + Generate link + {{/if}} {{/if}} - {{#if (this.getPublishErrorForUrl this.subdirectoryRealmUrl)}} + {{#if this.unlistedLinkError}} +
+ {{this.unlistedLinkError}} +
+ {{/if}} + {{#if this.publishErrorForUnlistedLink}}
- {{this.getPublishErrorForUrl - this.subdirectoryRealmUrl - }} + {{this.publishErrorForUnlistedLink}}
{{/if}}
@@ -1026,9 +1106,9 @@ export default class PublishRealmModal extends Component { type='checkbox' id='custom-subdomain-checkbox' class='domain-checkbox' - checked={{this.isCustomSubdomainSelected}} + checked={{this.isCustomNameSelected}} data-test-custom-subdomain-checkbox - disabled={{not this.claimedDomain}} + disabled={{not this.claimedDomainIsCustom}} {{on 'change' this.toggleCustomSubdomain}} />
- {{else if this.claimedDomain}} + {{else if this.claimedDomainIsCustom}} @@ -1108,7 +1188,7 @@ export default class PublishRealmModal extends Component { class='url-part' >.{{this.customSubdomainBase}}/ - {{#if this.claimedDomain}} + {{#if this.claimedDomainIsCustom}}
{{#if this.claimedDomainLastPublishedTime}} Published @@ -1186,7 +1266,7 @@ export default class PublishRealmModal extends Component {
{{#if (not this.isCustomSubdomainSetupVisible)}} - {{#if this.isClaimedDomainPublished}} + {{#if this.isCustomNamePublished}} generateObscureSubdomain()), + ); + assert.strictEqual(values.size, 20, 'all generated values are distinct'); + }); + + test('isGeneratedSubdomain recognizes its own output', async function (assert) { + for (let i = 0; i < 20; i++) { + assert.true(isGeneratedSubdomain(generateObscureSubdomain())); + } + }); + + test('isGeneratedSubdomain rejects typical custom site names', async function (assert) { + for (let name of ['mysite', 'game-mechanics', 'mike', 'a', 'blog-2025']) { + assert.false( + isGeneratedSubdomain(name), + `"${name}" is not treated as a generated link`, + ); + } + }); }); }); diff --git a/packages/runtime-common/published-realm-url.ts b/packages/runtime-common/published-realm-url.ts index 8598305817..498594ac58 100644 --- a/packages/runtime-common/published-realm-url.ts +++ b/packages/runtime-common/published-realm-url.ts @@ -93,6 +93,83 @@ function normalizeProtocol(protocol: string): string { return protocol.replace(/:\/*$/, ''); } +// Character set for machine-generated "unlisted link" subdomains. Restricted to +// lowercase letters and digits (so the result always satisfies +// `validateSubdomain`) with the visually ambiguous characters removed +// (i, l, o, 0, 1) so a link read aloud or copied by hand is less error-prone. +const OBSCURE_SUBDOMAIN_ALPHABET = 'abcdefghjkmnpqrstuvwxyz'; +const OBSCURE_SUBDOMAIN_DIGITS = '23456789'; +const OBSCURE_SUBDOMAIN_CHARSET = + OBSCURE_SUBDOMAIN_ALPHABET + OBSCURE_SUBDOMAIN_DIGITS; + +// Length of a generated subdomain. 16 characters over a 31-symbol alphabet is +// ~79 bits of entropy — unguessable in the "even if the page is public, the URL +// is the secret" sense (a la a Google Doc link) — and is also the signal used +// to tell a generated subdomain apart from a user-chosen custom site name. +export const OBSCURE_SUBDOMAIN_LENGTH = 16; + +function randomBytes(length: number): Uint8Array { + let cryptoObj = ( + globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => void } } + ).crypto; + if (!cryptoObj?.getRandomValues) { + throw new Error( + 'A secure random source (crypto.getRandomValues) is required to generate an unlisted link', + ); + } + let bytes = new Uint8Array(length); + cryptoObj.getRandomValues(bytes); + return bytes; +} + +// Picks `count` characters uniformly from `charset` using rejection sampling so +// there is no modulo bias toward the earlier characters of the alphabet. +function randomChars(charset: string, count: number): string { + let max = Math.floor(256 / charset.length) * charset.length; + let out = ''; + while (out.length < count) { + for (let byte of randomBytes(count - out.length)) { + if (byte < max) { + out += charset[byte % charset.length]; + if (out.length === count) { + break; + } + } + } + } + return out; +} + +// Generates an unguessable subdomain for an "unlisted link" publish target. The +// first character is always a letter so the result can never be the pure-number +// form `validateSubdomain` rejects, and the whole string is drawn from a +// subdomain-safe alphabet so the claim/availability check accepts it as-is. +export function generateObscureSubdomain(): string { + let first = randomChars(OBSCURE_SUBDOMAIN_ALPHABET, 1); + let rest = randomChars( + OBSCURE_SUBDOMAIN_CHARSET, + OBSCURE_SUBDOMAIN_LENGTH - 1, + ); + return first + rest; +} + +// Whether a subdomain looks like one `generateObscureSubdomain` produced. Used +// to decide which publish-modal card (unlisted link vs. custom site name) owns +// a realm's single claimed `boxel.site` domain. A user-chosen name of the same +// length drawn from the same alphabet would be misclassified, but that only +// affects which card displays the claim, not correctness of publishing. +export function isGeneratedSubdomain(subdomain: string): boolean { + if (subdomain.length !== OBSCURE_SUBDOMAIN_LENGTH) { + return false; + } + if (!OBSCURE_SUBDOMAIN_ALPHABET.includes(subdomain[0])) { + return false; + } + return [...subdomain].every((char) => + OBSCURE_SUBDOMAIN_CHARSET.includes(char), + ); +} + export function resolvePublishedRealmUrl( target: PublishTargetSpec, ctx: PublishedRealmUrlContext = {}, From 8a0f3f8562219dbe3c152e427ca2a82a5d135ff5 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 16 Jun 2026 16:49:49 -0500 Subject: [PATCH 02/11] Fix case --- .../host/app/components/operator-mode/publish-realm-modal.gts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/app/components/operator-mode/publish-realm-modal.gts b/packages/host/app/components/operator-mode/publish-realm-modal.gts index d9232ceac7..6134e261d1 100644 --- a/packages/host/app/components/operator-mode/publish-realm-modal.gts +++ b/packages/host/app/components/operator-mode/publish-realm-modal.gts @@ -963,7 +963,7 @@ export default class PublishRealmModal extends Component { disabled={{not this.claimedDomainIsGenerated}} /> + Link
{{#if this.claimedDomainIsGenerated}} From 849df22c88a52a9c798ad282b11df7c44f5070a6 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 16 Jun 2026 16:51:47 -0500 Subject: [PATCH 03/11] Remove overly-detailed explanation --- .../host/app/components/operator-mode/publish-realm-modal.gts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/host/app/components/operator-mode/publish-realm-modal.gts b/packages/host/app/components/operator-mode/publish-realm-modal.gts index 6134e261d1..21ab16d86d 100644 --- a/packages/host/app/components/operator-mode/publish-realm-modal.gts +++ b/packages/host/app/components/operator-mode/publish-realm-modal.gts @@ -1044,8 +1044,7 @@ export default class PublishRealmModal extends Component {
{{else}} {{/if}} From 0024be5eea187b638b28eab69446a834048f2f7a Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 16 Jun 2026 17:50:48 -0500 Subject: [PATCH 04/11] Add tests --- .../tests/acceptance/host-submode-test.gts | 267 +++++++++--------- packages/matrix/tests/head-tags.spec.ts | 28 +- packages/matrix/tests/publish-realm.spec.ts | 58 ++-- 3 files changed, 184 insertions(+), 169 deletions(-) diff --git a/packages/host/tests/acceptance/host-submode-test.gts b/packages/host/tests/acceptance/host-submode-test.gts index dbd18cd5ba..a865b269f1 100644 --- a/packages/host/tests/acceptance/host-submode-test.gts +++ b/packages/host/tests/acceptance/host-submode-test.gts @@ -74,6 +74,62 @@ function withUpdatedTestRealmInfo( }; } +// Mocks the availability + claim endpoints that the "Unlisted link" flow drives, +// echoing back whatever generated hostname the modal posts so the resulting +// claim is classified as a generated (unlisted) link rather than a custom name. +function mockUnlistedLinkClaim(sourceRealmURL: string) { + let network = getService('network'); + network.mount( + async (request: Request) => { + if (!request.url.includes('_check-boxel-domain-availability')) { + return null; + } + let subdomain = new URL(request.url).searchParams.get('subdomain') ?? ''; + return new Response( + JSON.stringify({ + available: true, + hostname: `${subdomain}.localhost:4201`, + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + }, + { prepend: true }, + ); + network.mount( + async (request: Request) => { + if ( + request.method !== 'POST' || + !request.url.includes('_boxel-claimed-domains') + ) { + return null; + } + let body = (await request.json()) as { + data: { attributes: { hostname: string } }; + }; + let hostname = body.data.attributes.hostname; + let subdomain = hostname.replace(/\.localhost:4201$/, ''); + return new Response( + JSON.stringify({ + data: { + type: 'claimed-domain', + id: '1', + attributes: { hostname, subdomain, sourceRealmURL }, + }, + }), + { headers: { 'Content-Type': 'application/vnd.api+json' } }, + ); + }, + { prepend: true }, + ); +} + +// The URL shown in the unlisted-link card after generating, e.g. +// "https://.localhost:4201/". The subdomain is random per run. +function generatedUnlistedUrl(): string { + let el = document.querySelector('[data-test-unlisted-link-url]'); + return (el?.textContent ?? '').replace(/\s+/g, ''); +} + module('Acceptance | host submode', function (hooks) { setupApplicationTest(hooks); setupLocalIndexing(hooks); @@ -663,7 +719,9 @@ module('Acceptance | host submode', function (hooks) { getService('realm-server').unpublishRealm = unpublishRealm; }); - test('can publish realm', async function (assert) { + test('can publish an unlisted link', async function (assert) { + mockUnlistedLinkClaim(testRealmURL); + await visitOperatorMode({ submode: 'host', trail: [`${testRealmURL}Person/1.json`], @@ -676,13 +734,22 @@ module('Acceptance | host submode', function (hooks) { assert.dom('[data-test-last-published-at]').doesNotExist(); assert.dom('[data-test-unpublish-button]').doesNotExist(); assert.dom('[data-test-open-site-button]').doesNotExist(); - assert.dom('[data-test-default-domain-checkbox]').isNotChecked(); + assert.dom('[data-test-unlisted-link-url]').doesNotExist(); + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); assert.dom('[data-test-publish-button]').isDisabled(); - await click('[data-test-default-domain-checkbox]'); - assert.dom('[data-test-default-domain-checkbox]').isChecked(); + // Generating an unlisted link claims a random subdomain and selects it. + await click('[data-test-generate-unlisted-link-button]'); + await waitFor('[data-test-unlisted-link-url]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); assert.dom('[data-test-publish-button]').isNotDisabled(); + let unlistedUrl = generatedUnlistedUrl(); + assert.ok( + /^https:\/\/[a-z0-9]+\.localhost:4201\/$/.test(unlistedUrl), + `generated an obscure URL (${unlistedUrl})`, + ); + await click('[data-test-publish-button]'); assert.dom('[data-test-publish-button]').hasText('Publishing…'); assert.dom('[data-test-publish-button]').hasAttribute('disabled'); @@ -696,8 +763,7 @@ module('Acceptance | host submode', function (hooks) { assert.dom('.publishing-realm-popover').exists(); assert .dom('.publishing-realm-popover') - .containsText(`Publishing to: https://testuser.localhost:4201/test/`); - assert.dom('.publishing-realm-popover').exists(); + .containsText(`Publishing to: ${unlistedUrl}`); assert.dom('.loading-icon').exists(); publishDeferred.fulfill(); @@ -727,28 +793,32 @@ module('Acceptance | host submode', function (hooks) { assert .dom( - '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', ) - .hasAttribute('href', 'https://testuser.localhost:4201/test/') + .hasAttribute('href', unlistedUrl) .hasAttribute('target', '_blank'); }); - test('preselects previously published domains on refresh', async function (assert) { + test('preselects a previously published unlisted link on refresh', async function (assert) { let now = Date.now(); let realmServer = getService('realm-server') as any; let originalFetchClaimed = realmServer.fetchBoxelClaimedDomain; + // A 16-character subdomain from the generated alphabet is recognized as + // an unlisted link (see isGeneratedSubdomain). + let generatedSubdomain = 'k7f3qz9pbcdmnpqr'; + let generatedHost = `${generatedSubdomain}.localhost:4201`; + realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', - hostname: 'custom-site-name.localhost:4201', - subdomain: 'custom-site-name', + hostname: generatedHost, + subdomain: generatedSubdomain, sourceRealmURL: testRealmURL, }); let restoreRealmInfo = withUpdatedTestRealmInfo({ lastPublishedAt: { - 'https://testuser.localhost:4201/test/': String(now), - 'https://custom-site-name.localhost:4201/': String(now), + [`https://${generatedHost}/`]: String(now), }, }); @@ -761,8 +831,10 @@ module('Acceptance | host submode', function (hooks) { await click('[data-test-publish-realm-button]'); await waitFor('[data-test-publish-realm-modal]'); - assert.dom('[data-test-default-domain-checkbox]').isChecked(); - assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert + .dom('[data-test-unlisted-link-url]') + .hasText(`https://${generatedHost}/`); assert.dom('[data-test-publish-button]').isNotDisabled(); } finally { realmServer.fetchBoxelClaimedDomain = originalFetchClaimed; @@ -770,7 +842,9 @@ module('Acceptance | host submode', function (hooks) { } }); - test('default domain checkbox can be checked and unchecked', async function (assert) { + test('unlisted link checkbox can be checked and unchecked', async function (assert) { + mockUnlistedLinkClaim(testRealmURL); + await visitOperatorMode({ submode: 'host', trail: [`${testRealmURL}Person/1.json`], @@ -778,22 +852,42 @@ module('Acceptance | host submode', function (hooks) { await click('[data-test-publish-realm-button]'); - assert.dom('[data-test-default-domain-checkbox]').isNotChecked(); + // Before generating, the checkbox is disabled and nothing is selected. + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-unlisted-link-checkbox]').isDisabled(); assert.dom('[data-test-publish-button]').isDisabled(); - await click('[data-test-default-domain-checkbox]'); - assert.dom('[data-test-default-domain-checkbox]').isChecked(); + await click('[data-test-generate-unlisted-link-button]'); + await waitFor('[data-test-unlisted-link-url]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); assert.dom('[data-test-publish-button]').isNotDisabled(); - await click('[data-test-default-domain-checkbox]'); - assert.dom('[data-test-default-domain-checkbox]').isNotChecked(); + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); assert.dom('[data-test-publish-button]').isDisabled(); + + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); }); - test('can unpublish realm', async function (assert) { + test('can unpublish an unlisted link', async function (assert) { + let realmServer = getService('realm-server') as any; + let originalFetchClaimed = realmServer.fetchBoxelClaimedDomain; + + let generatedSubdomain = 'k7f3qz9pbcdmnpqr'; + let generatedHost = `${generatedSubdomain}.localhost:4201`; + + realmServer.fetchBoxelClaimedDomain = async () => ({ + id: 'claimed-domain-1', + hostname: generatedHost, + subdomain: generatedSubdomain, + sourceRealmURL: testRealmURL, + }); + let restoreRealmInfo = withUpdatedTestRealmInfo({ lastPublishedAt: { - ['https://testuser.localhost:4201/test/']: ( + [`https://${generatedHost}/`]: ( new Date().getTime() - 3 * 24 * 60 * 60 * 1000 ).toString(), @@ -824,6 +918,7 @@ module('Acceptance | host submode', function (hooks) { assert.dom('[data-test-open-site-button]').doesNotExist(); } finally { restoreRealmInfo(); + realmServer.fetchBoxelClaimedDomain = originalFetchClaimed; } }); @@ -1164,7 +1259,7 @@ module('Acceptance | host submode', function (hooks) { await waitFor('[data-test-publish-realm-modal]'); assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); - assert.dom('[data-test-default-domain-checkbox]').isNotChecked(); + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); assert.dom('[data-test-publish-button]').isNotDisabled(); } finally { realmServer.fetchBoxelClaimedDomain = originalFetchClaimed; @@ -1207,6 +1302,8 @@ module('Acceptance | host submode', function (hooks) { }); test('shows inline error when publishing to a domain fails', async function (assert) { + mockUnlistedLinkClaim(testRealmURL); + let publishError = new Deferred(); let publishRealm = async () => { @@ -1224,34 +1321,37 @@ module('Acceptance | host submode', function (hooks) { await click('[data-test-publish-realm-button]'); assert.dom('[data-test-publish-realm-modal]').exists(); - let defaultUrl = 'https://testuser.localhost:4201/test/'; + await click('[data-test-generate-unlisted-link-button]'); + await waitFor('[data-test-unlisted-link-url]'); + let unlistedUrl = generatedUnlistedUrl(); + assert - .dom(`[data-test-domain-publish-error="${defaultUrl}"]`) + .dom(`[data-test-domain-publish-error="${unlistedUrl}"]`) .doesNotExist(); - await click('[data-test-default-domain-checkbox]'); await click('[data-test-publish-button]'); publishError.reject( new Error('Network error: Failed to publish realm'), ); - await waitFor(`[data-test-domain-publish-error="${defaultUrl}"]`); + await waitFor(`[data-test-domain-publish-error="${unlistedUrl}"]`); // Error should appear inline on the domain option - assert.dom(`[data-test-domain-publish-error="${defaultUrl}"]`).exists(); assert - .dom(`[data-test-domain-publish-error="${defaultUrl}"] .error-text`) + .dom(`[data-test-domain-publish-error="${unlistedUrl}"]`) + .exists(); + assert + .dom(`[data-test-domain-publish-error="${unlistedUrl}"] .error-text`) .hasText('Network error: Failed to publish realm'); // Verify the modal stays open when there's an error assert.dom('[data-test-publish-realm-modal]').exists(); }); - test('shows inline error for failed domain while allowing successful ones', async function (assert) { + test('can publish a claimed custom domain', async function (assert) { let realmServer = getService('realm-server') as any; let originalFetchClaimed = realmServer.fetchBoxelClaimedDomain; - let originalPublishRealm = realmServer.publishRealm; realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', @@ -1260,27 +1360,6 @@ module('Acceptance | host submode', function (hooks) { sourceRealmURL: testRealmURL, }); - let defaultUrl = 'https://testuser.localhost:4201/test/'; - let customUrl = 'https://my-custom-site.localhost:4201/'; - - // Mock publish to succeed for default, fail for custom - realmServer.publishRealm = async ( - _sourceURL: string, - publishedURL: string, - ) => { - await publishDeferred.promise; - if (publishedURL === customUrl) { - throw new Error('Custom domain validation failed'); - } - return { - sourceRealmURL: _sourceURL, - publishedRealmURL: publishedURL, - publishedRealmId: '1', - lastPublishedAt: String(new Date().getTime()), - status: 'published', - }; - }; - try { await visitOperatorMode({ submode: 'host', @@ -1290,73 +1369,10 @@ module('Acceptance | host submode', function (hooks) { await click('[data-test-publish-realm-button]'); await waitFor('[data-test-publish-realm-modal]'); + // The claimed custom name is preselected; the unlisted link is not + // claimed, so its checkbox is disabled. assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); - assert.dom('[data-test-default-domain-checkbox]').isNotChecked(); - - // Check default checkbox (custom already selected) - await click('[data-test-default-domain-checkbox]'); - - await click('[data-test-publish-button]'); - publishDeferred.fulfill(); - - await waitUntil(() => { - return !document.querySelector( - '[data-test-publish-realm-button].publishing', - ); - }); - - await click('[data-test-publish-realm-button]'); - - // Default domain should show as published (success) - assert - .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(1)', - ) - .containsText('Published'); - - // Custom domain should show error (failure) - assert - .dom(`[data-test-domain-publish-error="${customUrl}"]`) - .exists(); - assert - .dom(`[data-test-domain-publish-error="${customUrl}"] .error-text`) - .hasText('Custom domain validation failed'); - - // Default domain should NOT have error - assert - .dom(`[data-test-domain-publish-error="${defaultUrl}"]`) - .doesNotExist(); - } finally { - realmServer.fetchBoxelClaimedDomain = originalFetchClaimed; - realmServer.publishRealm = originalPublishRealm; - } - }); - - test('can publish claimed domain', async function (assert) { - let realmServer = getService('realm-server') as any; - let originalFetchClaimed = realmServer.fetchBoxelClaimedDomain; - - realmServer.fetchBoxelClaimedDomain = async () => ({ - id: 'claimed-domain-1', - hostname: 'my-custom-site.localhost:4201', - subdomain: 'my-custom-site', - sourceRealmURL: testRealmURL, - }); - - try { - await visitOperatorMode({ - submode: 'host', - trail: [`${testRealmURL}Person/1.json`], - }); - - await click('[data-test-publish-realm-button]'); - await waitFor('[data-test-publish-realm-modal]'); - - // Custom is preselected; add default checkbox - await click('[data-test-default-domain-checkbox]'); - - assert.dom('[data-test-default-domain-checkbox]').isChecked(); - assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); + assert.dom('[data-test-unlisted-link-checkbox]').isDisabled(); await click('[data-test-publish-button]'); assert.dom('[data-test-publish-button]').hasText('Publishing…'); @@ -1371,26 +1387,15 @@ module('Acceptance | host submode', function (hooks) { await click('[data-test-publish-realm-button]'); - // Both domains should show as published - assert - .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(1)', - ) - .containsText('Published'); + // The custom domain (second card) should show as published. assert .dom( '[data-test-publish-realm-modal] .domain-option:nth-of-type(2)', ) .containsText('Published'); - - // Both should have unpublish buttons - assert.dom('[data-test-unpublish-button]').exists(); assert.dom('[data-test-unpublish-custom-subdomain-button]').exists(); - // Custom subdomain should have Open Site button await waitFor('[data-test-open-custom-subdomain-button]'); - assert.dom('[data-test-open-custom-subdomain-button]').exists(); - assert .dom('[data-test-open-custom-subdomain-button]') .hasAttribute('href', 'https://my-custom-site.localhost:4201/') diff --git a/packages/matrix/tests/head-tags.spec.ts b/packages/matrix/tests/head-tags.spec.ts index b5bb2358ee..9de978fc5b 100644 --- a/packages/matrix/tests/head-tags.spec.ts +++ b/packages/matrix/tests/head-tags.spec.ts @@ -55,10 +55,12 @@ test.describe('Head tags', () => { await page.locator('[data-test-publish-realm-button]').click(); } - async function publishDefaultRealm( + // Publishes the realm to a generated unlisted link and returns the published + // URL (random per run, e.g. "https://.localhost:4205/"). + async function publishUnlistedLink( page: Page, opts?: { realmName?: string; displayName?: string }, - ) { + ): Promise { let { realmName = 'new-workspace', displayName = '1New Workspace' } = opts ?? {}; await createUserAndRealm(page, { @@ -67,25 +69,28 @@ test.describe('Head tags', () => { displayName, }); await openPublishRealmModal(page, displayName); - await page.locator('[data-test-default-domain-checkbox]').click(); + await page.locator('[data-test-generate-unlisted-link-button]').click(); + await page.waitForSelector('[data-test-unlisted-link-url]'); + let publishedURL = ( + await page.locator('[data-test-unlisted-link-url]').innerText() + ).replace(/\s+/g, ''); await page.locator('[data-test-publish-button]').click(); await page.waitForSelector('[data-test-unpublish-button]'); await expect( page.locator( - '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', ), ).toBeVisible(); + return publishedURL; } test('the HTML response from a published realm has relevant meta tags', async ({ page, }) => { - await publishDefaultRealm(page); - - let publishedRealmURLString = `https://${user.username}.localhost:4205/new-workspace/index`; + let publishedURL = await publishUnlistedLink(page); - await page.goto(publishedRealmURLString); + await page.goto(`${publishedURL}index`); await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( 'content', @@ -267,11 +272,14 @@ test.describe('Head tags', () => { ); await openPublishRealmModal(page, displayName); - await page.locator('[data-test-default-domain-checkbox]').click(); + await page.locator('[data-test-generate-unlisted-link-button]').click(); + await page.waitForSelector('[data-test-unlisted-link-url]'); + let publishedRealmURL = ( + await page.locator('[data-test-unlisted-link-url]').innerText() + ).replace(/\s+/g, ''); await page.locator('[data-test-publish-button]').click(); await page.waitForSelector('[data-test-unpublish-button]'); - let publishedRealmURL = `https://${user.username}.localhost:4205/${realmName}/`; let defaultCardURL = `${publishedRealmURL}default-head-card.json`; // Publishing returns before the published realm finishes re-indexing diff --git a/packages/matrix/tests/publish-realm.spec.ts b/packages/matrix/tests/publish-realm.spec.ts index 60e8d18b73..3e17608161 100644 --- a/packages/matrix/tests/publish-realm.spec.ts +++ b/packages/matrix/tests/publish-realm.spec.ts @@ -32,40 +32,44 @@ test.describe('Publish realm', () => { await page.locator('[data-test-publish-realm-button]').click(); } - async function publishDefaultRealm(page: Page) { + // Generates + claims a random unlisted-link subdomain, publishes to it, and + // returns the published URL (random per run, e.g. + // "https://.localhost:4205/"). + async function publishUnlistedLink(page: Page): Promise { await openPublishRealmModal(page); - await page.locator('[data-test-default-domain-checkbox]').click(); + await page.locator('[data-test-generate-unlisted-link-button]').click(); + await page.waitForSelector('[data-test-unlisted-link-url]'); + let publishedURL = ( + await page.locator('[data-test-unlisted-link-url]').innerText() + ).replace(/\s+/g, ''); await page.locator('[data-test-publish-button]').click(); await page.waitForSelector('[data-test-unpublish-button]'); await expect( page.locator( - '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', ), ).toBeVisible(); + return publishedURL; } - test('it can publish a realm to a subdirectory', async ({ page }) => { - await publishDefaultRealm(page); + test('it can publish an unlisted link', async ({ page }) => { + let publishedURL = await publishUnlistedLink(page); let newTabPromise = page.waitForEvent('popup'); await page .locator( - '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', ) .click(); let newTab = await newTabPromise; await newTab.waitForLoadState(); - await expect(newTab).toHaveURL( - `https://${user.username}.localhost:4205/new-workspace/`, - ); + await expect(newTab).toHaveURL(publishedURL); await expect( - newTab.locator( - `[data-test-card="https://${user.username}.localhost:4205/new-workspace/index"]`, - ), + newTab.locator(`[data-test-card="${publishedURL}index"]`), ).toBeVisible(); await newTab.close(); await page.bringToFront(); @@ -312,7 +316,8 @@ test.describe('Publish realm', () => { await page.locator('[data-test-submode-switcher] button').click(); await page.locator('[data-test-boxel-menu-item-text="Host"]').click(); await page.locator('[data-test-publish-realm-button]').click(); - await page.locator('[data-test-default-domain-checkbox]').click(); + await page.locator('[data-test-generate-unlisted-link-button]').click(); + await page.waitForSelector('[data-test-unlisted-link-url]'); await page.locator('[data-test-publish-button]').click(); await page.waitForSelector('[data-test-unpublish-button]'); @@ -320,7 +325,7 @@ test.describe('Publish realm', () => { let firstTabPromise = page.waitForEvent('popup'); await page .locator( - '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', ) .click(); let firstTab = await firstTabPromise; @@ -380,13 +385,14 @@ test.describe('Publish realm', () => { 'source index.json should contain the updated sentinel after postCardSource', ).toBeTruthy(); - // Re-open the publish modal and re-trigger publish. The - // default-domain checkbox can lose its selection on modal close, - // so check its state and click only when needed — otherwise the - // publish button is disabled (`!hasSelectedPublishedRealmURLs`) - // and the click silently no-ops. + // Re-open the publish modal and re-trigger publish. The existing + // unlisted-link claim loads asynchronously; wait for it, then ensure + // its checkbox is selected — it can lose its selection on modal close, + // and otherwise the publish button is disabled + // (`!hasSelectedPublishedRealmURLs`) and the click silently no-ops. await page.locator('[data-test-publish-realm-button]').click(); - let domainCheckbox = page.locator('[data-test-default-domain-checkbox]'); + await page.waitForSelector('[data-test-unlisted-link-url]'); + let domainCheckbox = page.locator('[data-test-unlisted-link-checkbox]'); if (!(await domainCheckbox.isChecked())) { await domainCheckbox.click(); } @@ -421,7 +427,7 @@ test.describe('Publish realm', () => { let secondTabPromise = page.waitForEvent('popup'); await page .locator( - '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', ) .click(); let secondTab = await secondTabPromise; @@ -441,7 +447,7 @@ test.describe('Publish realm', () => { }); test('open site popover opens with shift-click', async ({ page }) => { - await publishDefaultRealm(page); + let publishedURL = await publishUnlistedLink(page); let newTabPromise = page.waitForEvent('popup'); @@ -451,9 +457,7 @@ test.describe('Publish realm', () => { let newTab = await newTabPromise; await newTab.waitForLoadState(); - await expect(newTab).toHaveURL( - `https://${user.username}.localhost:4205/new-workspace/`, - ); + await expect(newTab).toHaveURL(publishedURL); await newTab.close(); await page.bringToFront(); @@ -481,9 +485,7 @@ test.describe('Publish realm', () => { newTab = await newTabPromise; await newTab.waitForLoadState(); - await expect(newTab).toHaveURL( - `https://${user.username}.localhost:4205/new-workspace/`, - ); + await expect(newTab).toHaveURL(publishedURL); await newTab.close(); await page.bringToFront(); }); From 2dcb0dcb12dba277b3334882884ab7942fdf3513 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 16 Jun 2026 19:08:32 -0500 Subject: [PATCH 05/11] Change network mock to service stub --- .../tests/acceptance/host-submode-test.gts | 320 +++++++++--------- 1 file changed, 161 insertions(+), 159 deletions(-) diff --git a/packages/host/tests/acceptance/host-submode-test.gts b/packages/host/tests/acceptance/host-submode-test.gts index a865b269f1..e4b26a04fa 100644 --- a/packages/host/tests/acceptance/host-submode-test.gts +++ b/packages/host/tests/acceptance/host-submode-test.gts @@ -74,53 +74,41 @@ function withUpdatedTestRealmInfo( }; } -// Mocks the availability + claim endpoints that the "Unlisted link" flow drives, -// echoing back whatever generated hostname the modal posts so the resulting -// claim is classified as a generated (unlisted) link rather than a custom name. -function mockUnlistedLinkClaim(sourceRealmURL: string) { - let network = getService('network'); - network.mount( - async (request: Request) => { - if (!request.url.includes('_check-boxel-domain-availability')) { - return null; - } - let subdomain = new URL(request.url).searchParams.get('subdomain') ?? ''; - return new Response( - JSON.stringify({ - available: true, - hostname: `${subdomain}.localhost:4201`, - }), - { headers: { 'Content-Type': 'application/json' } }, - ); - }, - { prepend: true }, - ); - network.mount( - async (request: Request) => { - if ( - request.method !== 'POST' || - !request.url.includes('_boxel-claimed-domains') - ) { - return null; - } - let body = (await request.json()) as { - data: { attributes: { hostname: string } }; - }; - let hostname = body.data.attributes.hostname; - let subdomain = hostname.replace(/\.localhost:4201$/, ''); - return new Response( - JSON.stringify({ - data: { - type: 'claimed-domain', - id: '1', - attributes: { hostname, subdomain, sourceRealmURL }, - }, - }), - { headers: { 'Content-Type': 'application/vnd.api+json' } }, - ); +// Stubs the realm-server availability + claim methods the "Unlisted link" flow +// calls, echoing back the generated hostname so the resulting claim is +// classified as a generated (unlisted) link rather than a custom name. Mirrors +// how the rest of this module stubs realm-server methods (publishRealm, +// fetchBoxelClaimedDomain) rather than mocking network traffic; the real +// generate→claim→publish round-trip is covered by the Matrix publish-realm +// spec. Returns a restore function to call in a `finally`. +function stubUnlistedLinkClaim(sourceRealmURL: string): () => void { + let realmServer = getService('realm-server') as any; + let originalCheck = realmServer.checkDomainAvailability; + let originalClaim = realmServer.claimBoxelDomain; + + realmServer.checkDomainAvailability = async (subdomain: string) => ({ + available: true, + hostname: `${subdomain}.localhost:4201`, + }); + realmServer.claimBoxelDomain = async ( + _sourceRealmURL: string, + hostname: string, + ) => ({ + data: { + type: 'claimed-domain', + id: '1', + attributes: { + hostname, + subdomain: hostname.replace(/\.localhost:4201$/, ''), + sourceRealmURL, + }, }, - { prepend: true }, - ); + }); + + return () => { + realmServer.checkDomainAvailability = originalCheck; + realmServer.claimBoxelDomain = originalClaim; + }; } // The URL shown in the unlisted-link card after generating, e.g. @@ -720,83 +708,88 @@ module('Acceptance | host submode', function (hooks) { }); test('can publish an unlisted link', async function (assert) { - mockUnlistedLinkClaim(testRealmURL); + let restoreClaim = stubUnlistedLinkClaim(testRealmURL); + try { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); - await visitOperatorMode({ - submode: 'host', - trail: [`${testRealmURL}Person/1.json`], - }); + assert + .dom('[data-test-publish-realm-button]') + .hasText('Publish Site'); + await click('[data-test-publish-realm-button]'); + assert.dom('[data-test-publish-realm-modal]').exists(); - assert.dom('[data-test-publish-realm-button]').hasText('Publish Site'); - await click('[data-test-publish-realm-button]'); - assert.dom('[data-test-publish-realm-modal]').exists(); + assert.dom('[data-test-last-published-at]').doesNotExist(); + assert.dom('[data-test-unpublish-button]').doesNotExist(); + assert.dom('[data-test-open-site-button]').doesNotExist(); + assert.dom('[data-test-unlisted-link-url]').doesNotExist(); + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); - assert.dom('[data-test-last-published-at]').doesNotExist(); - assert.dom('[data-test-unpublish-button]').doesNotExist(); - assert.dom('[data-test-open-site-button]').doesNotExist(); - assert.dom('[data-test-unlisted-link-url]').doesNotExist(); - assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); - assert.dom('[data-test-publish-button]').isDisabled(); - - // Generating an unlisted link claims a random subdomain and selects it. - await click('[data-test-generate-unlisted-link-button]'); - await waitFor('[data-test-unlisted-link-url]'); - assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); - assert.dom('[data-test-publish-button]').isNotDisabled(); - - let unlistedUrl = generatedUnlistedUrl(); - assert.ok( - /^https:\/\/[a-z0-9]+\.localhost:4201\/$/.test(unlistedUrl), - `generated an obscure URL (${unlistedUrl})`, - ); + // Generating an unlisted link claims a random subdomain and selects it. + await click('[data-test-generate-unlisted-link-button]'); + await waitFor('[data-test-unlisted-link-url]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); - await click('[data-test-publish-button]'); - assert.dom('[data-test-publish-button]').hasText('Publishing…'); - assert.dom('[data-test-publish-button]').hasAttribute('disabled'); + let unlistedUrl = generatedUnlistedUrl(); + assert.ok( + /^https:\/\/[a-z0-9]+\.localhost:4201\/$/.test(unlistedUrl), + `generated an obscure URL (${unlistedUrl})`, + ); - await waitFor('[data-test-publish-realm-button].publishing'); - assert.dom('[data-test-publish-realm-button]').hasText('Publishing…'); - assert.dom('[data-test-publish-realm-button]').hasClass('publishing'); + await click('[data-test-publish-button]'); + assert.dom('[data-test-publish-button]').hasText('Publishing…'); + assert.dom('[data-test-publish-button]').hasAttribute('disabled'); - await click('[data-test-publish-realm-button]'); - await waitFor('.publishing-realm-popover'); - assert.dom('.publishing-realm-popover').exists(); - assert - .dom('.publishing-realm-popover') - .containsText(`Publishing to: ${unlistedUrl}`); - assert.dom('.loading-icon').exists(); + await waitFor('[data-test-publish-realm-button].publishing'); + assert.dom('[data-test-publish-realm-button]').hasText('Publishing…'); + assert.dom('[data-test-publish-realm-button]').hasClass('publishing'); - publishDeferred.fulfill(); + await click('[data-test-publish-realm-button]'); + await waitFor('.publishing-realm-popover'); + assert.dom('.publishing-realm-popover').exists(); + assert + .dom('.publishing-realm-popover') + .containsText(`Publishing to: ${unlistedUrl}`); + assert.dom('.loading-icon').exists(); - await waitUntil(() => { - return !document.querySelector( - '[data-test-publish-realm-button].publishing', - ); - }); + publishDeferred.fulfill(); - assert - .dom('[data-test-publish-realm-button]') - .hasText('Republish Site'); - assert - .dom('[data-test-publish-realm-button]') - .doesNotHaveClass('publishing'); - assert.dom('.publishing-realm-popover').doesNotExist(); + await waitUntil(() => { + return !document.querySelector( + '[data-test-publish-realm-button].publishing', + ); + }); - await click('[data-test-publish-realm-button]'); + assert + .dom('[data-test-publish-realm-button]') + .hasText('Republish Site'); + assert + .dom('[data-test-publish-realm-button]') + .doesNotHaveClass('publishing'); + assert.dom('.publishing-realm-popover').doesNotExist(); + + await click('[data-test-publish-realm-button]'); - assert.dom('[data-test-last-published-at]').exists(); - assert.dom('[data-test-last-published-at]').containsText('Published'); - assert.dom('[data-test-unpublish-button]').exists(); - assert.dom('[data-test-unpublish-button]').containsText('Unpublish'); - assert.dom('[data-test-open-site-button]').exists(); - assert.dom('[data-test-open-site-button]').containsText('Open Site'); + assert.dom('[data-test-last-published-at]').exists(); + assert.dom('[data-test-last-published-at]').containsText('Published'); + assert.dom('[data-test-unpublish-button]').exists(); + assert.dom('[data-test-unpublish-button]').containsText('Unpublish'); + assert.dom('[data-test-open-site-button]').exists(); + assert.dom('[data-test-open-site-button]').containsText('Open Site'); - assert - .dom( - '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', - ) - .hasAttribute('href', unlistedUrl) - .hasAttribute('target', '_blank'); + assert + .dom( + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + ) + .hasAttribute('href', unlistedUrl) + .hasAttribute('target', '_blank'); + } finally { + restoreClaim(); + } }); test('preselects a previously published unlisted link on refresh', async function (assert) { @@ -843,32 +836,35 @@ module('Acceptance | host submode', function (hooks) { }); test('unlisted link checkbox can be checked and unchecked', async function (assert) { - mockUnlistedLinkClaim(testRealmURL); - - await visitOperatorMode({ - submode: 'host', - trail: [`${testRealmURL}Person/1.json`], - }); + let restoreClaim = stubUnlistedLinkClaim(testRealmURL); + try { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); - await click('[data-test-publish-realm-button]'); + await click('[data-test-publish-realm-button]'); - // Before generating, the checkbox is disabled and nothing is selected. - assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); - assert.dom('[data-test-unlisted-link-checkbox]').isDisabled(); - assert.dom('[data-test-publish-button]').isDisabled(); + // Before generating, the checkbox is disabled and nothing is selected. + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-unlisted-link-checkbox]').isDisabled(); + assert.dom('[data-test-publish-button]').isDisabled(); - await click('[data-test-generate-unlisted-link-button]'); - await waitFor('[data-test-unlisted-link-url]'); - assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); - assert.dom('[data-test-publish-button]').isNotDisabled(); + await click('[data-test-generate-unlisted-link-button]'); + await waitFor('[data-test-unlisted-link-url]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); - await click('[data-test-unlisted-link-checkbox]'); - assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); - assert.dom('[data-test-publish-button]').isDisabled(); + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); - await click('[data-test-unlisted-link-checkbox]'); - assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); - assert.dom('[data-test-publish-button]').isNotDisabled(); + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); + } finally { + restoreClaim(); + } }); test('can unpublish an unlisted link', async function (assert) { @@ -1302,7 +1298,7 @@ module('Acceptance | host submode', function (hooks) { }); test('shows inline error when publishing to a domain fails', async function (assert) { - mockUnlistedLinkClaim(testRealmURL); + let restoreClaim = stubUnlistedLinkClaim(testRealmURL); let publishError = new Deferred(); @@ -1313,40 +1309,46 @@ module('Acceptance | host submode', function (hooks) { getService('realm-server').publishRealm = publishRealm; - await visitOperatorMode({ - submode: 'host', - trail: [`${testRealmURL}Person/1.json`], - }); + try { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); - await click('[data-test-publish-realm-button]'); - assert.dom('[data-test-publish-realm-modal]').exists(); + await click('[data-test-publish-realm-button]'); + assert.dom('[data-test-publish-realm-modal]').exists(); - await click('[data-test-generate-unlisted-link-button]'); - await waitFor('[data-test-unlisted-link-url]'); - let unlistedUrl = generatedUnlistedUrl(); + await click('[data-test-generate-unlisted-link-button]'); + await waitFor('[data-test-unlisted-link-url]'); + let unlistedUrl = generatedUnlistedUrl(); - assert - .dom(`[data-test-domain-publish-error="${unlistedUrl}"]`) - .doesNotExist(); + assert + .dom(`[data-test-domain-publish-error="${unlistedUrl}"]`) + .doesNotExist(); - await click('[data-test-publish-button]'); + await click('[data-test-publish-button]'); - publishError.reject( - new Error('Network error: Failed to publish realm'), - ); + publishError.reject( + new Error('Network error: Failed to publish realm'), + ); - await waitFor(`[data-test-domain-publish-error="${unlistedUrl}"]`); + await waitFor(`[data-test-domain-publish-error="${unlistedUrl}"]`); - // Error should appear inline on the domain option - assert - .dom(`[data-test-domain-publish-error="${unlistedUrl}"]`) - .exists(); - assert - .dom(`[data-test-domain-publish-error="${unlistedUrl}"] .error-text`) - .hasText('Network error: Failed to publish realm'); + // Error should appear inline on the domain option + assert + .dom(`[data-test-domain-publish-error="${unlistedUrl}"]`) + .exists(); + assert + .dom( + `[data-test-domain-publish-error="${unlistedUrl}"] .error-text`, + ) + .hasText('Network error: Failed to publish realm'); - // Verify the modal stays open when there's an error - assert.dom('[data-test-publish-realm-modal]').exists(); + // Verify the modal stays open when there's an error + assert.dom('[data-test-publish-realm-modal]').exists(); + } finally { + restoreClaim(); + } }); test('can publish a claimed custom domain', async function (assert) { From 70ef72f7b465ebfb5b3ec798697ec6f98687a9eb Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 18 Jun 2026 11:46:59 -0500 Subject: [PATCH 06/11] Restore "Your Boxel Space" otpion --- .../operator-mode/publish-realm-modal.gts | 544 +++++++++++------- .../tests/acceptance/host-submode-test.gts | 493 ++++++++++------ packages/matrix/tests/head-tags.spec.ts | 28 +- packages/matrix/tests/publish-realm.spec.ts | 89 ++- .../tests/resolve-published-realm-url-test.ts | 50 +- .../runtime-common/published-realm-url.ts | 58 +- 6 files changed, 732 insertions(+), 530 deletions(-) diff --git a/packages/host/app/components/operator-mode/publish-realm-modal.gts b/packages/host/app/components/operator-mode/publish-realm-modal.gts index 21ab16d86d..b8422a10dd 100644 --- a/packages/host/app/components/operator-mode/publish-realm-modal.gts +++ b/packages/host/app/components/operator-mode/publish-realm-modal.gts @@ -25,11 +25,11 @@ import { not } from '@cardstack/boxel-ui/helpers'; import { IconX, Warning as WarningIcon } from '@cardstack/boxel-ui/icons'; import { + deriveRealmName, ensureTrailingSlash, - generateObscureSubdomain, + generateObscureSlug, isCardErrorJSONAPI, isCardInstance, - isGeneratedSubdomain, resolvePublishedRealmUrl, } from '@cardstack/runtime-common'; import { getPublishedRealmDomainOverrides } from '@cardstack/runtime-common/constants'; @@ -44,6 +44,7 @@ import config from '@cardstack/host/config/environment'; import type CommandService from '@cardstack/host/services/command-service'; import type HostModeService from '@cardstack/host/services/host-mode-service'; import type LoaderService from '@cardstack/host/services/loader-service'; +import type MatrixService from '@cardstack/host/services/matrix-service'; import type RealmService from '@cardstack/host/services/realm'; import type { @@ -82,6 +83,7 @@ interface Signature { export default class PublishRealmModal extends Component { @service declare private hostModeService: HostModeService; @service declare private loaderService: LoaderService; + @service declare private matrixService: MatrixService; @service declare private realm: RealmService; @service declare private realmServer: RealmServerService; @service declare private commandService: CommandService; @@ -100,7 +102,11 @@ export default class PublishRealmModal extends Component { @tracked private customSubdomainError: string | null = null; @tracked private isCheckingCustomSubdomain = false; @tracked private claimedDomain: ClaimedDomain | null = null; - @tracked private unlistedLinkError: string | null = null; + // Random path segment for the "Unlisted Link" target + // (`.//`). Seeded with a fresh + // slug so the URL is always well-formed; replaced by a prior publish's slug + // during init when one exists. + @tracked private unlistedPathSegment: string = generateObscureSlug(); @tracked private privateDependencyCheckError: string | null = null; @tracked private privateDependencyViolations: @@ -132,6 +138,10 @@ export default class PublishRealmModal extends Component { return this.#cardAPI; } + get isSubdirectoryRealmPublished() { + return this.hostModeService.isPublished(this.subdirectoryRealmUrl); + } + get isPublishDisabled() { return ( !this.hasSelectedPublishedRealmURLs || @@ -178,6 +188,10 @@ export default class PublishRealmModal extends Component { ); }; + get lastPublishedTime() { + return this.getFormattedLastPublishedTime(this.subdirectoryRealmUrl); + } + get claimedDomainPublishedUrl() { if (!this.claimedDomain) { return null; @@ -206,39 +220,6 @@ export default class PublishRealmModal extends Component { return !!this.claimedDomain && !this.isClaimedDomainPublished; } - // A realm holds a single claimed boxel.site domain. We tell the auto-generated - // "unlisted link" apart from a user-chosen "custom site name" by the shape of - // the subdomain, so each can live in its own card while sharing the one claim. - get claimedDomainIsGenerated() { - return ( - !!this.claimedDomain && isGeneratedSubdomain(this.claimedDomain.subdomain) - ); - } - - get claimedDomainIsCustom() { - return !!this.claimedDomain && !this.claimedDomainIsGenerated; - } - - get unlistedLinkSubdomain() { - return this.claimedDomain?.subdomain ?? ''; - } - - get isUnlistedLinkSelected() { - return this.claimedDomainIsGenerated && this.isCustomSubdomainSelected; - } - - get isCustomNameSelected() { - return this.claimedDomainIsCustom && this.isCustomSubdomainSelected; - } - - get isCustomNamePublished() { - return this.claimedDomainIsCustom && this.isClaimedDomainPublished; - } - - get isGeneratingUnlistedLink() { - return this.generateUnlistedLinkTask.isRunning; - } - get isUnclaimDomainButtonDisabled() { return ( this.handleUnclaimCustomSubdomainTask.isRunning || @@ -266,6 +247,48 @@ export default class PublishRealmModal extends Component { } } + get isSubdirectoryRealmSelected() { + return this.selectedPublishedRealmURLs.includes(this.subdirectoryRealmUrl); + } + + // The "Unlisted Link" target: the user's own space subdomain with a random + // path segment instead of the realm name, e.g. + // `https://.//`. Like the Boxel Space target + // it is namespaced to the owner, so it needs no claim/availability check. + get unlistedRealmUrl(): string { + return resolvePublishedRealmUrl( + { type: 'subdirectory', name: this.unlistedPathSegment }, + { + protocol: this.getProtocol(), + matrixUsername: this.getMatrixUsername(), + spaceDomain: this.getDefaultPublishedRealmDomain(), + }, + ); + } + + get unlistedRealmParts() { + return { + baseUrl: `${this.getProtocol()}://${this.getMatrixUsername()}.${this.getDefaultPublishedRealmDomain()}/`, + pathSegment: this.unlistedPathSegment, + }; + } + + get isUnlistedRealmSelected() { + return this.selectedPublishedRealmURLs.includes(this.unlistedRealmUrl); + } + + get isUnlistedRealmPublished() { + return this.hostModeService.isPublished(this.unlistedRealmUrl); + } + + get unlistedLastPublishedTime() { + return this.getFormattedLastPublishedTime(this.unlistedRealmUrl); + } + + get publishErrorForUnlistedLink() { + return this.getPublishErrorForUrl(this.unlistedRealmUrl); + } + get isCustomSubdomainSelected() { if (!this.claimedDomainPublishedUrl) { return false; @@ -343,7 +366,7 @@ export default class PublishRealmModal extends Component { } get customSubdomainDisplay() { - if (this.claimedDomainIsCustom && this.claimedDomain) { + if (this.claimedDomain) { return this.claimedDomain.subdomain; } @@ -377,6 +400,29 @@ export default class PublishRealmModal extends Component { return this.hostModeService.realmURL; } + get subdirectoryRealmUrl() { + return resolvePublishedRealmUrl( + { type: 'subdirectory', name: this.getRealmName() }, + { + protocol: this.getProtocol(), + matrixUsername: this.getMatrixUsername(), + spaceDomain: this.getDefaultPublishedRealmDomain(), + }, + ); + } + + get subdirectoryRealmParts() { + const protocol = this.getProtocol(); + const matrixUsername = this.getMatrixUsername(); + const domain = this.getDefaultPublishedRealmDomain(); + const realmName = this.getRealmName(); + + return { + baseUrl: `${protocol}://${matrixUsername}.${domain}/`, + realmName: realmName, + }; + } + private getProtocol(): string { // The local dev stack speaks HTTPS+HTTP/2 across the board now (the // realm-server reads the mkcert leaf via REALM_SERVER_TLS_CERT_FILE @@ -386,6 +432,23 @@ export default class PublishRealmModal extends Component { return 'https'; } + private getMatrixUsername(): string { + const userName = this.matrixService.userName; + if (!userName) { + throw new Error('Matrix username is not available'); + } + return userName; + } + + private getDefaultPublishedRealmDomain(): string { + // publishedRealmBoxelSpaceDomain is the domain that is used to form urls like "mike.boxel.space/game-mechanics" + // which are used to create Boxel Spaces (we will also have Boxel Sites, which is a different published realm) + + // TODO: since we currently only have Boxel Spaces, we can default to that domain. When we add Boxel Sites, + // adjust this component to know which published realm domain to use. + return config.publishedRealmBoxelSpaceDomain; + } + private buildPublishedRealmUrl(hostname: string): string { return resolvePublishedRealmUrl( { type: 'custom', name: hostname }, @@ -520,56 +583,6 @@ export default class PublishRealmModal extends Component { } }); - // Generates an unguessable subdomain and claims it through the same - // availability + claim path as a custom site name, so the realm's single - // boxel.site claim ends up holding a hard-to-guess host. Retries on the - // (astronomically unlikely) chance a generated name is already taken. - private generateUnlistedLinkTask = restartableTask(async () => { - this.unlistedLinkError = null; - let baseDomain = this.customSubdomainBase; - try { - let command = new CheckDomainAvailabilityCommand( - this.commandService.commandContext, - ); - for (let attempt = 0; attempt < 5; attempt++) { - let subdomain = generateObscureSubdomain(); - let result = await command.execute({ type: 'custom', name: subdomain }); - if (!result.available) { - continue; - } - let hostname = `${subdomain}.${baseDomain}`; - let claimResult = (await this.realmServer.claimBoxelDomain( - this.currentRealmURL, - hostname, - )) as { - data: { - id: string; - attributes: { - subdomain: string; - hostname: string; - sourceRealmURL: string; - }; - }; - }; - this.applyClaimedDomain( - { - id: claimResult.data.id, - subdomain: claimResult.data.attributes.subdomain, - hostname: claimResult.data.attributes.hostname, - sourceRealmURL: claimResult.data.attributes.sourceRealmURL, - }, - { select: true }, - ); - return; - } - this.unlistedLinkError = - 'Could not generate an available link. Please try again.'; - } catch (error) { - this.unlistedLinkError = - error instanceof Error ? error.message : 'Failed to generate link'; - } - }); - private handleUnclaimCustomSubdomainTask = restartableTask(async () => { if (!this.claimedDomain) { return; @@ -594,6 +607,81 @@ export default class PublishRealmModal extends Component { this.customSubdomainSelection = selection; } + private getRealmName(): string { + const realmUrl = this.currentRealmURL; + if (!realmUrl) { + throw new Error('Current realm URL is not available'); + } + return deriveRealmName(realmUrl); + } + + @action + toggleDefaultDomain(event: Event) { + const defaultUrl = this.subdirectoryRealmUrl; + const input = event.target as HTMLInputElement; + if (input.checked) { + this.addPublishedRealmUrl(defaultUrl); + } else { + this.removePublishedRealmUrl(defaultUrl); + } + } + + @action + toggleUnlistedDomain(event: Event) { + const input = event.target as HTMLInputElement; + if (input.checked) { + this.addPublishedRealmUrl(this.unlistedRealmUrl); + } else { + this.removePublishedRealmUrl(this.unlistedRealmUrl); + } + } + + // Roll a fresh unlisted link. Only meaningful before it is published — once + // published the URL is fixed and recovered on reopen. + @action + regenerateUnlistedLink() { + if (this.isUnlistedRealmPublished) { + return; + } + const wasSelected = this.isUnlistedRealmSelected; + this.removePublishedRealmUrl(this.unlistedRealmUrl); + this.unlistedPathSegment = generateObscureSlug(); + if (wasSelected) { + this.addPublishedRealmUrl(this.unlistedRealmUrl); + } + } + + // If the realm already has a published unlisted link (a space-subdomain URL + // whose path segment isn't the realm name), reuse its slug so reopening the + // modal shows the same link instead of the freshly-seeded one. + private recoverPublishedUnlistedPath() { + const recovered = this.findPublishedUnlistedPathSegment(); + if (recovered) { + this.unlistedPathSegment = recovered; + } + } + + private findPublishedUnlistedPathSegment(): string | null { + const spaceBaseUrl = this.subdirectoryRealmParts.baseUrl; + const boxelSpaceUrl = this.subdirectoryRealmUrl; + for (const publishedUrl of this.hostModeService.publishedRealmURLs) { + if (publishedUrl === boxelSpaceUrl) { + continue; + } + if (!publishedUrl.startsWith(spaceBaseUrl)) { + continue; + } + const segments = publishedUrl + .slice(spaceBaseUrl.length) + .split('/') + .filter(Boolean); + if (segments.length === 1) { + return segments[0]; + } + } + return null; + } + @action toggleCustomSubdomain(event: Event) { if (this.claimedDomain) { @@ -786,14 +874,7 @@ export default class PublishRealmModal extends Component { }; get publishErrorForCustomSubdomain() { - if (!this.claimedDomainIsCustom || !this.claimedDomainPublishedUrl) { - return null; - } - return this.getPublishErrorForUrl(this.claimedDomainPublishedUrl); - } - - get publishErrorForUnlistedLink() { - if (!this.claimedDomainIsGenerated || !this.claimedDomainPublishedUrl) { + if (!this.claimedDomainPublishedUrl) { return null; } return this.getPublishErrorForUrl(this.claimedDomainPublishedUrl); @@ -809,6 +890,7 @@ export default class PublishRealmModal extends Component { ensureInitialSelectionsTask = restartableTask( async (claim: ClaimedDomain | null = null) => { await this.realm.ensureRealmMeta(this.currentRealmURL); + this.recoverPublishedUnlistedPath(); this.applyInitialSelections(claim); }, ); @@ -952,146 +1034,176 @@ export default class PublishRealmModal extends Component { {{/if}}
+
+ + + +
+ + + +
+ + {{this.subdirectoryRealmParts.baseUrl}}{{this.subdirectoryRealmParts.realmName}}/ + + {{#if this.isSubdirectoryRealmPublished}} +
+ Published + {{this.lastPublishedTime}} + + {{#if + (this.isUnpublishingRealm this.subdirectoryRealmUrl) + }} + + Unpublishing… + {{else}} + + Unpublish + {{/if}} + + +
+ {{/if}} +
+
+ {{#if this.isSubdirectoryRealmPublished}} + + + Open Site + + {{/if}} + {{#if (this.getPublishErrorForUrl this.subdirectoryRealmUrl)}} +
+ {{this.getPublishErrorForUrl + this.subdirectoryRealmUrl + }} +
+ {{/if}} +
+
-
- {{#if this.claimedDomainIsGenerated}} - - - -
- - {{this.getProtocol}}://{{this.unlistedLinkSubdomain}}.{{this.customSubdomainBase}}/ - +
+ + + +
+ + {{this.unlistedRealmParts.baseUrl}}{{this.unlistedRealmParts.pathSegment}}/ + + {{#if this.isUnlistedRealmPublished}}
- {{#if this.claimedDomainLastPublishedTime}} - - Published - {{this.claimedDomainLastPublishedTime}} - {{#if this.claimedDomainPublishedUrl}} - - {{#if - (this.isUnpublishingRealm - this.claimedDomainPublishedUrl - ) - }} - - Unpublishing… - {{else}} - - Unpublish - {{/if}} - + Published + {{this.unlistedLastPublishedTime}} + + {{#if (this.isUnpublishingRealm this.unlistedRealmUrl)}} + + Unpublishing… + {{else}} + + Unpublish {{/if}} - {{else}} - Not published yet - {{/if}} - {{#if this.shouldShowUnclaimDomainButton}} - - {{#if this.handleUnclaimCustomSubdomainTask.isRunning}} - - Removing… - {{else}} - Remove link - {{/if}} - - {{/if}} +
-
- {{else}} - - {{/if}} + {{/if}} +
- {{#if this.claimedDomainIsGenerated}} - {{#if this.isClaimedDomainPublished}} - - - Open Site - - {{/if}} - {{else if (not this.claimedDomain)}} + {{#if this.isUnlistedRealmPublished}} - {{#if this.isGeneratingUnlistedLink}} - - Generating… - {{else}} - Generate link - {{/if}} + + Open Site + + {{else}} + + New link - {{/if}} - {{#if this.unlistedLinkError}} -
- {{this.unlistedLinkError}} -
{{/if}} {{#if this.publishErrorForUnlistedLink}}
{ type='checkbox' id='custom-subdomain-checkbox' class='domain-checkbox' - checked={{this.isCustomNameSelected}} + checked={{this.isCustomSubdomainSelected}} data-test-custom-subdomain-checkbox - disabled={{not this.claimedDomainIsCustom}} + disabled={{not this.claimedDomain}} {{on 'change' this.toggleCustomSubdomain}} />
- {{else if this.claimedDomainIsCustom}} + {{else if this.claimedDomain}} @@ -1187,7 +1299,7 @@ export default class PublishRealmModal extends Component { class='url-part' >.{{this.customSubdomainBase}}/ - {{#if this.claimedDomainIsCustom}} + {{#if this.claimedDomain}}
{{#if this.claimedDomainLastPublishedTime}} Published @@ -1265,7 +1377,7 @@ export default class PublishRealmModal extends Component {
{{#if (not this.isCustomSubdomainSetupVisible)}} - {{#if this.isCustomNamePublished}} + {{#if this.isClaimedDomainPublished}} void { - let realmServer = getService('realm-server') as any; - let originalCheck = realmServer.checkDomainAvailability; - let originalClaim = realmServer.claimBoxelDomain; - - realmServer.checkDomainAvailability = async (subdomain: string) => ({ - available: true, - hostname: `${subdomain}.${publishedSpaceHost}`, - }); - realmServer.claimBoxelDomain = async ( - _sourceRealmURL: string, - hostname: string, - ) => ({ - data: { - type: 'claimed-domain', - id: '1', - attributes: { - hostname, - subdomain: hostname.replace(`.${publishedSpaceHost}`, ''), - sourceRealmURL, - }, - }, - }); - - return () => { - realmServer.checkDomainAvailability = originalCheck; - realmServer.claimBoxelDomain = originalClaim; - }; -} - -// The URL shown in the unlisted-link card after generating, e.g. -// "https://./". The subdomain is random per run. -function generatedUnlistedUrl(): string { +// The URL shown in the unlisted-link card, e.g. +// "https://testuser.//". The path segment is random per run. +function unlistedLinkUrlFromDom(): string { let el = document.querySelector('[data-test-unlisted-link-url]'); return (el?.textContent ?? '').replace(/\s+/g, ''); } @@ -719,115 +682,94 @@ module('Acceptance | host submode', function (hooks) { getService('realm-server').unpublishRealm = unpublishRealm; }); - test('can publish an unlisted link', async function (assert) { - let restoreClaim = stubUnlistedLinkClaim(testRealmURL); - try { - await visitOperatorMode({ - submode: 'host', - trail: [`${testRealmURL}Person/1.json`], - }); - - assert - .dom('[data-test-publish-realm-button]') - .hasText('Publish Site'); - await click('[data-test-publish-realm-button]'); - assert.dom('[data-test-publish-realm-modal]').exists(); + test('can publish realm', async function (assert) { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); - assert.dom('[data-test-last-published-at]').doesNotExist(); - assert.dom('[data-test-unpublish-button]').doesNotExist(); - assert.dom('[data-test-open-site-button]').doesNotExist(); - assert.dom('[data-test-unlisted-link-url]').doesNotExist(); - assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); - assert.dom('[data-test-publish-button]').isDisabled(); + assert.dom('[data-test-publish-realm-button]').hasText('Publish Site'); + await click('[data-test-publish-realm-button]'); + assert.dom('[data-test-publish-realm-modal]').exists(); - // Generating an unlisted link claims a random subdomain and selects it. - await click('[data-test-generate-unlisted-link-button]'); - await waitFor('[data-test-unlisted-link-url]'); - assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); - assert.dom('[data-test-publish-button]').isNotDisabled(); + assert.dom('[data-test-last-published-at]').doesNotExist(); + assert.dom('[data-test-unpublish-button]').doesNotExist(); + assert.dom('[data-test-open-site-button]').doesNotExist(); + assert.dom('[data-test-default-domain-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); - let unlistedUrl = generatedUnlistedUrl(); - assert.ok( - unlistedUrl.startsWith('https://'), - `generated an https URL (${unlistedUrl})`, - ); - assert.ok( - unlistedUrl.endsWith(`.${publishedSpaceHost}/`), - `generated URL is on the published host (${unlistedUrl})`, - ); + await click('[data-test-default-domain-checkbox]'); + assert.dom('[data-test-default-domain-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); - await click('[data-test-publish-button]'); - assert.dom('[data-test-publish-button]').hasText('Publishing…'); - assert.dom('[data-test-publish-button]').hasAttribute('disabled'); + await click('[data-test-publish-button]'); + assert.dom('[data-test-publish-button]').hasText('Publishing…'); + assert.dom('[data-test-publish-button]').hasAttribute('disabled'); - await waitFor('[data-test-publish-realm-button].publishing'); - assert.dom('[data-test-publish-realm-button]').hasText('Publishing…'); - assert.dom('[data-test-publish-realm-button]').hasClass('publishing'); + await waitFor('[data-test-publish-realm-button].publishing'); + assert.dom('[data-test-publish-realm-button]').hasText('Publishing…'); + assert.dom('[data-test-publish-realm-button]').hasClass('publishing'); - await click('[data-test-publish-realm-button]'); - await waitFor('.publishing-realm-popover'); - assert.dom('.publishing-realm-popover').exists(); - assert - .dom('.publishing-realm-popover') - .containsText(`Publishing to: ${unlistedUrl}`); - assert.dom('.loading-icon').exists(); + await click('[data-test-publish-realm-button]'); + await waitFor('.publishing-realm-popover'); + assert.dom('.publishing-realm-popover').exists(); + assert + .dom('.publishing-realm-popover') + .containsText( + `Publishing to: https://testuser.${publishedSpaceHost}/test/`, + ); + assert.dom('.publishing-realm-popover').exists(); + assert.dom('.loading-icon').exists(); - publishDeferred.fulfill(); + publishDeferred.fulfill(); - await waitUntil(() => { - return !document.querySelector( - '[data-test-publish-realm-button].publishing', - ); - }); + await waitUntil(() => { + return !document.querySelector( + '[data-test-publish-realm-button].publishing', + ); + }); - assert - .dom('[data-test-publish-realm-button]') - .hasText('Republish Site'); - assert - .dom('[data-test-publish-realm-button]') - .doesNotHaveClass('publishing'); - assert.dom('.publishing-realm-popover').doesNotExist(); + assert + .dom('[data-test-publish-realm-button]') + .hasText('Republish Site'); + assert + .dom('[data-test-publish-realm-button]') + .doesNotHaveClass('publishing'); + assert.dom('.publishing-realm-popover').doesNotExist(); - await click('[data-test-publish-realm-button]'); + await click('[data-test-publish-realm-button]'); - assert.dom('[data-test-last-published-at]').exists(); - assert.dom('[data-test-last-published-at]').containsText('Published'); - assert.dom('[data-test-unpublish-button]').exists(); - assert.dom('[data-test-unpublish-button]').containsText('Unpublish'); - assert.dom('[data-test-open-site-button]').exists(); - assert.dom('[data-test-open-site-button]').containsText('Open Site'); + assert.dom('[data-test-last-published-at]').exists(); + assert.dom('[data-test-last-published-at]').containsText('Published'); + assert.dom('[data-test-unpublish-button]').exists(); + assert.dom('[data-test-unpublish-button]').containsText('Unpublish'); + assert.dom('[data-test-open-site-button]').exists(); + assert.dom('[data-test-open-site-button]').containsText('Open Site'); - assert - .dom( - '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', - ) - .hasAttribute('href', unlistedUrl) - .hasAttribute('target', '_blank'); - } finally { - restoreClaim(); - } + assert + .dom( + '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', + ) + .hasAttribute('href', `https://testuser.${publishedSpaceHost}/test/`) + .hasAttribute('target', '_blank'); }); - test('preselects a previously published unlisted link on refresh', async function (assert) { + test('preselects previously published domains on refresh', async function (assert) { let now = Date.now(); let realmServer = getService('realm-server') as any; let originalFetchClaimed = realmServer.fetchBoxelClaimedDomain; - // A 16-character subdomain from the generated alphabet is recognized as - // an unlisted link (see isGeneratedSubdomain). - let generatedSubdomain = 'k7f3qz9pbcdmnpqr'; - let generatedHost = `${generatedSubdomain}.${publishedSpaceHost}`; - realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', - hostname: generatedHost, - subdomain: generatedSubdomain, + hostname: `custom-site-name.${publishedSpaceHost}`, + subdomain: 'custom-site-name', sourceRealmURL: testRealmURL, }); let restoreRealmInfo = withUpdatedTestRealmInfo({ lastPublishedAt: { - [`https://${generatedHost}/`]: String(now), + [`https://testuser.${publishedSpaceHost}/test/`]: String(now), + [`https://custom-site-name.${publishedSpaceHost}/`]: String(now), }, }); @@ -840,10 +782,8 @@ module('Acceptance | host submode', function (hooks) { await click('[data-test-publish-realm-button]'); await waitFor('[data-test-publish-realm-modal]'); - assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); - assert - .dom('[data-test-unlisted-link-url]') - .hasText(`https://${generatedHost}/`); + assert.dom('[data-test-default-domain-checkbox]').isChecked(); + assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); assert.dom('[data-test-publish-button]').isNotDisabled(); } finally { realmServer.fetchBoxelClaimedDomain = originalFetchClaimed; @@ -851,8 +791,126 @@ module('Acceptance | host submode', function (hooks) { } }); + test('default domain checkbox can be checked and unchecked', async function (assert) { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); + + await click('[data-test-publish-realm-button]'); + + assert.dom('[data-test-default-domain-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); + + await click('[data-test-default-domain-checkbox]'); + assert.dom('[data-test-default-domain-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); + + await click('[data-test-default-domain-checkbox]'); + assert.dom('[data-test-default-domain-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); + }); + + test('can publish an unlisted link', async function (assert) { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); + + await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-publish-realm-modal]'); + await waitFor('[data-test-unlisted-link-url]'); + + // The unlisted link is the user's own space subdomain with a random + // path segment instead of the realm name. + let unlistedUrl = unlistedLinkUrlFromDom(); + let spacePrefix = `https://testuser.${publishedSpaceHost}/`; + assert.ok( + unlistedUrl.startsWith(spacePrefix), + `unlisted link is on the user's space (${unlistedUrl})`, + ); + assert.ok(unlistedUrl.endsWith('/'), 'unlisted link is a realm root'); + let slug = unlistedUrl.slice(spacePrefix.length, -1); + assert.ok(/^[a-z0-9]+$/.test(slug), `random path segment (${slug})`); + assert.notStrictEqual(slug, 'test', 'path is not the realm name'); + + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); + + await click('[data-test-publish-button]'); + publishDeferred.fulfill(); + await waitUntil(() => { + return !document.querySelector( + '[data-test-publish-realm-button].publishing', + ); + }); + + await click('[data-test-publish-realm-button]'); + assert + .dom( + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + ) + .hasAttribute('href', unlistedUrl) + .hasAttribute('target', '_blank'); + }); + test('unlisted link checkbox can be checked and unchecked', async function (assert) { - let restoreClaim = stubUnlistedLinkClaim(testRealmURL); + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); + + await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-unlisted-link-url]'); + + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); + + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); + + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); + }); + + test('can regenerate the unlisted link before publishing', async function (assert) { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); + + await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-unlisted-link-url]'); + + let firstUrl = unlistedLinkUrlFromDom(); + await click('[data-test-regenerate-unlisted-link-button]'); + let secondUrl = unlistedLinkUrlFromDom(); + + assert.notStrictEqual( + secondUrl, + firstUrl, + 'regenerating produces a different link', + ); + assert.ok( + secondUrl.startsWith(`https://testuser.${publishedSpaceHost}/`), + `regenerated link is still on the user's space (${secondUrl})`, + ); + }); + + test('preselects a previously published unlisted link on refresh', async function (assert) { + let now = Date.now(); + let unlistedUrl = `https://testuser.${publishedSpaceHost}/k7f3qz9pbcdmnpqr/`; + + let restoreRealmInfo = withUpdatedTestRealmInfo({ + lastPublishedAt: { + [unlistedUrl]: String(now), + }, + }); + try { await visitOperatorMode({ submode: 'host', @@ -860,46 +918,20 @@ module('Acceptance | host submode', function (hooks) { }); await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-publish-realm-modal]'); - // Before generating, the checkbox is disabled and nothing is selected. - assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); - assert.dom('[data-test-unlisted-link-checkbox]').isDisabled(); - assert.dom('[data-test-publish-button]').isDisabled(); - - await click('[data-test-generate-unlisted-link-button]'); - await waitFor('[data-test-unlisted-link-url]'); - assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); - assert.dom('[data-test-publish-button]').isNotDisabled(); - - await click('[data-test-unlisted-link-checkbox]'); - assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); - assert.dom('[data-test-publish-button]').isDisabled(); - - await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-url]').hasText(unlistedUrl); assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); assert.dom('[data-test-publish-button]').isNotDisabled(); } finally { - restoreClaim(); + restoreRealmInfo(); } }); - test('can unpublish an unlisted link', async function (assert) { - let realmServer = getService('realm-server') as any; - let originalFetchClaimed = realmServer.fetchBoxelClaimedDomain; - - let generatedSubdomain = 'k7f3qz9pbcdmnpqr'; - let generatedHost = `${generatedSubdomain}.${publishedSpaceHost}`; - - realmServer.fetchBoxelClaimedDomain = async () => ({ - id: 'claimed-domain-1', - hostname: generatedHost, - subdomain: generatedSubdomain, - sourceRealmURL: testRealmURL, - }); - + test('can unpublish realm', async function (assert) { let restoreRealmInfo = withUpdatedTestRealmInfo({ lastPublishedAt: { - [`https://${generatedHost}/`]: ( + [`https://testuser.${publishedSpaceHost}/test/`]: ( new Date().getTime() - 3 * 24 * 60 * 60 * 1000 ).toString(), @@ -930,7 +962,6 @@ module('Acceptance | host submode', function (hooks) { assert.dom('[data-test-open-site-button]').doesNotExist(); } finally { restoreRealmInfo(); - realmServer.fetchBoxelClaimedDomain = originalFetchClaimed; } }); @@ -1271,7 +1302,7 @@ module('Acceptance | host submode', function (hooks) { await waitFor('[data-test-publish-realm-modal]'); assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); - assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-default-domain-checkbox]').isNotChecked(); assert.dom('[data-test-publish-button]').isNotDisabled(); } finally { realmServer.fetchBoxelClaimedDomain = originalFetchClaimed; @@ -1314,8 +1345,6 @@ module('Acceptance | host submode', function (hooks) { }); test('shows inline error when publishing to a domain fails', async function (assert) { - let restoreClaim = stubUnlistedLinkClaim(testRealmURL); - let publishError = new Deferred(); let publishRealm = async () => { @@ -1325,6 +1354,71 @@ module('Acceptance | host submode', function (hooks) { getService('realm-server').publishRealm = publishRealm; + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); + + await click('[data-test-publish-realm-button]'); + assert.dom('[data-test-publish-realm-modal]').exists(); + + let defaultUrl = `https://testuser.${publishedSpaceHost}/test/`; + assert + .dom(`[data-test-domain-publish-error="${defaultUrl}"]`) + .doesNotExist(); + + await click('[data-test-default-domain-checkbox]'); + await click('[data-test-publish-button]'); + + publishError.reject( + new Error('Network error: Failed to publish realm'), + ); + + await waitFor(`[data-test-domain-publish-error="${defaultUrl}"]`); + + // Error should appear inline on the domain option + assert.dom(`[data-test-domain-publish-error="${defaultUrl}"]`).exists(); + assert + .dom(`[data-test-domain-publish-error="${defaultUrl}"] .error-text`) + .hasText('Network error: Failed to publish realm'); + + // Verify the modal stays open when there's an error + assert.dom('[data-test-publish-realm-modal]').exists(); + }); + + test('shows inline error for failed domain while allowing successful ones', async function (assert) { + let realmServer = getService('realm-server') as any; + let originalFetchClaimed = realmServer.fetchBoxelClaimedDomain; + let originalPublishRealm = realmServer.publishRealm; + + realmServer.fetchBoxelClaimedDomain = async () => ({ + id: 'claimed-domain-1', + hostname: `my-custom-site.${publishedSpaceHost}`, + subdomain: 'my-custom-site', + sourceRealmURL: testRealmURL, + }); + + let defaultUrl = `https://testuser.${publishedSpaceHost}/test/`; + let customUrl = `https://my-custom-site.${publishedSpaceHost}/`; + + // Mock publish to succeed for default, fail for custom + realmServer.publishRealm = async ( + _sourceURL: string, + publishedURL: string, + ) => { + await publishDeferred.promise; + if (publishedURL === customUrl) { + throw new Error('Custom domain validation failed'); + } + return { + sourceRealmURL: _sourceURL, + publishedRealmURL: publishedURL, + publishedRealmId: '1', + lastPublishedAt: String(new Date().getTime()), + status: 'published', + }; + }; + try { await visitOperatorMode({ submode: 'host', @@ -1332,42 +1426,51 @@ module('Acceptance | host submode', function (hooks) { }); await click('[data-test-publish-realm-button]'); - assert.dom('[data-test-publish-realm-modal]').exists(); + await waitFor('[data-test-publish-realm-modal]'); - await click('[data-test-generate-unlisted-link-button]'); - await waitFor('[data-test-unlisted-link-url]'); - let unlistedUrl = generatedUnlistedUrl(); + assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); + assert.dom('[data-test-default-domain-checkbox]').isNotChecked(); - assert - .dom(`[data-test-domain-publish-error="${unlistedUrl}"]`) - .doesNotExist(); + // Check default checkbox (custom already selected) + await click('[data-test-default-domain-checkbox]'); await click('[data-test-publish-button]'); + publishDeferred.fulfill(); - publishError.reject( - new Error('Network error: Failed to publish realm'), - ); + await waitUntil(() => { + return !document.querySelector( + '[data-test-publish-realm-button].publishing', + ); + }); - await waitFor(`[data-test-domain-publish-error="${unlistedUrl}"]`); + await click('[data-test-publish-realm-button]'); - // Error should appear inline on the domain option - assert - .dom(`[data-test-domain-publish-error="${unlistedUrl}"]`) - .exists(); + // Default domain should show as published (success) assert .dom( - `[data-test-domain-publish-error="${unlistedUrl}"] .error-text`, + '[data-test-publish-realm-modal] .domain-option:nth-of-type(1)', ) - .hasText('Network error: Failed to publish realm'); + .containsText('Published'); - // Verify the modal stays open when there's an error - assert.dom('[data-test-publish-realm-modal]').exists(); + // Custom domain should show error (failure) + assert + .dom(`[data-test-domain-publish-error="${customUrl}"]`) + .exists(); + assert + .dom(`[data-test-domain-publish-error="${customUrl}"] .error-text`) + .hasText('Custom domain validation failed'); + + // Default domain should NOT have error + assert + .dom(`[data-test-domain-publish-error="${defaultUrl}"]`) + .doesNotExist(); } finally { - restoreClaim(); + realmServer.fetchBoxelClaimedDomain = originalFetchClaimed; + realmServer.publishRealm = originalPublishRealm; } }); - test('can publish a claimed custom domain', async function (assert) { + test('can publish claimed domain', async function (assert) { let realmServer = getService('realm-server') as any; let originalFetchClaimed = realmServer.fetchBoxelClaimedDomain; @@ -1387,10 +1490,11 @@ module('Acceptance | host submode', function (hooks) { await click('[data-test-publish-realm-button]'); await waitFor('[data-test-publish-realm-modal]'); - // The claimed custom name is preselected; the unlisted link is not - // claimed, so its checkbox is disabled. + // Custom is preselected; add default checkbox + await click('[data-test-default-domain-checkbox]'); + + assert.dom('[data-test-default-domain-checkbox]').isChecked(); assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); - assert.dom('[data-test-unlisted-link-checkbox]').isDisabled(); await click('[data-test-publish-button]'); assert.dom('[data-test-publish-button]').hasText('Publishing…'); @@ -1405,15 +1509,26 @@ module('Acceptance | host submode', function (hooks) { await click('[data-test-publish-realm-button]'); - // The custom domain (second card) should show as published. + // Both domains should show as published + assert + .dom( + '[data-test-publish-realm-modal] .domain-option:nth-of-type(1)', + ) + .containsText('Published'); assert .dom( '[data-test-publish-realm-modal] .domain-option:nth-of-type(2)', ) .containsText('Published'); + + // Both should have unpublish buttons + assert.dom('[data-test-unpublish-button]').exists(); assert.dom('[data-test-unpublish-custom-subdomain-button]').exists(); + // Custom subdomain should have Open Site button await waitFor('[data-test-open-custom-subdomain-button]'); + assert.dom('[data-test-open-custom-subdomain-button]').exists(); + assert .dom('[data-test-open-custom-subdomain-button]') .hasAttribute( diff --git a/packages/matrix/tests/head-tags.spec.ts b/packages/matrix/tests/head-tags.spec.ts index 9de978fc5b..b5bb2358ee 100644 --- a/packages/matrix/tests/head-tags.spec.ts +++ b/packages/matrix/tests/head-tags.spec.ts @@ -55,12 +55,10 @@ test.describe('Head tags', () => { await page.locator('[data-test-publish-realm-button]').click(); } - // Publishes the realm to a generated unlisted link and returns the published - // URL (random per run, e.g. "https://.localhost:4205/"). - async function publishUnlistedLink( + async function publishDefaultRealm( page: Page, opts?: { realmName?: string; displayName?: string }, - ): Promise { + ) { let { realmName = 'new-workspace', displayName = '1New Workspace' } = opts ?? {}; await createUserAndRealm(page, { @@ -69,28 +67,25 @@ test.describe('Head tags', () => { displayName, }); await openPublishRealmModal(page, displayName); - await page.locator('[data-test-generate-unlisted-link-button]').click(); - await page.waitForSelector('[data-test-unlisted-link-url]'); - let publishedURL = ( - await page.locator('[data-test-unlisted-link-url]').innerText() - ).replace(/\s+/g, ''); + await page.locator('[data-test-default-domain-checkbox]').click(); await page.locator('[data-test-publish-button]').click(); await page.waitForSelector('[data-test-unpublish-button]'); await expect( page.locator( - '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', ), ).toBeVisible(); - return publishedURL; } test('the HTML response from a published realm has relevant meta tags', async ({ page, }) => { - let publishedURL = await publishUnlistedLink(page); + await publishDefaultRealm(page); + + let publishedRealmURLString = `https://${user.username}.localhost:4205/new-workspace/index`; - await page.goto(`${publishedURL}index`); + await page.goto(publishedRealmURLString); await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( 'content', @@ -272,14 +267,11 @@ test.describe('Head tags', () => { ); await openPublishRealmModal(page, displayName); - await page.locator('[data-test-generate-unlisted-link-button]').click(); - await page.waitForSelector('[data-test-unlisted-link-url]'); - let publishedRealmURL = ( - await page.locator('[data-test-unlisted-link-url]').innerText() - ).replace(/\s+/g, ''); + await page.locator('[data-test-default-domain-checkbox]').click(); await page.locator('[data-test-publish-button]').click(); await page.waitForSelector('[data-test-unpublish-button]'); + let publishedRealmURL = `https://${user.username}.localhost:4205/${realmName}/`; let defaultCardURL = `${publishedRealmURL}default-head-card.json`; // Publishing returns before the published realm finishes re-indexing diff --git a/packages/matrix/tests/publish-realm.spec.ts b/packages/matrix/tests/publish-realm.spec.ts index 3e17608161..2f29251f66 100644 --- a/packages/matrix/tests/publish-realm.spec.ts +++ b/packages/matrix/tests/publish-realm.spec.ts @@ -32,32 +32,65 @@ test.describe('Publish realm', () => { await page.locator('[data-test-publish-realm-button]').click(); } - // Generates + claims a random unlisted-link subdomain, publishes to it, and - // returns the published URL (random per run, e.g. - // "https://.localhost:4205/"). - async function publishUnlistedLink(page: Page): Promise { + async function publishDefaultRealm(page: Page) { await openPublishRealmModal(page); - await page.locator('[data-test-generate-unlisted-link-button]').click(); - await page.waitForSelector('[data-test-unlisted-link-url]'); - let publishedURL = ( - await page.locator('[data-test-unlisted-link-url]').innerText() - ).replace(/\s+/g, ''); + await page.locator('[data-test-default-domain-checkbox]').click(); await page.locator('[data-test-publish-button]').click(); await page.waitForSelector('[data-test-unpublish-button]'); await expect( page.locator( - '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', ), ).toBeVisible(); - return publishedURL; } - test('it can publish an unlisted link', async ({ page }) => { - let publishedURL = await publishUnlistedLink(page); + test('it can publish a realm to a subdirectory', async ({ page }) => { + await publishDefaultRealm(page); let newTabPromise = page.waitForEvent('popup'); + await page + .locator( + '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', + ) + .click(); + + let newTab = await newTabPromise; + await newTab.waitForLoadState(); + + await expect(newTab).toHaveURL( + `https://${user.username}.localhost:4205/new-workspace/`, + ); + await expect( + newTab.locator( + `[data-test-card="https://${user.username}.localhost:4205/new-workspace/index"]`, + ), + ).toBeVisible(); + await newTab.close(); + await page.bringToFront(); + }); + + test('it can publish an unlisted link', async ({ page }) => { + await openPublishRealmModal(page); + + await page.waitForSelector('[data-test-unlisted-link-url]'); + let publishedURL = ( + await page.locator('[data-test-unlisted-link-url]').innerText() + ).replace(/\s+/g, ''); + + // The unlisted link is the user's own space subdomain with a random path. + expect(publishedURL).toMatch( + new RegExp(`^https://${user.username}\\.localhost:4205/[a-z0-9]+/$`), + ); + + await page.locator('[data-test-unlisted-link-checkbox]').click(); + await page.locator('[data-test-publish-button]').click(); + await page.waitForSelector( + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + ); + + let newTabPromise = page.waitForEvent('popup'); await page .locator( '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', @@ -316,8 +349,7 @@ test.describe('Publish realm', () => { await page.locator('[data-test-submode-switcher] button').click(); await page.locator('[data-test-boxel-menu-item-text="Host"]').click(); await page.locator('[data-test-publish-realm-button]').click(); - await page.locator('[data-test-generate-unlisted-link-button]').click(); - await page.waitForSelector('[data-test-unlisted-link-url]'); + await page.locator('[data-test-default-domain-checkbox]').click(); await page.locator('[data-test-publish-button]').click(); await page.waitForSelector('[data-test-unpublish-button]'); @@ -325,7 +357,7 @@ test.describe('Publish realm', () => { let firstTabPromise = page.waitForEvent('popup'); await page .locator( - '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', ) .click(); let firstTab = await firstTabPromise; @@ -385,14 +417,13 @@ test.describe('Publish realm', () => { 'source index.json should contain the updated sentinel after postCardSource', ).toBeTruthy(); - // Re-open the publish modal and re-trigger publish. The existing - // unlisted-link claim loads asynchronously; wait for it, then ensure - // its checkbox is selected — it can lose its selection on modal close, - // and otherwise the publish button is disabled - // (`!hasSelectedPublishedRealmURLs`) and the click silently no-ops. + // Re-open the publish modal and re-trigger publish. The + // default-domain checkbox can lose its selection on modal close, + // so check its state and click only when needed — otherwise the + // publish button is disabled (`!hasSelectedPublishedRealmURLs`) + // and the click silently no-ops. await page.locator('[data-test-publish-realm-button]').click(); - await page.waitForSelector('[data-test-unlisted-link-url]'); - let domainCheckbox = page.locator('[data-test-unlisted-link-checkbox]'); + let domainCheckbox = page.locator('[data-test-default-domain-checkbox]'); if (!(await domainCheckbox.isChecked())) { await domainCheckbox.click(); } @@ -427,7 +458,7 @@ test.describe('Publish realm', () => { let secondTabPromise = page.waitForEvent('popup'); await page .locator( - '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', ) .click(); let secondTab = await secondTabPromise; @@ -447,7 +478,7 @@ test.describe('Publish realm', () => { }); test('open site popover opens with shift-click', async ({ page }) => { - let publishedURL = await publishUnlistedLink(page); + await publishDefaultRealm(page); let newTabPromise = page.waitForEvent('popup'); @@ -457,7 +488,9 @@ test.describe('Publish realm', () => { let newTab = await newTabPromise; await newTab.waitForLoadState(); - await expect(newTab).toHaveURL(publishedURL); + await expect(newTab).toHaveURL( + `https://${user.username}.localhost:4205/new-workspace/`, + ); await newTab.close(); await page.bringToFront(); @@ -485,7 +518,9 @@ test.describe('Publish realm', () => { newTab = await newTabPromise; await newTab.waitForLoadState(); - await expect(newTab).toHaveURL(publishedURL); + await expect(newTab).toHaveURL( + `https://${user.username}.localhost:4205/new-workspace/`, + ); await newTab.close(); await page.bringToFront(); }); diff --git a/packages/realm-server/tests/resolve-published-realm-url-test.ts b/packages/realm-server/tests/resolve-published-realm-url-test.ts index ec51e908fd..41891c8f94 100644 --- a/packages/realm-server/tests/resolve-published-realm-url-test.ts +++ b/packages/realm-server/tests/resolve-published-realm-url-test.ts @@ -2,12 +2,10 @@ import { module, test } from 'qunit'; import { basename } from 'path'; import { deriveRealmName, - generateObscureSubdomain, - isGeneratedSubdomain, - OBSCURE_SUBDOMAIN_LENGTH, + generateObscureSlug, + OBSCURE_SLUG_LENGTH, resolvePublishedRealmUrl, } from '@cardstack/runtime-common'; -import { validateSubdomain } from '../lib/user-subdomain-validation.ts'; module(basename(__filename), function () { module('resolve-published-realm-url', function () { @@ -217,53 +215,27 @@ module(basename(__filename), function () { ); }); - // generateObscureSubdomain / isGeneratedSubdomain - test('generateObscureSubdomain produces a fixed-length subdomain-safe string', async function (assert) { + // generateObscureSlug + test('generateObscureSlug produces a fixed-length URL-path-safe string', async function (assert) { for (let i = 0; i < 50; i++) { - let subdomain = generateObscureSubdomain(); + let slug = generateObscureSlug(); assert.strictEqual( - subdomain.length, - OBSCURE_SUBDOMAIN_LENGTH, + slug.length, + OBSCURE_SLUG_LENGTH, 'has the expected length', ); assert.ok( - /^[a-z][a-z0-9]*$/.test(subdomain), - `"${subdomain}" starts with a letter and is lowercase alphanumeric`, + /^[a-z0-9]+$/.test(slug), + `"${slug}" is lowercase alphanumeric (safe as a URL path segment)`, ); } }); - test('generated subdomains pass realm-server subdomain validation', async function (assert) { - for (let i = 0; i < 50; i++) { - let subdomain = generateObscureSubdomain(); - let result = validateSubdomain(subdomain); - assert.true( - result.valid, - `"${subdomain}" is accepted (${result.error ?? 'no error'})`, - ); - } - }); - - test('generateObscureSubdomain is not deterministic', async function (assert) { + test('generateObscureSlug is not deterministic', async function (assert) { let values = new Set( - Array.from({ length: 20 }, () => generateObscureSubdomain()), + Array.from({ length: 20 }, () => generateObscureSlug()), ); assert.strictEqual(values.size, 20, 'all generated values are distinct'); }); - - test('isGeneratedSubdomain recognizes its own output', async function (assert) { - for (let i = 0; i < 20; i++) { - assert.true(isGeneratedSubdomain(generateObscureSubdomain())); - } - }); - - test('isGeneratedSubdomain rejects typical custom site names', async function (assert) { - for (let name of ['mysite', 'game-mechanics', 'mike', 'a', 'blog-2025']) { - assert.false( - isGeneratedSubdomain(name), - `"${name}" is not treated as a generated link`, - ); - } - }); }); }); diff --git a/packages/runtime-common/published-realm-url.ts b/packages/runtime-common/published-realm-url.ts index 498594ac58..5b254b9ed0 100644 --- a/packages/runtime-common/published-realm-url.ts +++ b/packages/runtime-common/published-realm-url.ts @@ -93,20 +93,19 @@ function normalizeProtocol(protocol: string): string { return protocol.replace(/:\/*$/, ''); } -// Character set for machine-generated "unlisted link" subdomains. Restricted to -// lowercase letters and digits (so the result always satisfies -// `validateSubdomain`) with the visually ambiguous characters removed -// (i, l, o, 0, 1) so a link read aloud or copied by hand is less error-prone. -const OBSCURE_SUBDOMAIN_ALPHABET = 'abcdefghjkmnpqrstuvwxyz'; -const OBSCURE_SUBDOMAIN_DIGITS = '23456789'; -const OBSCURE_SUBDOMAIN_CHARSET = - OBSCURE_SUBDOMAIN_ALPHABET + OBSCURE_SUBDOMAIN_DIGITS; +// Character set for the machine-generated path segment of an "unlisted link" +// publish target (`.//`). Lowercase letters and +// digits only — safe as a URL path segment with no escaping — with the visually +// ambiguous characters removed (i, l, o, 0, 1) so a link read aloud or copied by +// hand is less error-prone. +const OBSCURE_SLUG_ALPHABET = 'abcdefghjkmnpqrstuvwxyz'; +const OBSCURE_SLUG_DIGITS = '23456789'; +const OBSCURE_SLUG_CHARSET = OBSCURE_SLUG_ALPHABET + OBSCURE_SLUG_DIGITS; -// Length of a generated subdomain. 16 characters over a 31-symbol alphabet is -// ~79 bits of entropy — unguessable in the "even if the page is public, the URL -// is the secret" sense (a la a Google Doc link) — and is also the signal used -// to tell a generated subdomain apart from a user-chosen custom site name. -export const OBSCURE_SUBDOMAIN_LENGTH = 16; +// Length of a generated slug. 16 characters over a 31-symbol alphabet is ~79 +// bits of entropy — unguessable in the "even if the page is public, the URL is +// the secret" sense (a la a Google Doc link). +export const OBSCURE_SLUG_LENGTH = 16; function randomBytes(length: number): Uint8Array { let cryptoObj = ( @@ -140,34 +139,11 @@ function randomChars(charset: string, count: number): string { return out; } -// Generates an unguessable subdomain for an "unlisted link" publish target. The -// first character is always a letter so the result can never be the pure-number -// form `validateSubdomain` rejects, and the whole string is drawn from a -// subdomain-safe alphabet so the claim/availability check accepts it as-is. -export function generateObscureSubdomain(): string { - let first = randomChars(OBSCURE_SUBDOMAIN_ALPHABET, 1); - let rest = randomChars( - OBSCURE_SUBDOMAIN_CHARSET, - OBSCURE_SUBDOMAIN_LENGTH - 1, - ); - return first + rest; -} - -// Whether a subdomain looks like one `generateObscureSubdomain` produced. Used -// to decide which publish-modal card (unlisted link vs. custom site name) owns -// a realm's single claimed `boxel.site` domain. A user-chosen name of the same -// length drawn from the same alphabet would be misclassified, but that only -// affects which card displays the claim, not correctness of publishing. -export function isGeneratedSubdomain(subdomain: string): boolean { - if (subdomain.length !== OBSCURE_SUBDOMAIN_LENGTH) { - return false; - } - if (!OBSCURE_SUBDOMAIN_ALPHABET.includes(subdomain[0])) { - return false; - } - return [...subdomain].every((char) => - OBSCURE_SUBDOMAIN_CHARSET.includes(char), - ); +// Generates an unguessable path-segment slug for an "unlisted link" publish +// target. The whole string is drawn from a URL-path-safe alphabet so it needs no +// escaping in the published-realm URL. +export function generateObscureSlug(): string { + return randomChars(OBSCURE_SLUG_CHARSET, OBSCURE_SLUG_LENGTH); } export function resolvePublishedRealmUrl( From 4c704c51531c81fda5cd5a4d182fc5f02329aec9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 18 Jun 2026 13:03:19 -0500 Subject: [PATCH 07/11] Change to username subdomain, random subdirectory --- .../operator-mode/publish-realm-modal.gts | 263 ++++++++++-------- packages/host/app/services/realm-server.ts | 39 +++ .../config/schema/1780620712404_schema.sql | 188 ------------- ...71_schema.sql => 1780700000000_schema.sql} | 0 .../tests/acceptance/host-submode-test.gts | 182 +++++++----- ...80700000000_create-unlisted-realm-paths.js | 50 ++++ .../handlers/handle-publish-realm.ts | 36 ++- .../handlers/handle-unlisted-realm-path.ts | 106 +++++++ .../realm-server/lib/unlisted-realm-path.ts | 33 +++ packages/realm-server/routes.ts | 6 + packages/realm-server/tests/index.ts | 1 + .../tests/publish-unpublish-realm-test.ts | 32 +++ .../tests/unlisted-realm-path-test.ts | 156 +++++++++++ 13 files changed, 715 insertions(+), 377 deletions(-) delete mode 100644 packages/host/config/schema/1780620712404_schema.sql rename packages/host/config/schema/{1780627635271_schema.sql => 1780700000000_schema.sql} (100%) create mode 100644 packages/postgres/migrations/1780700000000_create-unlisted-realm-paths.js create mode 100644 packages/realm-server/handlers/handle-unlisted-realm-path.ts create mode 100644 packages/realm-server/lib/unlisted-realm-path.ts create mode 100644 packages/realm-server/tests/unlisted-realm-path-test.ts diff --git a/packages/host/app/components/operator-mode/publish-realm-modal.gts b/packages/host/app/components/operator-mode/publish-realm-modal.gts index b8422a10dd..2f0fa73797 100644 --- a/packages/host/app/components/operator-mode/publish-realm-modal.gts +++ b/packages/host/app/components/operator-mode/publish-realm-modal.gts @@ -27,7 +27,6 @@ import { IconX, Warning as WarningIcon } from '@cardstack/boxel-ui/icons'; import { deriveRealmName, ensureTrailingSlash, - generateObscureSlug, isCardErrorJSONAPI, isCardInstance, resolvePublishedRealmUrl, @@ -102,11 +101,12 @@ export default class PublishRealmModal extends Component { @tracked private customSubdomainError: string | null = null; @tracked private isCheckingCustomSubdomain = false; @tracked private claimedDomain: ClaimedDomain | null = null; - // Random path segment for the "Unlisted Link" target - // (`.//`). Seeded with a fresh - // slug so the URL is always well-formed; replaced by a prior publish's slug - // during init when one exists. - @tracked private unlistedPathSegment: string = generateObscureSlug(); + // Server-issued random path segment for the "Unlisted Link" target + // (`.//`). Loaded from the + // realm-server on open (`loadUnlistedPathTask`) — the server owns the slug so + // it can't be hand-picked — and replaced via "New link" + // (`regenerateUnlistedLinkTask`). Null while loading. + @tracked private unlistedPathSegment: string | null = null; @tracked private privateDependencyCheckError: string | null = null; @tracked private privateDependencyViolations: @@ -125,6 +125,7 @@ export default class PublishRealmModal extends Component { super(owner, args); this.ensureInitialSelectionsTask.perform(); this.fetchBoxelClaimedDomain.perform(); + this.loadUnlistedPathTask.perform(); this.checkPrivateDependenciesTask.perform(); this.checkDanglingRoutingRulesTask.perform(); } @@ -255,7 +256,10 @@ export default class PublishRealmModal extends Component { // path segment instead of the realm name, e.g. // `https://.//`. Like the Boxel Space target // it is namespaced to the owner, so it needs no claim/availability check. - get unlistedRealmUrl(): string { + get unlistedRealmUrl(): string | null { + if (!this.unlistedPathSegment) { + return null; + } return resolvePublishedRealmUrl( { type: 'subdirectory', name: this.unlistedPathSegment }, { @@ -269,23 +273,47 @@ export default class PublishRealmModal extends Component { get unlistedRealmParts() { return { baseUrl: `${this.getProtocol()}://${this.getMatrixUsername()}.${this.getDefaultPublishedRealmDomain()}/`, - pathSegment: this.unlistedPathSegment, + pathSegment: this.unlistedPathSegment ?? '', }; } + get isLoadingUnlistedLink() { + return this.loadUnlistedPathTask.isRunning || !this.unlistedPathSegment; + } + + get isRegeneratingUnlistedLink() { + return this.regenerateUnlistedLinkTask.isRunning; + } + + get isUnlistedCheckboxDisabled() { + return this.isLoadingUnlistedLink || this.isUnpublishingAnyRealms; + } + get isUnlistedRealmSelected() { - return this.selectedPublishedRealmURLs.includes(this.unlistedRealmUrl); + return ( + !!this.unlistedRealmUrl && + this.selectedPublishedRealmURLs.includes(this.unlistedRealmUrl) + ); } get isUnlistedRealmPublished() { - return this.hostModeService.isPublished(this.unlistedRealmUrl); + return ( + !!this.unlistedRealmUrl && + this.hostModeService.isPublished(this.unlistedRealmUrl) + ); } get unlistedLastPublishedTime() { + if (!this.unlistedRealmUrl) { + return null; + } return this.getFormattedLastPublishedTime(this.unlistedRealmUrl); } get publishErrorForUnlistedLink() { + if (!this.unlistedRealmUrl) { + return null; + } return this.getPublishErrorForUrl(this.unlistedRealmUrl); } @@ -628,59 +656,53 @@ export default class PublishRealmModal extends Component { @action toggleUnlistedDomain(event: Event) { + const unlistedUrl = this.unlistedRealmUrl; + if (!unlistedUrl) { + return; + } const input = event.target as HTMLInputElement; if (input.checked) { - this.addPublishedRealmUrl(this.unlistedRealmUrl); + this.addPublishedRealmUrl(unlistedUrl); } else { - this.removePublishedRealmUrl(this.unlistedRealmUrl); + this.removePublishedRealmUrl(unlistedUrl); } } - // Roll a fresh unlisted link. Only meaningful before it is published — once - // published the URL is fixed and recovered on reopen. - @action - regenerateUnlistedLink() { + // Loads the realm's server-issued unlisted-link slug, allocating one if none + // exists yet. The server owns the slug, so the client never generates it. + private loadUnlistedPathTask = restartableTask(async () => { + try { + let { slug } = await this.realmServer.allocateUnlistedPath( + this.currentRealmURL, + ); + this.unlistedPathSegment = slug; + } catch (error) { + console.error('Failed to load unlisted link', error); + } + }); + + // Roll a fresh unlisted link via the server. Only meaningful before it is + // published — once published the URL is fixed. + private regenerateUnlistedLinkTask = restartableTask(async () => { if (this.isUnlistedRealmPublished) { return; } const wasSelected = this.isUnlistedRealmSelected; - this.removePublishedRealmUrl(this.unlistedRealmUrl); - this.unlistedPathSegment = generateObscureSlug(); - if (wasSelected) { - this.addPublishedRealmUrl(this.unlistedRealmUrl); - } - } - - // If the realm already has a published unlisted link (a space-subdomain URL - // whose path segment isn't the realm name), reuse its slug so reopening the - // modal shows the same link instead of the freshly-seeded one. - private recoverPublishedUnlistedPath() { - const recovered = this.findPublishedUnlistedPathSegment(); - if (recovered) { - this.unlistedPathSegment = recovered; - } - } - - private findPublishedUnlistedPathSegment(): string | null { - const spaceBaseUrl = this.subdirectoryRealmParts.baseUrl; - const boxelSpaceUrl = this.subdirectoryRealmUrl; - for (const publishedUrl of this.hostModeService.publishedRealmURLs) { - if (publishedUrl === boxelSpaceUrl) { - continue; - } - if (!publishedUrl.startsWith(spaceBaseUrl)) { - continue; - } - const segments = publishedUrl - .slice(spaceBaseUrl.length) - .split('/') - .filter(Boolean); - if (segments.length === 1) { - return segments[0]; + const previousUrl = this.unlistedRealmUrl; + try { + let { slug } = await this.realmServer.allocateUnlistedPath( + this.currentRealmURL, + { regenerate: true }, + ); + this.removePublishedRealmUrl(previousUrl ?? undefined); + this.unlistedPathSegment = slug; + if (wasSelected && this.unlistedRealmUrl) { + this.addPublishedRealmUrl(this.unlistedRealmUrl); } + } catch (error) { + console.error('Failed to regenerate unlisted link', error); } - return null; - } + }); @action toggleCustomSubdomain(event: Event) { @@ -890,7 +912,6 @@ export default class PublishRealmModal extends Component { ensureInitialSelectionsTask = restartableTask( async (claim: ClaimedDomain | null = null) => { await this.realm.ensureRealmMeta(this.currentRealmURL); - this.recoverPublishedUnlistedPath(); this.applyInitialSelections(claim); }, ); @@ -1130,7 +1151,7 @@ export default class PublishRealmModal extends Component { {{on 'change' this.toggleUnlistedDomain}} class='domain-checkbox' data-test-unlisted-link-checkbox - disabled={{this.isUnpublishingAnyRealms}} + disabled={{this.isUnlistedCheckboxDisabled}} /> @@ -1139,66 +1160,86 @@ export default class PublishRealmModal extends Component { -
- - {{this.unlistedRealmParts.baseUrl}}{{this.unlistedRealmParts.pathSegment}}/ + {{#if this.unlistedRealmUrl}} +
+ + {{this.unlistedRealmParts.baseUrl}}{{this.unlistedRealmParts.pathSegment}}/ + + {{#if this.isUnlistedRealmPublished}} +
+ Published + {{this.unlistedLastPublishedTime}} + + {{#if (this.isUnpublishingRealm this.unlistedRealmUrl)}} + + Unpublishing… + {{else}} + + Unpublish + {{/if}} + +
+ {{/if}} +
+ {{else}} + + Generating link… - {{#if this.isUnlistedRealmPublished}} -
- Published - {{this.unlistedLastPublishedTime}} - - {{#if (this.isUnpublishingRealm this.unlistedRealmUrl)}} - - Unpublishing… - {{else}} - - Unpublish - {{/if}} - -
- {{/if}} -
+ {{/if}}
- {{#if this.isUnlistedRealmPublished}} - - - Open Site - - {{else}} - - New link - + {{#if this.unlistedRealmUrl}} + {{#if this.isUnlistedRealmPublished}} + + + Open Site + + {{else}} + + {{#if this.isRegeneratingUnlistedLink}} + + Generating… + {{else}} + New link + {{/if}} + + {{/if}} {{/if}} {{#if this.publishErrorForUnlistedLink}}
{ + await this.login(); + + let response = await this.authedFetch( + `${this.url.href}_unlisted-realm-path`, + { + method: 'POST', + headers: { + Accept: SupportedMimeType.JSONAPI, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sourceRealmURL, + regenerate: options.regenerate ?? false, + }), + }, + ); + + if (!response.ok) { + let errorText = await response.text(); + throw new Error( + `Allocate unlisted link failed: ${response.status} - ${errorText}`, + ); + } + + let { + data: { attributes }, + } = (await response.json()) as { + data: { attributes: { sourceRealmURL: string; slug: string } }; + }; + return { sourceRealmURL: attributes.sourceRealmURL, slug: attributes.slug }; + } + async deleteBoxelClaimedDomain(claimedDomainId: string): Promise { await this.login(); diff --git a/packages/host/config/schema/1780620712404_schema.sql b/packages/host/config/schema/1780620712404_schema.sql deleted file mode 100644 index 6a56a20d82..0000000000 --- a/packages/host/config/schema/1780620712404_schema.sql +++ /dev/null @@ -1,188 +0,0 @@ --- This is auto-generated by packages/realm-server/scripts/convert-to-sqlite.ts --- Please don't directly modify this file - - CREATE TABLE IF NOT EXISTS bot_commands ( - id NOT NULL, - bot_id NOT NULL, - command TEXT NOT NULL, - command_filter BLOB NOT NULL, - created_at NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS bot_registrations ( - id NOT NULL, - username TEXT NOT NULL, - created_at NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS boxel_index ( - url TEXT NOT NULL, - file_alias TEXT NOT NULL, - type TEXT NOT NULL, - realm_version INTEGER NOT NULL, - realm_url TEXT NOT NULL, - pristine_doc BLOB, - search_doc BLOB, - error_doc BLOB, - deps BLOB DEFAULT '[]', - types BLOB, - isolated_html TEXT, - indexed_at, - is_deleted BOOLEAN, - last_modified, - embedded_html BLOB, - atom_html TEXT, - fitted_html BLOB, - display_names BLOB, - resource_created_at, - icon_html TEXT, - head_html TEXT, - has_error BOOLEAN DEFAULT false NOT NULL, - last_known_good_deps BLOB, - markdown TEXT, - diagnostics BLOB, - PRIMARY KEY ( url, realm_url, type ) -); - - CREATE TABLE IF NOT EXISTS boxel_index_working ( - url TEXT NOT NULL, - file_alias TEXT NOT NULL, - type TEXT NOT NULL, - realm_version INTEGER NOT NULL, - realm_url TEXT NOT NULL, - pristine_doc BLOB, - search_doc BLOB, - error_doc BLOB, - deps BLOB DEFAULT '[]', - types BLOB, - icon_html TEXT, - isolated_html TEXT, - indexed_at, - is_deleted BOOLEAN, - last_modified, - embedded_html BLOB, - atom_html TEXT, - fitted_html BLOB, - display_names BLOB, - resource_created_at, - head_html TEXT, - has_error BOOLEAN DEFAULT false NOT NULL, - last_known_good_deps BLOB, - markdown TEXT, - diagnostics BLOB, - job_id INTEGER, - PRIMARY KEY ( url, realm_url, type ) -); - - CREATE TABLE IF NOT EXISTS incoming_webhooks ( - id NOT NULL, - username TEXT NOT NULL, - webhook_path TEXT NOT NULL, - verification_type TEXT NOT NULL, - verification_config BLOB NOT NULL, - signing_secret TEXT NOT NULL, - created_at NOT NULL, - updated_at NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS job_scoped_instance_cache ( - job_id TEXT NOT NULL, - url TEXT NOT NULL, - result TEXT NOT NULL, - created_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - PRIMARY KEY ( job_id, url ) -); - - CREATE TABLE IF NOT EXISTS module_transpile_cache ( - realm_url TEXT NOT NULL, - canonical_path TEXT NOT NULL, - body TEXT, - headers BLOB, - dependency_keys BLOB, - generation DEFAULT 0 NOT NULL, - created_at, - PRIMARY KEY ( realm_url, canonical_path ) -); - - CREATE TABLE IF NOT EXISTS modules ( - url TEXT NOT NULL, - cache_scope TEXT NOT NULL, - auth_user_id TEXT NOT NULL, - resolved_realm_url TEXT NOT NULL, - definitions BLOB, - deps BLOB, - error_doc BLOB, - created_at, - file_alias TEXT, - url_hash TEXT GENERATED ALWAYS AS (url) STORED NOT NULL, - diagnostics BLOB, - PRIMARY KEY ( url, cache_scope, auth_user_id ) -); - - CREATE TABLE IF NOT EXISTS realm_file_meta ( - realm_url TEXT NOT NULL, - file_path TEXT NOT NULL, - created_at INTEGER NOT NULL, - content_hash TEXT, - content_size INTEGER, - PRIMARY KEY ( realm_url, file_path ) -); - - CREATE TABLE IF NOT EXISTS realm_meta ( - realm_url TEXT NOT NULL, - realm_version INTEGER NOT NULL, - value BLOB NOT NULL, - indexed_at, - PRIMARY KEY ( realm_url, realm_version ) -); - - CREATE TABLE IF NOT EXISTS realm_metadata ( - url TEXT NOT NULL, - show_as_catalog BOOLEAN, - publishable BOOLEAN, - created_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - PRIMARY KEY ( url ) -); - - CREATE TABLE IF NOT EXISTS realm_registry ( - id DEFAULT (hex(randomblob(16))) NOT NULL, - url TEXT NOT NULL, - kind TEXT NOT NULL, - disk_id TEXT NOT NULL, - owner_username TEXT NOT NULL, - source_url TEXT, - last_published_at, - pinned BOOLEAN DEFAULT false NOT NULL, - created_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS realm_user_permissions ( - realm_url TEXT NOT NULL, - username TEXT NOT NULL, - read BOOLEAN NOT NULL, - write BOOLEAN NOT NULL, - realm_owner BOOLEAN DEFAULT false NOT NULL, - PRIMARY KEY ( realm_url, username ) -); - - CREATE TABLE IF NOT EXISTS realm_versions ( - realm_url TEXT NOT NULL, - current_version INTEGER NOT NULL, - PRIMARY KEY ( realm_url ) -); - - CREATE TABLE IF NOT EXISTS webhook_commands ( - id NOT NULL, - incoming_webhook_id NOT NULL, - command TEXT NOT NULL, - command_filter BLOB, - created_at NOT NULL, - updated_at NOT NULL, - PRIMARY KEY ( id ) -); \ No newline at end of file diff --git a/packages/host/config/schema/1780627635271_schema.sql b/packages/host/config/schema/1780700000000_schema.sql similarity index 100% rename from packages/host/config/schema/1780627635271_schema.sql rename to packages/host/config/schema/1780700000000_schema.sql diff --git a/packages/host/tests/acceptance/host-submode-test.gts b/packages/host/tests/acceptance/host-submode-test.gts index 64f960fc05..24f37c8c79 100644 --- a/packages/host/tests/acceptance/host-submode-test.gts +++ b/packages/host/tests/acceptance/host-submode-test.gts @@ -86,11 +86,21 @@ function withUpdatedTestRealmInfo( }; } -// The URL shown in the unlisted-link card, e.g. -// "https://testuser.//". The path segment is random per run. -function unlistedLinkUrlFromDom(): string { - let el = document.querySelector('[data-test-unlisted-link-url]'); - return (el?.textContent ?? '').replace(/\s+/g, ''); +// Stubs the realm-server `allocateUnlistedPath` method (which normally hits the +// server that owns the random slug), returning the given slug(s) — successive +// calls walk the list and then stick on the last entry, so passing two slugs +// covers the initial load + a "New link" regenerate. Returns a restore function. +function stubUnlistedPath(slugs: string | string[]): () => void { + let realmServer = getService('realm-server') as any; + let original = realmServer.allocateUnlistedPath; + let queue = Array.isArray(slugs) ? [...slugs] : [slugs]; + realmServer.allocateUnlistedPath = async (sourceRealmURL: string) => { + let slug = queue.length > 1 ? queue.shift()! : queue[0]; + return { sourceRealmURL, slug }; + }; + return () => { + realmServer.allocateUnlistedPath = original; + }; } module('Acceptance | host submode', function (hooks) { @@ -812,99 +822,115 @@ module('Acceptance | host submode', function (hooks) { }); test('can publish an unlisted link', async function (assert) { - await visitOperatorMode({ - submode: 'host', - trail: [`${testRealmURL}Person/1.json`], - }); + // The server owns the random slug; the modal just renders what the + // `_unlisted-realm-path` endpoint returns. + let restoreUnlisted = stubUnlistedPath('k7f3qz9pbcdmnpqr'); + let unlistedUrl = `https://testuser.${publishedSpaceHost}/k7f3qz9pbcdmnpqr/`; + try { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); - await click('[data-test-publish-realm-button]'); - await waitFor('[data-test-publish-realm-modal]'); - await waitFor('[data-test-unlisted-link-url]'); - - // The unlisted link is the user's own space subdomain with a random - // path segment instead of the realm name. - let unlistedUrl = unlistedLinkUrlFromDom(); - let spacePrefix = `https://testuser.${publishedSpaceHost}/`; - assert.ok( - unlistedUrl.startsWith(spacePrefix), - `unlisted link is on the user's space (${unlistedUrl})`, - ); - assert.ok(unlistedUrl.endsWith('/'), 'unlisted link is a realm root'); - let slug = unlistedUrl.slice(spacePrefix.length, -1); - assert.ok(/^[a-z0-9]+$/.test(slug), `random path segment (${slug})`); - assert.notStrictEqual(slug, 'test', 'path is not the realm name'); - - assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); - await click('[data-test-unlisted-link-checkbox]'); - assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); - assert.dom('[data-test-publish-button]').isNotDisabled(); + await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-publish-realm-modal]'); + await waitFor('[data-test-unlisted-link-url]'); - await click('[data-test-publish-button]'); - publishDeferred.fulfill(); - await waitUntil(() => { - return !document.querySelector( - '[data-test-publish-realm-button].publishing', - ); - }); + // The unlisted link is the user's own space subdomain with the + // server-issued slug as the path, not the realm name. + assert.dom('[data-test-unlisted-link-url]').hasText(unlistedUrl); - await click('[data-test-publish-realm-button]'); - assert - .dom( - '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', - ) - .hasAttribute('href', unlistedUrl) - .hasAttribute('target', '_blank'); + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); + + await click('[data-test-publish-button]'); + publishDeferred.fulfill(); + await waitUntil(() => { + return !document.querySelector( + '[data-test-publish-realm-button].publishing', + ); + }); + + await click('[data-test-publish-realm-button]'); + assert + .dom( + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + ) + .hasAttribute('href', unlistedUrl) + .hasAttribute('target', '_blank'); + } finally { + restoreUnlisted(); + } }); test('unlisted link checkbox can be checked and unchecked', async function (assert) { - await visitOperatorMode({ - submode: 'host', - trail: [`${testRealmURL}Person/1.json`], - }); + let restoreUnlisted = stubUnlistedPath('k7f3qz9pbcdmnpqr'); + try { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); - await click('[data-test-publish-realm-button]'); - await waitFor('[data-test-unlisted-link-url]'); + await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-unlisted-link-url]'); - assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); - assert.dom('[data-test-publish-button]').isDisabled(); + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); - await click('[data-test-unlisted-link-checkbox]'); - assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); - assert.dom('[data-test-publish-button]').isNotDisabled(); + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); - await click('[data-test-unlisted-link-checkbox]'); - assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); - assert.dom('[data-test-publish-button]').isDisabled(); + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); + } finally { + restoreUnlisted(); + } }); test('can regenerate the unlisted link before publishing', async function (assert) { - await visitOperatorMode({ - submode: 'host', - trail: [`${testRealmURL}Person/1.json`], - }); + let restoreUnlisted = stubUnlistedPath([ + 'firstslug00000000', + 'secondslug0000000', + ]); + try { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); - await click('[data-test-publish-realm-button]'); - await waitFor('[data-test-unlisted-link-url]'); + await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-unlisted-link-url]'); - let firstUrl = unlistedLinkUrlFromDom(); - await click('[data-test-regenerate-unlisted-link-button]'); - let secondUrl = unlistedLinkUrlFromDom(); + assert + .dom('[data-test-unlisted-link-url]') + .hasText( + `https://testuser.${publishedSpaceHost}/firstslug00000000/`, + ); - assert.notStrictEqual( - secondUrl, - firstUrl, - 'regenerating produces a different link', - ); - assert.ok( - secondUrl.startsWith(`https://testuser.${publishedSpaceHost}/`), - `regenerated link is still on the user's space (${secondUrl})`, - ); + await click('[data-test-regenerate-unlisted-link-button]'); + + assert + .dom('[data-test-unlisted-link-url]') + .hasText( + `https://testuser.${publishedSpaceHost}/secondslug0000000/`, + ); + } finally { + restoreUnlisted(); + } }); test('preselects a previously published unlisted link on refresh', async function (assert) { let now = Date.now(); - let unlistedUrl = `https://testuser.${publishedSpaceHost}/k7f3qz9pbcdmnpqr/`; + let slug = 'k7f3qz9pbcdmnpqr'; + let unlistedUrl = `https://testuser.${publishedSpaceHost}/${slug}/`; + // The server returns the realm's existing slug, so the modal shows the + // same URL that was previously published. + let restoreUnlisted = stubUnlistedPath(slug); let restoreRealmInfo = withUpdatedTestRealmInfo({ lastPublishedAt: { [unlistedUrl]: String(now), @@ -919,12 +945,14 @@ module('Acceptance | host submode', function (hooks) { await click('[data-test-publish-realm-button]'); await waitFor('[data-test-publish-realm-modal]'); + await waitFor('[data-test-unlisted-link-url]'); assert.dom('[data-test-unlisted-link-url]').hasText(unlistedUrl); assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); assert.dom('[data-test-publish-button]').isNotDisabled(); } finally { restoreRealmInfo(); + restoreUnlisted(); } }); diff --git a/packages/postgres/migrations/1780700000000_create-unlisted-realm-paths.js b/packages/postgres/migrations/1780700000000_create-unlisted-realm-paths.js new file mode 100644 index 0000000000..1c79b61cc6 --- /dev/null +++ b/packages/postgres/migrations/1780700000000_create-unlisted-realm-paths.js @@ -0,0 +1,50 @@ +exports.shorthands = undefined; + +// Stores the server-issued random path segment for each source realm's +// "unlisted link" publish target (`.//`). The slug +// is generated and owned by the server so the unguessable string can't be +// chosen by a client via direct API calls; the publish handler only allows a +// subdirectory publish to this slug (or the realm name). +exports.up = (pgm) => { + pgm.createTable('unlisted_realm_paths', { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + source_realm_url: { + type: 'varchar', + notNull: true, + }, + slug: { + type: 'varchar', + notNull: true, + }, + owner_user_id: { + type: 'varchar', + notNull: true, + }, + created_at: { + type: 'timestamp', + notNull: true, + default: pgm.func('now()'), + }, + updated_at: { + type: 'timestamp', + notNull: true, + default: pgm.func('now()'), + }, + }); + + pgm.createIndex('unlisted_realm_paths', ['source_realm_url'], { + unique: true, + name: 'unlisted_realm_paths_source_url_unique_index', + }); +}; + +exports.down = (pgm) => { + pgm.dropIndex('unlisted_realm_paths', ['source_realm_url'], { + name: 'unlisted_realm_paths_source_url_unique_index', + }); + pgm.dropTable('unlisted_realm_paths'); +}; diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index aa16dbfd93..c232d1a63e 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -14,7 +14,9 @@ import { fetchRealmPermissions, uuidv4, userInitiatedPriority, + deriveRealmName, } from '@cardstack/runtime-common'; +import { getUnlistedSlug } from '../lib/unlisted-realm-path.ts'; import { getPublishedRealmDomainOverrides } from '@cardstack/runtime-common/constants'; import { join } from 'path'; @@ -39,7 +41,10 @@ import { createJWT } from '../jwt.ts'; import type { CreateRoutesArgs } from '../routes.ts'; import type { RealmServerTokenClaim } from '../utils/jwt.ts'; import { registerUser } from '../synapse.ts'; -import { passwordFromSeed } from '@cardstack/runtime-common/matrix-client'; +import { + getMatrixUsername, + passwordFromSeed, +} from '@cardstack/runtime-common/matrix-client'; import { enqueueReindexRealmJob } from '@cardstack/runtime-common/jobs/reindex-realm'; import { upsertPublishedRealmInRegistry } from '../lib/realm-registry-writes.ts'; @@ -315,6 +320,35 @@ export default function handlePublishRealm({ ); return; } + + // Within the owner's own published space (`.`), a + // subdirectory publish may target only the realm-name path (the "Your + // Boxel Space" target) or the server-issued unlisted-link slug — never an + // arbitrary, client-chosen path — so the unlisted link's unguessable + // path can't be hand-picked through a direct API call. Publishes to any + // other host (claimed custom domains, etc.) are left permissive. + let spaceDomain = domainsForPublishedRealms?.boxelSpace; + let matrixUsername = getMatrixUsername(ownerUserId); + let publishedURLForPath = new URL(publishedRealmURL); + let isOwnerSpaceHost = + !!spaceDomain && + (publishedURLForPath.host === `${matrixUsername}.${spaceDomain}` || + publishedURLForPath.hostname === `${matrixUsername}.${spaceDomain}`); + if (isOwnerSpaceHost) { + let publishedPath = publishedURLForPath.pathname + .split('/') + .filter(Boolean) + .join('/'); + let realmName = deriveRealmName(sourceRealmURL); + let unlistedSlug = await getUnlistedSlug(dbAdapter, sourceRealmURL); + if (publishedPath !== realmName && publishedPath !== unlistedSlug) { + await sendResponseForBadRequest( + ctxt, + 'publishedRealmURL path must be the realm name or the server-issued unlisted link', + ); + return; + } + } } try { diff --git a/packages/realm-server/handlers/handle-unlisted-realm-path.ts b/packages/realm-server/handlers/handle-unlisted-realm-path.ts new file mode 100644 index 0000000000..65248f54c4 --- /dev/null +++ b/packages/realm-server/handlers/handle-unlisted-realm-path.ts @@ -0,0 +1,106 @@ +import type Koa from 'koa'; +import { + ensureTrailingSlash, + fetchRealmPermissions, + generateObscureSlug, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import { + fetchRequestFromContext, + sendResponseForBadRequest, + sendResponseForForbiddenRequest, + sendResponseForSystemError, + setContextResponse, +} from '../middleware/index.ts'; +import type { RealmServerTokenClaim } from '../utils/jwt.ts'; +import type { CreateRoutesArgs } from '../routes.ts'; +import { + getUnlistedSlug, + upsertUnlistedSlug, +} from '../lib/unlisted-realm-path.ts'; + +// Returns the server-issued random path segment ("slug") for a source realm's +// unlisted link, generating and persisting one on first request. Pass +// `regenerate: true` to mint a fresh slug. The slug is always generated here, on +// the server — clients never supply it — so the unlisted link's unguessability +// can't be undermined by a hand-crafted request. +export default function handleUnlistedRealmPathRequest({ + dbAdapter, +}: CreateRoutesArgs): (ctxt: Koa.Context, next: Koa.Next) => Promise { + return async function (ctxt: Koa.Context, _next: Koa.Next) { + try { + let token = ctxt.state.token as RealmServerTokenClaim; + if (!token) { + await sendResponseForSystemError( + ctxt, + 'token is required to allocate an unlisted realm path', + ); + return; + } + let { user: ownerUserId } = token; + + let request = await fetchRequestFromContext(ctxt); + let body = await request.text(); + let json: Record; + try { + json = JSON.parse(body); + } catch (_error) { + await sendResponseForBadRequest( + ctxt, + 'Request body is not valid JSON - invalid JSON', + ); + return; + } + + if (!json.sourceRealmURL) { + await sendResponseForBadRequest(ctxt, 'sourceRealmURL is required'); + return; + } + let sourceRealmURL = ensureTrailingSlash(json.sourceRealmURL); + let regenerate = json.regenerate === true; + + let permissions = await fetchRealmPermissions( + dbAdapter, + new URL(sourceRealmURL), + ); + if (!permissions[ownerUserId]?.includes('realm-owner')) { + await sendResponseForForbiddenRequest( + ctxt, + `${ownerUserId} does not have enough permission to allocate an unlisted link for this realm`, + ); + return; + } + + let slug = regenerate + ? null + : await getUnlistedSlug(dbAdapter, sourceRealmURL); + if (!slug) { + slug = generateObscureSlug(); + await upsertUnlistedSlug(dbAdapter, { + sourceRealmURL, + slug, + ownerUserId, + }); + } + + await setContextResponse( + ctxt, + new Response( + JSON.stringify({ + data: { + type: 'unlisted-realm-path', + attributes: { sourceRealmURL, slug }, + }, + }), + { + status: 200, + headers: { 'content-type': SupportedMimeType.JSONAPI }, + }, + ), + ); + } catch (error) { + console.error('Error allocating unlisted realm path:', error); + await sendResponseForSystemError(ctxt, 'Internal server error'); + } + }; +} diff --git a/packages/realm-server/lib/unlisted-realm-path.ts b/packages/realm-server/lib/unlisted-realm-path.ts new file mode 100644 index 0000000000..7ba75236bb --- /dev/null +++ b/packages/realm-server/lib/unlisted-realm-path.ts @@ -0,0 +1,33 @@ +import { query, param, type DBAdapter } from '@cardstack/runtime-common'; + +// Read/write helpers for `unlisted_realm_paths` — the server-issued random path +// segment for a source realm's "unlisted link" publish target. The slug is +// generated server-side (never supplied by the client) so the unguessable +// string can't be hand-picked through direct API calls, and the publish handler +// consults it to reject subdirectory publishes to any other path. + +export async function getUnlistedSlug( + dbAdapter: DBAdapter, + sourceRealmURL: string, +): Promise { + let rows = (await query(dbAdapter, [ + `SELECT slug FROM unlisted_realm_paths WHERE source_realm_url =`, + param(sourceRealmURL), + ])) as { slug: string }[]; + return rows[0]?.slug ?? null; +} + +export async function upsertUnlistedSlug( + dbAdapter: DBAdapter, + args: { sourceRealmURL: string; slug: string; ownerUserId: string }, +): Promise { + await query(dbAdapter, [ + `INSERT INTO unlisted_realm_paths (source_realm_url, slug, owner_user_id) VALUES (`, + param(args.sourceRealmURL), + `,`, + param(args.slug), + `,`, + param(args.ownerUserId), + `) ON CONFLICT (source_realm_url) DO UPDATE SET slug = EXCLUDED.slug, owner_user_id = EXCLUDED.owner_user_id, updated_at = now()`, + ]); +} diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index e319ee8f19..1b2920311d 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -41,6 +41,7 @@ import handleRealmAuth from './handlers/handle-realm-auth.ts'; import handleGetBoxelClaimedDomainRequest from './handlers/handle-get-boxel-claimed-domain.ts'; import handleClaimBoxelDomainRequest from './handlers/handle-claim-boxel-domain.ts'; import handleDeleteBoxelClaimedDomainRequest from './handlers/handle-delete-boxel-claimed-domain.ts'; +import handleUnlistedRealmPathRequest from './handlers/handle-unlisted-realm-path.ts'; import handlePrerenderProxy from './handlers/handle-prerender-proxy.ts'; import handleSearch from './handlers/handle-search.ts'; import handleSearchV2 from './handlers/handle-search-v2.ts'; @@ -309,6 +310,11 @@ export function createRoutes(args: CreateRoutesArgs) { jwtMiddleware(args.realmSecretSeed), handleDeleteBoxelClaimedDomainRequest(args), ); + router.post( + '/_unlisted-realm-path', + jwtMiddleware(args.realmSecretSeed), + handleUnlistedRealmPathRequest(args), + ); // Matrix tests don't need the GitHub PR integration, and skipping this route // keeps the realm server from loading Octokit's ESM entrypoint during boot. if (process.env.DISABLE_GITHUB_PR_ROUTE !== 'true') { diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index c72b8f93eb..db16606d25 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -294,6 +294,7 @@ const ALL_TEST_FILES: string[] = [ './coerce-error-message-test', './realm-operations-test', './resolve-published-realm-url-test', + './unlisted-realm-path-test', './fallback-models-test', './host-routing-validation-test', './normalize-realm-meta-value-test', diff --git a/packages/realm-server/tests/publish-unpublish-realm-test.ts b/packages/realm-server/tests/publish-unpublish-realm-test.ts index 4a058a1d23..95b0bac3a3 100644 --- a/packages/realm-server/tests/publish-unpublish-realm-test.ts +++ b/packages/realm-server/tests/publish-unpublish-realm-test.ts @@ -180,6 +180,38 @@ module(basename(__filename), function () { `); }); + test('POST /_publish-realm rejects a hand-picked subdirectory under the owner space', async function (assert) { + // `mango` is the owner, so `mango.localhost` is their own space; the path + // here is neither the realm name nor a server-issued unlisted slug, so + // the unguessable unlisted path can't be chosen via a direct API call. + let response = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: ownerUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send( + JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: + 'http://mango.localhost:4445/hand-picked-path/', + }), + ); + + assert.strictEqual(response.status, 400, 'HTTP 400 status'); + assert.ok( + response.text.includes( + 'must be the realm name or the server-issued unlisted link', + ), + 'error explains the path restriction', + ); + }); + test('POST /_publish-realm can publish realm successfully', async function (assert) { let response = await request .post('/_publish-realm') diff --git a/packages/realm-server/tests/unlisted-realm-path-test.ts b/packages/realm-server/tests/unlisted-realm-path-test.ts new file mode 100644 index 0000000000..3986256b10 --- /dev/null +++ b/packages/realm-server/tests/unlisted-realm-path-test.ts @@ -0,0 +1,156 @@ +import { module, test } from 'qunit'; +import { basename, join } from 'path'; +import type { PgAdapter } from '@cardstack/postgres'; +import { query, param } from '@cardstack/runtime-common'; +import { + setupDB, + insertUser, + runTestRealmServer, + createVirtualNetwork, + fixtureDir, + matrixURL, + closeServer, + realmSecretSeed, +} from './helpers/index.ts'; +import type { RealmServerTokenClaim } from '../utils/jwt.ts'; +import { createJWT as createRealmServerJWT } from '../utils/jwt.ts'; +import type { SuperTest, Test } from 'supertest'; +import supertest from 'supertest'; +import type { RealmHttpServer as Server } from '../server.ts'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync, ensureDirSync } from 'fs-extra'; + +const testRealmURL = new URL('http://127.0.0.1:0/test/'); +const ownerUserId = 'matrix-owner-id'; +const sourceRealmURL = 'https://test-realm.example/owner/my-realm/'; + +module(basename(__filename), function () { + module('unlisted realm path endpoint', function (hooks) { + let testRealmServer: Server; + let request: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let defaultToken: RealmServerTokenClaim; + + hooks.beforeEach(async function () { + dir = dirSync(); + }); + + setupDB(hooks, { + beforeEach: async (_dbAdapter, publisher, runner) => { + dbAdapter = _dbAdapter; + let testRealmDir = join(dir.name, 'realm_server_unlisted', 'test'); + ensureDirSync(testRealmDir); + copySync(fixtureDir('simple'), testRealmDir); + + testRealmServer = ( + await runTestRealmServer({ + virtualNetwork: createVirtualNetwork(), + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_unlisted'), + realmURL: testRealmURL, + dbAdapter, + publisher, + runner, + matrixURL, + }) + ).testRealmHttpServer; + request = supertest(testRealmServer); + + await insertUser( + dbAdapter, + ownerUserId, + 'test-user', + 'test-user@example.com', + ); + // Grant the caller realm-owner permission on the source realm so the + // ownership check in the handler passes. + await dbAdapter.execute( + `INSERT INTO realm_user_permissions (realm_url, username, read, write, realm_owner) VALUES ('${sourceRealmURL}', '${ownerUserId}', true, true, true)`, + ); + defaultToken = { user: ownerUserId, sessionRoom: 'test-session' }; + }, + afterEach: async () => { + await closeServer(testRealmServer); + }, + }); + + async function post(token: RealmServerTokenClaim | null, body?: any) { + let builder = request + .post('/_unlisted-realm-path') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json'); + if (token) { + builder = builder.set( + 'Authorization', + `Bearer ${createRealmServerJWT(token, realmSecretSeed)}`, + ); + } + if (body !== undefined) { + builder = builder.send(body); + } + return await builder; + } + + test('returns 400 when sourceRealmURL is missing', async function (assert) { + let response = await post(defaultToken, {}); + assert.strictEqual( + response.status, + 400, + 'rejects a request with no realm', + ); + }); + + test('returns 403 when the caller is not the realm owner', async function (assert) { + let response = await post(defaultToken, { + sourceRealmURL: 'https://test-realm.example/someone-else/realm/', + }); + assert.strictEqual( + response.status, + 403, + 'rejects a non-owner of the source realm', + ); + }); + + test('allocates and persists a server-generated slug', async function (assert) { + let response = await post(defaultToken, { sourceRealmURL }); + assert.strictEqual(response.status, 200, 'allocates a slug'); + + let slug = response.body.data.attributes.slug; + assert.ok( + /^[a-z0-9]+$/.test(slug), + `slug is a random lowercase-alphanumeric string (${slug})`, + ); + + let rows = (await query(dbAdapter, [ + `SELECT slug FROM unlisted_realm_paths WHERE source_realm_url =`, + param(sourceRealmURL), + ])) as { slug: string }[]; + assert.strictEqual(rows.length, 1, 'one row is persisted'); + assert.strictEqual(rows[0].slug, slug, 'the persisted slug is returned'); + }); + + test('returns the same slug on repeat requests', async function (assert) { + let first = await post(defaultToken, { sourceRealmURL }); + let second = await post(defaultToken, { sourceRealmURL }); + assert.strictEqual( + second.body.data.attributes.slug, + first.body.data.attributes.slug, + 'allocation is idempotent without regenerate', + ); + }); + + test('regenerate mints a new slug', async function (assert) { + let first = await post(defaultToken, { sourceRealmURL }); + let regenerated = await post(defaultToken, { + sourceRealmURL, + regenerate: true, + }); + assert.notStrictEqual( + regenerated.body.data.attributes.slug, + first.body.data.attributes.slug, + 'regenerate produces a different slug', + ); + }); + }); +}); From b74e3d8a4979d2cdb3cfd5e768eb682323745650 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 18 Jun 2026 14:14:05 -0500 Subject: [PATCH 08/11] =?UTF-8?q?Fix=20=E2=80=9Csuspicious=E2=80=9D=20migr?= =?UTF-8?q?ation=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schema/{1780700000000_schema.sql => 1780713642519_schema.sql} | 0 ...ealm-paths.js => 1780713642519_create-unlisted-realm-paths.js} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/host/config/schema/{1780700000000_schema.sql => 1780713642519_schema.sql} (100%) rename packages/postgres/migrations/{1780700000000_create-unlisted-realm-paths.js => 1780713642519_create-unlisted-realm-paths.js} (100%) diff --git a/packages/host/config/schema/1780700000000_schema.sql b/packages/host/config/schema/1780713642519_schema.sql similarity index 100% rename from packages/host/config/schema/1780700000000_schema.sql rename to packages/host/config/schema/1780713642519_schema.sql diff --git a/packages/postgres/migrations/1780700000000_create-unlisted-realm-paths.js b/packages/postgres/migrations/1780713642519_create-unlisted-realm-paths.js similarity index 100% rename from packages/postgres/migrations/1780700000000_create-unlisted-realm-paths.js rename to packages/postgres/migrations/1780713642519_create-unlisted-realm-paths.js From d4b4a1c0acf177246109a77888cbbec2cf474481 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 19 Jun 2026 09:37:19 -0500 Subject: [PATCH 09/11] Fix publish-modal card position in tests and owner-space publish path in delete-realm test Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/acceptance/host-submode-test.gts | 19 +++++++++++++------ .../server-endpoints/delete-realm-test.ts | 12 ++++++++++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/host/tests/acceptance/host-submode-test.gts b/packages/host/tests/acceptance/host-submode-test.gts index be4036027b..237d94bc2d 100644 --- a/packages/host/tests/acceptance/host-submode-test.gts +++ b/packages/host/tests/acceptance/host-submode-test.gts @@ -688,6 +688,13 @@ module('Acceptance | host submode', function (hooks) { getService('realm-server').publishRealm = publishRealm; getService('realm-server').unpublishRealm = unpublishRealm; + // The publish modal asks the server for the unlisted-link slug on open; + // default it so the unlisted card renders a URL (not a stuck "Generating + // link…") in tests that don't exercise it. Tests that do use + // `stubUnlistedPath` to control the slug. + getService('realm-server').allocateUnlistedPath = async ( + sourceRealmURL: string, + ) => ({ sourceRealmURL, slug: 'defaultunlistedab' }); }); test('can publish realm', async function (assert) { @@ -1256,7 +1263,7 @@ module('Acceptance | host submode', function (hooks) { await waitFor('[data-test-publish-realm-modal]'); let customDomainOption = - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2)'; + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3)'; await waitFor(`${customDomainOption} .realm-icon`); assert @@ -1543,7 +1550,7 @@ module('Acceptance | host submode', function (hooks) { .containsText('Published'); assert .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2)', + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3)', ) .containsText('Published'); @@ -1590,7 +1597,7 @@ module('Acceptance | host submode', function (hooks) { assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); assert .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2)', + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3)', ) .containsText('Not published yet'); @@ -1634,7 +1641,7 @@ module('Acceptance | host submode', function (hooks) { // Custom subdomain should show as published assert .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2) .last-published-at', + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3) .last-published-at', ) .containsText('Published 2 days ago'); @@ -1656,12 +1663,12 @@ module('Acceptance | host submode', function (hooks) { // Should show "Not published yet" after unpublishing assert .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2) .not-published-yet', + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3) .not-published-yet', ) .exists(); assert .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2)', + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3)', ) .containsText('Not published yet'); } finally { diff --git a/packages/realm-server/tests/server-endpoints/delete-realm-test.ts b/packages/realm-server/tests/server-endpoints/delete-realm-test.ts index 52067e97bb..c0a88b9038 100644 --- a/packages/realm-server/tests/server-endpoints/delete-realm-test.ts +++ b/packages/realm-server/tests/server-endpoints/delete-realm-test.ts @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { asExpressions, + deriveRealmName, insert, insertPermissions, PUBLISHED_DIRECTORY_NAME, @@ -100,7 +101,12 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { let ownerUserId = `@${owner}:localhost`; let realmURL = await createRealmFor(ownerUserId); let realmPath = new URL(realmURL).pathname.split('/').filter(Boolean); - let publishedRealmURL = `http://${owner}.localhost:4445/published-${uuidv4()}/`; + // Publishing to the owner's own space is restricted to the realm-name path + // (the "Your Boxel Space" target) or a server-issued unlisted slug, so use + // the realm name here. + let publishedRealmURL = `http://${owner}.localhost:4445/${deriveRealmName( + realmURL, + )}/`; let unrelatedRealmURL = `http://papaya.localhost:4445/unrelated-${uuidv4()}/`; let user = await insertUser( @@ -615,7 +621,9 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { let owner = `mango-${uuidv4()}`; let ownerUserId = `@${owner}:localhost`; let realmURL = await createRealmFor(ownerUserId); - let publishedRealmURL = `http://${owner}.localhost:4445/published-${uuidv4()}/`; + let publishedRealmURL = `http://${owner}.localhost:4445/${deriveRealmName( + realmURL, + )}/`; await insertUser( context.dbAdapter, From a2651f03ba334c233be39ef38d28b0f5a165d289 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 19 Jun 2026 16:41:44 -0500 Subject: [PATCH 10/11] Make unlisted-link slug allocation insert-only to avoid clobbering on concurrent requests Reserve slug overwrites for explicit regeneration; first-time allocation now inserts-or-returns-existing so two overlapping requests converge on one slug. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../handlers/handle-unlisted-realm-path.ts | 21 +++++++++----- .../realm-server/lib/unlisted-realm-path.ts | 28 ++++++++++++++++++- .../tests/unlisted-realm-path-test.ts | 27 ++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/realm-server/handlers/handle-unlisted-realm-path.ts b/packages/realm-server/handlers/handle-unlisted-realm-path.ts index 65248f54c4..ba9c5b563f 100644 --- a/packages/realm-server/handlers/handle-unlisted-realm-path.ts +++ b/packages/realm-server/handlers/handle-unlisted-realm-path.ts @@ -15,8 +15,8 @@ import { import type { RealmServerTokenClaim } from '../utils/jwt.ts'; import type { CreateRoutesArgs } from '../routes.ts'; import { - getUnlistedSlug, - upsertUnlistedSlug, + allocateUnlistedSlug, + regenerateUnlistedSlug, } from '../lib/unlisted-realm-path.ts'; // Returns the server-issued random path segment ("slug") for a source realm's @@ -71,16 +71,23 @@ export default function handleUnlistedRealmPathRequest({ return; } - let slug = regenerate - ? null - : await getUnlistedSlug(dbAdapter, sourceRealmURL); - if (!slug) { + let slug: string; + if (regenerate) { + // Explicit "New link" — overwrite any existing slug. slug = generateObscureSlug(); - await upsertUnlistedSlug(dbAdapter, { + await regenerateUnlistedSlug(dbAdapter, { sourceRealmURL, slug, ownerUserId, }); + } else { + // First-time/idempotent allocation — insert a fresh slug, or return the + // one already stored, so concurrent requests can't clobber each other. + slug = await allocateUnlistedSlug(dbAdapter, { + sourceRealmURL, + candidateSlug: generateObscureSlug(), + ownerUserId, + }); } await setContextResponse( diff --git a/packages/realm-server/lib/unlisted-realm-path.ts b/packages/realm-server/lib/unlisted-realm-path.ts index 7ba75236bb..5c8f8c9e81 100644 --- a/packages/realm-server/lib/unlisted-realm-path.ts +++ b/packages/realm-server/lib/unlisted-realm-path.ts @@ -17,7 +17,33 @@ export async function getUnlistedSlug( return rows[0]?.slug ?? null; } -export async function upsertUnlistedSlug( +// Allocates the realm's unlisted slug without clobbering an existing one: insert +// `candidateSlug`, or — if a row already exists — return the stored slug +// unchanged. The no-op `DO UPDATE` makes `RETURNING` yield the existing row on +// conflict, so two racing first-time allocations both converge on whichever slug +// committed first (the other's candidate is discarded). This keeps a slug shown +// in one tab from being silently replaced by a concurrent allocation in another +// — which would otherwise make `handle-publish-realm` reject the first link. +export async function allocateUnlistedSlug( + dbAdapter: DBAdapter, + args: { sourceRealmURL: string; candidateSlug: string; ownerUserId: string }, +): Promise { + let rows = (await query(dbAdapter, [ + `INSERT INTO unlisted_realm_paths (source_realm_url, slug, owner_user_id) VALUES (`, + param(args.sourceRealmURL), + `,`, + param(args.candidateSlug), + `,`, + param(args.ownerUserId), + `) ON CONFLICT (source_realm_url) DO UPDATE SET source_realm_url = EXCLUDED.source_realm_url RETURNING slug`, + ])) as { slug: string }[]; + return rows[0].slug; +} + +// Overwrites the realm's unlisted slug. Used only for an explicit "New link" +// regeneration — never for first-time allocation, which must not clobber a slug +// a concurrent request may already be displaying (see allocateUnlistedSlug). +export async function regenerateUnlistedSlug( dbAdapter: DBAdapter, args: { sourceRealmURL: string; slug: string; ownerUserId: string }, ): Promise { diff --git a/packages/realm-server/tests/unlisted-realm-path-test.ts b/packages/realm-server/tests/unlisted-realm-path-test.ts index 3986256b10..d98b16feb1 100644 --- a/packages/realm-server/tests/unlisted-realm-path-test.ts +++ b/packages/realm-server/tests/unlisted-realm-path-test.ts @@ -140,6 +140,33 @@ module(basename(__filename), function () { ); }); + test('concurrent first-time allocations converge on one slug', async function (assert) { + // Two overlapping first-time requests must not clobber each other: both + // get the slug that committed first, and only one row is persisted. + let [a, b] = await Promise.all([ + post(defaultToken, { sourceRealmURL }), + post(defaultToken, { sourceRealmURL }), + ]); + assert.strictEqual(a.status, 200, 'first request succeeds'); + assert.strictEqual(b.status, 200, 'second request succeeds'); + assert.strictEqual( + b.body.data.attributes.slug, + a.body.data.attributes.slug, + 'both requests return the same slug', + ); + + let rows = (await query(dbAdapter, [ + `SELECT slug FROM unlisted_realm_paths WHERE source_realm_url =`, + param(sourceRealmURL), + ])) as { slug: string }[]; + assert.strictEqual(rows.length, 1, 'only one row is persisted'); + assert.strictEqual( + rows[0].slug, + a.body.data.attributes.slug, + 'the persisted slug matches what both requests returned', + ); + }); + test('regenerate mints a new slug', async function (assert) { let first = await post(defaultToken, { sourceRealmURL }); let regenerated = await post(defaultToken, { From b78b77dafbbde37db0678e81be419cb3eb31d5c2 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 19 Jun 2026 16:46:58 -0500 Subject: [PATCH 11/11] Clean up unlisted-link slug on realm deletion Prevents a realm recreated at the same endpoint from reusing the prior unguessable slug, which would expose it to holders of the old unlisted link. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../handlers/handle-delete-realm.ts | 9 ++++++++ .../server-endpoints/delete-realm-test.ts | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/realm-server/handlers/handle-delete-realm.ts b/packages/realm-server/handlers/handle-delete-realm.ts index 92b28c1ef1..15af19563d 100644 --- a/packages/realm-server/handlers/handle-delete-realm.ts +++ b/packages/realm-server/handlers/handle-delete-realm.ts @@ -208,6 +208,15 @@ export default function handleDeleteRealm({ ` AND removed_at IS NULL`, ]); + // Server-issued unlisted-link slug for this realm. Hard-delete it so a + // realm later recreated at the same endpoint can't reuse the old + // unguessable slug — which would expose the new realm to anyone holding + // the previous unlisted URL. + await q([ + `DELETE FROM unlisted_realm_paths WHERE source_realm_url = `, + param(realmURL), + ]); + await removeRealmPermissions(dbAdapter, parsedRealmURL, txQuerier); await removeRealmDatabaseArtifacts({ dbAdapter, diff --git a/packages/realm-server/tests/server-endpoints/delete-realm-test.ts b/packages/realm-server/tests/server-endpoints/delete-realm-test.ts index c0a88b9038..86c0b1b888 100644 --- a/packages/realm-server/tests/server-endpoints/delete-realm-test.ts +++ b/packages/realm-server/tests/server-endpoints/delete-realm-test.ts @@ -278,6 +278,20 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { insert('claimed_domains_for_sites', nameExpressions, valueExpressions), ); + let unlisted = asExpressions({ + source_realm_url: realmURL, + slug: 'deleteslugexample', + owner_user_id: ownerUserId, + }); + await query( + context.dbAdapter, + insert( + 'unlisted_realm_paths', + unlisted.nameExpressions, + unlisted.valueExpressions, + ), + ); + // Phase 3: source realm is mounted lazily. The publish handler // above called reconciler.lookupOrMount(sourceRealmURL), so by now // the source realm is in realms[]. @@ -603,6 +617,15 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { 'claimed domains are soft deleted', ); + let unlistedPaths = (await context.dbAdapter.execute( + `SELECT slug FROM unlisted_realm_paths WHERE source_realm_url = '${realmURL}'`, + )) as { slug: string }[]; + assert.strictEqual( + unlistedPaths.length, + 0, + 'unlisted-link slug is removed so a recreated realm cannot reuse it', + ); + assert.notOk( context.testRealmServer.testingOnlyRealms.find( (realm) => realm.url === realmURL,