diff --git a/packages/host/app/services/realm.ts b/packages/host/app/services/realm.ts index ca46316611..ece37d92e0 100644 --- a/packages/host/app/services/realm.ts +++ b/packages/host/app/services/realm.ts @@ -127,8 +127,6 @@ export interface RealmPrivateDependencyReport { warningTypes?: PublishabilityWarningType[]; } -type RealmInfoProperty = 'backgroundURL' | 'iconURL'; - type AuthStatus = | { type: 'logged-in'; token: string; claims: JWTPayload } | { type: 'anonymous' }; @@ -498,45 +496,6 @@ class RealmResource { }); }); - async setRealmInfoProperty( - property: RealmInfoProperty, - value: string | null, - ): Promise { - await this.loginTask.perform(); - let headers: Record = { - Accept: SupportedMimeType.JSON, - Authorization: `Bearer ${this.token}`, - }; - let response = await this.network.authedFetch(`${this.realmURL}_config`, { - method: 'PATCH', - headers, - body: JSON.stringify({ - data: { - type: 'realm-config', - id: this.url, - attributes: { [property]: value }, - }, - }), - }); - - if (response.status !== 200) { - throw new Error( - `Failed to set realm config property '${property}' for realm ${this.url}: ${response.status}`, - ); - } - let json = await waitForPromise(response.json()); - let isPublic = Boolean( - response.headers.get('x-boxel-realm-public-readable'), - ); - let updatedInfo = new TrackedObject({ - url: json.data.id, - ...json.data.attributes, - isIndexing: this.info?.isIndexing ?? false, - isPublic, - }) as EnhancedRealmInfo; - this.info = updatedInfo; - } - async fetchRealmPermissions() { return await this.fetchRealmPermissionsTask.perform(); } diff --git a/packages/realm-server/handlers/create-realm.ts b/packages/realm-server/handlers/create-realm.ts index ea3d7b3ee9..ce94b584a9 100644 --- a/packages/realm-server/handlers/create-realm.ts +++ b/packages/realm-server/handlers/create-realm.ts @@ -152,7 +152,7 @@ export async function createRealm( // publishable lives in realm_metadata. A fresh realm has no // hostRoutingRules to seed (host mode picks them up from the - // realm.json card once an operator sets one via /_config). Reset + // realm.json card once an operator edits one). Reset // all mutable metadata columns on conflict so a stale row (e.g. // left over from a previous realm at the same URL whose delete // didn't clean up) doesn't bleed into the new realm. diff --git a/packages/realm-server/tests/realm-endpoints-test.ts b/packages/realm-server/tests/realm-endpoints-test.ts index 47e208e351..d6b5f502b6 100644 --- a/packages/realm-server/tests/realm-endpoints-test.ts +++ b/packages/realm-server/tests/realm-endpoints-test.ts @@ -4,15 +4,7 @@ import supertest from 'supertest'; import { join, resolve, basename } from 'path'; import type { RealmHttpServer as Server } from '../server.ts'; import { dirSync, type DirResult } from 'tmp'; -import { - copySync, - ensureDirSync, - existsSync, - readFileSync, - readJSONSync, - removeSync, - writeFileSync, -} from 'fs-extra'; +import { copySync, ensureDirSync, readFileSync, readJSONSync } from 'fs-extra'; import { utimesSync } from 'fs'; import type { Realm } from '@cardstack/runtime-common'; import { @@ -348,494 +340,6 @@ module(basename(__filename), function () { ); }); - module('realm config patch', function (hooks) { - let realmConfigPath: string; - let initialConfig: any; - - hooks.beforeEach(function () { - realmConfigPath = join( - dir.name, - 'realm_server_1', - 'test', - 'realm.json', - ); - initialConfig = existsSync(realmConfigPath) - ? readJSONSync(realmConfigPath) - : undefined; - }); - - test('non-owner cannot patch realm config', async function (assert) { - let response = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'carol', ['read', 'write'])}`, - ) - .send({ - data: { - type: 'realm-config', - attributes: { backgroundURL: 'new-bg' }, - }, - }); - - assert.strictEqual(response.status, 403, 'HTTP 403 status'); - if (initialConfig) { - assert.deepEqual( - readJSONSync(realmConfigPath), - initialConfig, - 'realm.json card was not modified', - ); - } - }); - - test('realm-owner can patch allowed realm config property', async function (assert) { - let response = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`, - ) - .send({ - data: { - type: 'realm-config', - attributes: { backgroundURL: 'new-bg' }, - }, - }); - - assert.strictEqual(response.status, 200, 'HTTP 200 status'); - assert.deepEqual( - response.body, - { - data: { - id: testRealmHref, - type: 'realm-config', - attributes: { - ...testRealmInfo, - backgroundURL: 'new-bg', - }, - }, - }, - 'response includes updated realm info', - ); - // backgroundURL is owned by the RealmConfig card, not the sidecar. - let cardPath = join(dir.name, 'realm_server_1', 'test', 'realm.json'); - let cardDoc = readJSONSync(cardPath); - assert.strictEqual( - cardDoc.data.attributes.backgroundURL, - 'new-bg', - 'realm.json card contains the updated backgroundURL', - ); - }); - - test('can clear card-owned URL fields by patching null', async function (assert) { - let auth = `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`; - - // First set non-null values so we have something to clear. - let setResponse = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set('Authorization', auth) - .send({ - data: { - type: 'realm-config', - attributes: { - backgroundURL: 'http://example.com/bg.jpg', - iconURL: 'http://example.com/icon.png', - }, - }, - }); - assert.strictEqual(setResponse.status, 200, 'set HTTP 200 status'); - assert.strictEqual( - setResponse.body.data.attributes.backgroundURL, - 'http://example.com/bg.jpg', - 'backgroundURL set in overlay', - ); - assert.strictEqual( - setResponse.body.data.attributes.iconURL, - 'http://example.com/icon.png', - 'iconURL set in overlay', - ); - - // Now clear them. - let clearResponse = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set('Authorization', auth) - .send({ - data: { - type: 'realm-config', - attributes: { backgroundURL: null, iconURL: null }, - }, - }); - assert.strictEqual(clearResponse.status, 200, 'clear HTTP 200 status'); - assert.strictEqual( - clearResponse.body.data.attributes.backgroundURL, - null, - 'backgroundURL is null after patching null', - ); - assert.strictEqual( - clearResponse.body.data.attributes.iconURL, - null, - 'iconURL is null after patching null', - ); - }); - - test('allows any property except showAsCatalog', async function (assert) { - let response = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`, - ) - .send({ - data: { - type: 'realm-config', - attributes: { publishable: true }, - }, - }); - - assert.strictEqual(response.status, 200, 'HTTP 200 status'); - assert.true( - response.body.data.attributes.publishable, - 'response includes publishable: true (sourced from realm_metadata)', - ); - // publishable lives in realm_metadata now, not the sidecar. - if (initialConfig) { - assert.deepEqual( - readJSONSync(realmConfigPath), - initialConfig, - 'realm.json card is untouched by a publishable PATCH', - ); - } - - let showAsCatalogResponse = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`, - ) - .send({ - data: { - type: 'realm-config', - attributes: { showAsCatalog: true }, - }, - }); - - assert.strictEqual( - showAsCatalogResponse.status, - 400, - 'HTTP 400 status when attempting to set showAsCatalog', - ); - if (initialConfig) { - assert.deepEqual( - readJSONSync(realmConfigPath), - initialConfig, - 'realm.json card remains unchanged after disallowed property', - ); - } - }); - - test('card responses reflect updated realm config without re-indexing', async function (assert) { - // Fetch a card before updating realm config - let cardResponse = await request - .get('/person-1') - .set('Accept', 'application/vnd.card+json'); - assert.strictEqual(cardResponse.status, 200, 'HTTP 200 status'); - assert.deepEqual( - cardResponse.body.data.meta.realmInfo, - testRealmInfo, - 'card has original realmInfo before config change', - ); - - // Update the realm config - let patchResponse = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`, - ) - .send({ - data: { - type: 'realm-config', - attributes: { name: 'Updated Realm Name' }, - }, - }); - assert.strictEqual(patchResponse.status, 200, 'config patch succeeded'); - - // Fetch the same card again — realmInfo should reflect the new config - // without needing to re-index - let updatedCardResponse = await request - .get('/person-1') - .set('Accept', 'application/vnd.card+json'); - assert.strictEqual(updatedCardResponse.status, 200, 'HTTP 200 status'); - assert.strictEqual( - updatedCardResponse.body.data.meta.realmInfo.name, - 'Updated Realm Name', - 'card realmInfo reflects updated realm name without re-indexing', - ); - }); - - test('card ETag invalidates after realm config change so old If-None-Match does not 304 stale realmInfo', async function (assert) { - // Capture the pre-PATCH ETag. - let initialResponse = await request - .get('/person-1') - .set('Accept', 'application/vnd.card+json'); - assert.strictEqual(initialResponse.status, 200, 'initial GET succeeds'); - let initialEtag = initialResponse.headers['etag']; - assert.ok(initialEtag, 'initial response carries an ETag'); - - // Change the realm name — this nulls the cached realmInfo without - // touching boxel_index, so a naive `indexed_at`-only ETag would - // still match and 304 with stale `meta.realmInfo`. The fix folds - // a hash of the realmInfo into the ETag base. - let patchResponse = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`, - ) - .send({ - data: { - type: 'realm-config', - attributes: { name: 'Etag Invalidation Test Realm' }, - }, - }); - assert.strictEqual(patchResponse.status, 200, 'config patch succeeded'); - - // Replay the GET with the OLD ETag. It must NOT 304: the assembled - // body now has a different `meta.realmInfo.name`, so the validator - // has to recognize that as a content change. - let revalidationResponse = await request - .get('/person-1') - .set('Accept', 'application/vnd.card+json') - .set('If-None-Match', initialEtag); - assert.strictEqual( - revalidationResponse.status, - 200, - 'old ETag does not match after /_config PATCH; server returns full 200', - ); - assert.notStrictEqual( - revalidationResponse.headers['etag'], - initialEtag, - 'fresh response carries a different ETag', - ); - assert.strictEqual( - revalidationResponse.body.data.meta.realmInfo.name, - 'Etag Invalidation Test Realm', - 'fresh response reflects the post-PATCH realm name', - ); - }); - - test('returns bad request for invalid json body', async function (assert) { - let response = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set('Content-Type', 'application/json') - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`, - ) - .send('{"data":'); - - assert.strictEqual(response.status, 400, 'HTTP 400 status'); - assert.ok( - response.body.errors?.[0]?.message?.startsWith( - 'The request body was not json', - ), - 'error message indicates malformed json', - ); - if (initialConfig) { - assert.deepEqual( - readJSONSync(realmConfigPath), - initialConfig, - 'realm.json card remains unchanged after invalid request', - ); - } else { - assert.false( - existsSync(realmConfigPath), - 'realm.json card is not created on invalid request', - ); - } - }); - - test('returns bad request when request structure is missing data or attributes', async function (assert) { - let missingDataResponse = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`, - ) - .send({}); - - assert.strictEqual( - missingDataResponse.status, - 400, - 'HTTP 400 status for missing data', - ); - - let missingAttributesResponse = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`, - ) - .send({ data: { type: 'realm-config' } }); - - assert.strictEqual( - missingAttributesResponse.status, - 400, - 'HTTP 400 status for missing attributes', - ); - - if (initialConfig) { - assert.deepEqual( - readJSONSync(realmConfigPath), - initialConfig, - 'realm.json card remains unchanged after malformed requests', - ); - } else { - assert.false( - existsSync(realmConfigPath), - 'realm.json card is not created when payloads are malformed', - ); - } - }); - - test('returns bad request when property name is empty', async function (assert) { - let response = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`, - ) - .send({ - data: { - type: 'realm-config', - attributes: { '': 'value' }, - }, - }); - - assert.strictEqual(response.status, 400, 'HTTP 400 status'); - assert.strictEqual( - response.body.errors?.[0]?.message, - 'Property names cannot be empty', - 'error message indicates empty property name is not allowed', - ); - if (initialConfig) { - assert.deepEqual( - readJSONSync(realmConfigPath), - initialConfig, - 'realm.json card remains unchanged after empty property name', - ); - } else { - assert.false( - existsSync(realmConfigPath), - 'realm.json card is not created when property name is empty', - ); - } - }); - - test('returns error when the existing RealmConfig card cannot be parsed', async function (assert) { - let invalidContent = '{ "data": { "type": "card" '; - writeFileSync(realmConfigPath, invalidContent); - - try { - // `name` is a card-owned field, so a PATCH that targets it - // reaches the card-read/parse path in patchRealmConfig. A - // malformed realm.json at this point surfaces a 500 with the - // "Unable to parse existing realm config card" message. - let response = await request - .patch('/_config') - .set('Accept', SupportedMimeType.JSON) - .set( - 'Authorization', - `Bearer ${createJWT(testRealm, 'user', [ - 'read', - 'write', - 'realm-owner', - ])}`, - ) - .send({ - data: { - type: 'realm-config', - attributes: { name: 'Patched Name' }, - }, - }); - - assert.strictEqual(response.status, 500, 'HTTP 500 status'); - assert.ok( - response.body.errors?.[0]?.message?.startsWith( - 'Unable to parse existing realm config card:', - ), - 'error message indicates parsing failure', - ); - assert.strictEqual( - readFileSync(realmConfigPath, 'utf8'), - invalidContent, - 'realm.json card remains unchanged when existing file is invalid', - ); - } finally { - if (initialConfig) { - writeFileSync( - realmConfigPath, - JSON.stringify(initialConfig, null, 2) + '\\n', - ); - } else { - removeSync(realmConfigPath); - } - } - }); - }); - test('serves module requests through read-through cache', async function (assert) { let modulePath = 'module-cache-test.js'; let authHeader = `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`; diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 9d8d40e287..b26fa4acf8 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -111,8 +111,6 @@ import merge from 'lodash/merge'; import mergeWith from 'lodash/mergeWith'; import cloneDeep from 'lodash/cloneDeep'; import isEqual from 'lodash/isEqual'; -import isPlainObject from 'lodash/isPlainObject'; -import { z } from 'zod'; import { inferContentType } from './infer-content-type.ts'; import { fileContentToText, @@ -224,8 +222,6 @@ export type RealmInfo = { includePrerenderedDefaultRealmIndex?: boolean | null; }; -const PROTECTED_REALM_CONFIG_PROPERTIES = ['showAsCatalog']; - // Marker header the host SPA attaches to outbound _federated-search / // _search calls when it's running inside a prerender tab. The prerender // server uses puppeteer's `evaluateOnNewDocument` to inject a window @@ -246,21 +242,6 @@ function isDuringPrerenderRequest(request: Request): boolean { return (request.headers.get(DURING_PRERENDER_HEADER) ?? '').length > 0; } -// Fields owned by the RealmConfig card instance at /realm.json. A PATCH -// /_config attribute outside this set and outside REALM_CONFIG_METADATA_- -// PROPERTIES is rejected — unrecognized keys have no storage target. -const REALM_CONFIG_CARD_PROPERTIES = new Set([ - 'name', - 'backgroundURL', - 'iconURL', - 'hostRoutingRules', - 'includePrerenderedDefaultRealmIndex', -]); - -// Fields owned by the realm_metadata DB table. Routes through -// upsertRealmMetadata. -const REALM_CONFIG_METADATA_PROPERTIES = new Set(['publishable']); - export interface FileRef { path: LocalPath; content: ReadableStream | Readable | Uint8Array | string; @@ -810,10 +791,11 @@ export class Realm { #virtualNetwork: VirtualNetwork; #cachedRealmInfo: RealmInfo | null = null; // md5 of the JSON-stringified `#cachedRealmInfo`. Folded into the - // card+json ETag so a /_config PATCH (or any other path that nulls - // `#cachedRealmInfo`) invalidates cached card responses, even - // though the index row's `indexed_at` doesn't bump on a config - // change. Recomputed lazily alongside the cached realm info. + // card+json ETag so any path that nulls `#cachedRealmInfo` (e.g. + // invalidateCachedRealmInfo on publish/unpublish) invalidates cached + // card responses, even though the index row's `indexed_at` doesn't + // bump on a config change. Recomputed lazily alongside the cached + // realm info. #cachedRealmInfoHash: string | null = null; // This loader is not meant to be used operationally, rather it serves as a @@ -951,11 +933,6 @@ export class Realm { this.#router = new Router(new URL(url)) .get('/_info', SupportedMimeType.RealmInfo, this.realmInfo.bind(this)) .query('/_info', SupportedMimeType.RealmInfo, this.realmInfo.bind(this)) - .patch( - '/_config', - SupportedMimeType.JSON, - this.patchRealmConfig.bind(this), - ) .query('/_lint', SupportedMimeType.JSON, this.lint.bind(this)) .get('/_mtimes', SupportedMimeType.Mtimes, this.realmMtimes.bind(this)) // Deprecated: legacy single-realm live-card search (the bound @@ -2804,8 +2781,6 @@ export class Realm { let requiredPermission: RealmAction = 'read'; if (localPath === '_permissions') { requiredPermission = 'realm-owner'; - } else if (localPath === '_config' && request.method === 'PATCH') { - requiredPermission = 'realm-owner'; } else if (['PUT', 'PATCH', 'POST', 'DELETE'].includes(request.method)) { requiredPermission = 'write'; } @@ -6621,43 +6596,6 @@ export class Realm { } } - // Upserts the patch into realm_metadata for this realm. Only the - // provided keys are written; absent keys retain their existing column - // values via COALESCE on the EXCLUDED row's NULL. Pass an explicit - // null to clear a column. - private async upsertRealmMetadata(patch: { - publishable?: boolean | null; - showAsCatalog?: boolean | null; - }): Promise { - if (patch.publishable === undefined && patch.showAsCatalog === undefined) { - return; - } - let publishable = - patch.publishable === undefined ? null : patch.publishable; - let showAsCatalog = - patch.showAsCatalog === undefined ? null : patch.showAsCatalog; - let publishableProvided = patch.publishable !== undefined; - let showAsCatalogProvided = patch.showAsCatalog !== undefined; - await query(this.#dbAdapter, [ - `INSERT INTO realm_metadata (url, publishable, show_as_catalog) VALUES (`, - param(this.url), - `,`, - param(publishable), - `,`, - param(showAsCatalog), - `) ON CONFLICT (url) DO UPDATE SET `, - // Update only the columns that were provided; preserve the - // existing values of the others. - ...(publishableProvided - ? [`publishable = `, param(publishable), `, `] - : []), - ...(showAsCatalogProvided - ? [`show_as_catalog = `, param(showAsCatalog), `, `] - : []), - `updated_at = now()`, - ]); - } - async getRealmInfo(): Promise { if (!this.#cachedRealmInfo) { this.#cachedRealmInfo = await this.parseRealmInfo(); @@ -6710,7 +6648,7 @@ export class Realm { }; // Overlay from the RealmConfig card file at /realm.json on disk. The - // file is the source of truth — patchRealmConfig writes it, publish + // file is the source of truth — card writes update it, publish // copySync's it from the source realm — and exists before the indexer // ever processes it. Reading from disk closes the gap during indexing, // when /_info can fire mid-pass via the prerender host's cardRender: @@ -6804,222 +6742,6 @@ export class Realm { return realmInfo; } - private async patchRealmConfig( - request: Request, - requestContext: RequestContext, - ): Promise { - let json: unknown; - try { - json = await request.json(); - } catch (e: any) { - return badRequest({ - message: `The request body was not json: ${e.message}`, - requestContext, - }); - } - - const realmConfigPatchSchema = z.object({ - data: z.object({ - type: z.literal('realm-config'), - attributes: z.record(z.unknown()), - }), - }); - - let parsed = realmConfigPatchSchema.safeParse(json); - if (!parsed.success) { - let message = - parsed.error.issues.map((issue: any) => issue.message).join(', ') || - 'The request body was invalid'; - return badRequest({ message, requestContext }); - } - - let { attributes } = parsed.data.data; - - if (Object.keys(attributes).length === 0) { - return badRequest({ - message: 'At least one property must be provided', - requestContext, - }); - } - - let emptyProperty = Object.keys(attributes).find( - (property) => property.trim().length === 0, - ); - if (emptyProperty !== undefined) { - return badRequest({ - message: 'Property names cannot be empty', - requestContext, - }); - } - - let protectedProperty = Object.keys(attributes).find((property) => - PROTECTED_REALM_CONFIG_PROPERTIES.includes(property), - ); - - if (protectedProperty) { - return badRequest({ - message: `${protectedProperty} cannot be updated`, - requestContext, - }); - } - - // Validate types of fields bound for realm_metadata BEFORE any - // writes. The patch schema accepts arbitrary attribute values - // (z.record(z.unknown())); without this check a non-boolean - // would reach the SQL boolean column and surface as an opaque - // 500. Card and sidecar fields rely on their own downstream - // validation; the DB-bound fields don't have one. - if ('publishable' in attributes) { - let publishableValue = attributes.publishable; - if (publishableValue !== null && typeof publishableValue !== 'boolean') { - return badRequest({ - message: `'publishable' must be a boolean or null`, - requestContext, - }); - } - } - - let cardAttrs: Record = {}; - let metadataAttrs: Record = {}; - let unknownKeys: string[] = []; - for (let [key, value] of Object.entries(attributes)) { - if (REALM_CONFIG_CARD_PROPERTIES.has(key)) { - cardAttrs[key] = value; - } else if (REALM_CONFIG_METADATA_PROPERTIES.has(key)) { - metadataAttrs[key] = value; - } else { - unknownKeys.push(key); - } - } - if (unknownKeys.length > 0) { - return badRequest({ - message: `Unknown realm config attribute(s): ${unknownKeys.join(', ')}`, - requestContext, - }); - } - - // Read and validate the card before writing anything. A mixed PATCH - // like { name, publishable } touches the card and the realm_metadata - // table; we don't want a malformed card to surface 500 *after* the - // metadata has already been mutated. - let cardPath: LocalPath | undefined; - let cardDoc: - | { - data: { - type: string; - attributes?: Record; - meta: { adoptsFrom: { module: string; name: string } }; - }; - } - | undefined; - if (Object.keys(cardAttrs).length > 0) { - cardPath = this.paths.local(this.paths.fileURL('realm.json')); - cardDoc = { - data: { - type: 'card', - attributes: {}, - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/realm-config', - name: 'RealmConfig', - }, - }, - }, - }; - let existingCard = await this.readFileAsText(cardPath, undefined); - if (existingCard?.content) { - let parsed: unknown; - try { - parsed = JSON.parse(existingCard.content); - } catch (e: any) { - return systemError({ - requestContext, - message: `Unable to parse existing realm config card: ${e.message}`, - }); - } - if (!isPlainObject(parsed)) { - return systemError({ - requestContext, - message: `Existing realm config card is not a JSON object`, - }); - } - cardDoc = parsed as typeof cardDoc; - cardDoc!.data = cardDoc!.data ?? ({} as any); - if (!isPlainObject(cardDoc!.data)) { - return systemError({ - requestContext, - message: `Existing realm config card data is not a JSON object`, - }); - } - let adoptsFrom = (cardDoc!.data as any).meta?.adoptsFrom; - if ( - !isPlainObject(adoptsFrom) || - adoptsFrom.module !== 'https://cardstack.com/base/realm-config' || - adoptsFrom.name !== 'RealmConfig' - ) { - return systemError({ - requestContext, - message: `Existing realm config card does not adopt from RealmConfig`, - }); - } - let existingAttrs = cardDoc!.data.attributes; - if (existingAttrs != null && !isPlainObject(existingAttrs)) { - return systemError({ - requestContext, - message: `Existing realm config card attributes is not a JSON object`, - }); - } - cardDoc!.data.attributes = existingAttrs ?? {}; - } - // `name` is exposed on the public RealmInfo shape but stored on the - // RealmConfig card under cardInfo.name (the standard CardDef slot - // that drives cardTitle). Translate so PATCH /_config callers can - // keep sending { name: ... } unchanged. - for (let [key, value] of Object.entries(cardAttrs)) { - if (key === 'name') { - let existingCardInfo = (cardDoc!.data.attributes!.cardInfo ?? - {}) as Record; - cardDoc!.data.attributes!.cardInfo = { - ...existingCardInfo, - name: value, - }; - } else { - cardDoc!.data.attributes![key] = value; - } - } - } - - if (cardPath !== undefined && cardDoc !== undefined) { - await this.write(cardPath, JSON.stringify(cardDoc, null, 2) + '\n'); - } - if (Object.keys(metadataAttrs).length > 0) { - await this.upsertRealmMetadata({ - publishable: - 'publishable' in metadataAttrs - ? (metadataAttrs.publishable as boolean | null) - : undefined, - }); - } - - this.invalidateCachedRealmInfo(); - - let realmInfo = await this.parseRealmInfo(); - let doc = { - data: { - id: this.url, - type: 'realm-config', - attributes: realmInfo, - }, - }; - return createResponse({ - body: JSON.stringify(doc, null, 2), - init: { - headers: { 'content-type': SupportedMimeType.JSON }, - }, - requestContext, - }); - } - private async realmInfo( _request: Request, requestContext: RequestContext,