diff --git a/packages/realm-server/handlers/archive-realm-utils.ts b/packages/realm-server/handlers/archive-realm-utils.ts new file mode 100644 index 0000000000..15242d2bc4 --- /dev/null +++ b/packages/realm-server/handlers/archive-realm-utils.ts @@ -0,0 +1,129 @@ +import type Koa from 'koa'; +import { + fetchRealmPermissions, + param, + query, + type DBAdapter, + type RealmPermissions, +} from '@cardstack/runtime-common'; +import { + fetchRequestFromContext, + sendResponseForBadRequest, + sendResponseForForbiddenRequest, + sendResponseForNotFound, + sendResponseForUnprocessableEntity, + sendResponseForSystemError, +} from '../middleware/index.ts'; +import { normalizeRealmURL } from '../utils/realm-url.ts'; +import type { RealmServerTokenClaim } from '../utils/jwt.ts'; + +export interface ArchiveTarget { + realmURL: string; + ownerUserId: string; + permissions: RealmPermissions; +} + +// Parse the JSON:API body, resolve the target realm URL, and authorize the +// request for both archive and unarchive. Returns the resolved target, or +// null after writing the appropriate error response (caller should return). +// +// Rules (shared by both endpoints): +// - body id must be a valid realm URL, else 400; +// - a source realm_registry row must exist for it, else 404 (a permission +// row alone is not proof the realm exists — a stale/manual realm-owner +// grant must not let an arbitrary URL be archived); +// - published/bootstrap realms are not archivable, else 422; +// - requester must be a realm-owner of the target realm, else 403; +// - public/catalog realms (world-readable) are not archivable, else 422. +export async function resolveAndAuthorizeArchiveTarget( + ctxt: Koa.Context, + dbAdapter: DBAdapter, + action: 'archive' | 'unarchive', +): Promise { + let token = ctxt.state.token as RealmServerTokenClaim; + if (!token) { + await sendResponseForSystemError( + ctxt, + `token is required to ${action} realm`, + ); + return null; + } + + let request = await fetchRequestFromContext(ctxt); + let body = await request.text(); + let json: Record; + try { + json = JSON.parse(body); + } catch (e) { + await sendResponseForBadRequest( + ctxt, + 'Request body is not valid JSON-API - invalid JSON', + ); + return null; + } + + let realmId = json?.data?.id; + if (typeof realmId !== 'string' || realmId.length === 0) { + await sendResponseForBadRequest( + ctxt, + 'Request body must be JSON-API with { data: { type: "realm", id: } }', + ); + return null; + } + + // Normalize through the shared realm-URL normalizer (strips query/fragment, + // exactly one trailing slash) so the lookup hits the canonical + // realm_registry / realm_user_permissions / realm_metadata rows. Returns + // null for an invalid URL → 400 rather than throwing a system error. + let parsedRealmURL = normalizeRealmURL(realmId); + if (!parsedRealmURL) { + await sendResponseForBadRequest( + ctxt, + `Invalid realm URL supplied: ${realmId}`, + ); + return null; + } + let realmURL = parsedRealmURL.href; + + // realm_registry is the source of truth for realm existence. Only source + // realms are archivable: published snapshots and bootstrap (base/catalog) + // realms are not. + let registryRow = (await query(dbAdapter, [ + `SELECT kind FROM realm_registry WHERE url =`, + param(realmURL), + ])) as { kind: string }[]; + if (registryRow.length === 0) { + await sendResponseForNotFound(ctxt, `Realm not found: ${realmURL}`); + return null; + } + if (registryRow[0].kind !== 'source') { + await sendResponseForUnprocessableEntity( + ctxt, + `Realm ${realmURL} is a ${registryRow[0].kind} realm and cannot be ${action}d`, + ); + return null; + } + + let { user: ownerUserId } = token; + let permissions = await fetchRealmPermissions(dbAdapter, parsedRealmURL); + + if (!permissions[ownerUserId]?.includes('realm-owner')) { + await sendResponseForForbiddenRequest( + ctxt, + `${ownerUserId} does not have enough permission to ${action} realm ${realmURL}`, + ); + return null; + } + + // Public/catalog realms are world-readable; archiving would hide a shared + // resource, so reject them. + if (permissions['*']?.includes('read')) { + await sendResponseForUnprocessableEntity( + ctxt, + `Realm ${realmURL} is public and cannot be ${action}d`, + ); + return null; + } + + return { realmURL, ownerUserId, permissions }; +} diff --git a/packages/realm-server/handlers/handle-archive-realm.ts b/packages/realm-server/handlers/handle-archive-realm.ts new file mode 100644 index 0000000000..bf278ceae8 --- /dev/null +++ b/packages/realm-server/handlers/handle-archive-realm.ts @@ -0,0 +1,64 @@ +import type Koa from 'koa'; +import { + archiveRealm, + createResponse, + logger, + SupportedMimeType, + type Realm, +} from '@cardstack/runtime-common'; +import * as Sentry from '@sentry/node'; +import { + sendResponseForSystemError, + setContextResponse, +} from '../middleware/index.ts'; +import type { CreateRoutesArgs } from '../routes.ts'; +import { resolveAndAuthorizeArchiveTarget } from './archive-realm-utils.ts'; + +const log = logger('handle-archive'); + +export default function handleArchiveRealm({ + dbAdapter, +}: CreateRoutesArgs): (ctxt: Koa.Context, next: Koa.Next) => Promise { + return async function (ctxt: Koa.Context, _next: Koa.Next) { + let target = await resolveAndAuthorizeArchiveTarget( + ctxt, + dbAdapter, + 'archive', + ); + if (!target) { + return; + } + let { realmURL, permissions } = target; + + try { + await archiveRealm(dbAdapter, new URL(realmURL)); + + let response = createResponse({ + body: JSON.stringify( + { + data: { + type: 'realm', + id: realmURL, + attributes: { archived: true }, + }, + }, + null, + 2, + ), + init: { + status: 200, + headers: { 'content-type': SupportedMimeType.JSONAPI }, + }, + requestContext: { + realm: { url: realmURL } as Realm, + permissions, + }, + }); + await setContextResponse(ctxt, response); + } catch (error: any) { + log.error(`Error archiving realm ${realmURL}:`, error); + Sentry.captureException(error); + await sendResponseForSystemError(ctxt, error.message); + } + }; +} diff --git a/packages/realm-server/handlers/handle-unarchive-realm.ts b/packages/realm-server/handlers/handle-unarchive-realm.ts new file mode 100644 index 0000000000..2b55aa87e7 --- /dev/null +++ b/packages/realm-server/handlers/handle-unarchive-realm.ts @@ -0,0 +1,78 @@ +import type Koa from 'koa'; +import { + createResponse, + logger, + SupportedMimeType, + systemInitiatedPriority, + unarchiveRealm, + type Realm, +} from '@cardstack/runtime-common'; +import * as Sentry from '@sentry/node'; +import { + sendResponseForSystemError, + setContextResponse, +} from '../middleware/index.ts'; +import type { CreateRoutesArgs } from '../routes.ts'; +import { resolveAndAuthorizeArchiveTarget } from './archive-realm-utils.ts'; + +const log = logger('handle-unarchive'); + +export default function handleUnarchiveRealm({ + dbAdapter, + queue, +}: CreateRoutesArgs): (ctxt: Koa.Context, next: Koa.Next) => Promise { + return async function (ctxt: Koa.Context, _next: Koa.Next) { + let target = await resolveAndAuthorizeArchiveTarget( + ctxt, + dbAdapter, + 'unarchive', + ); + if (!target) { + return; + } + let { realmURL, permissions } = target; + + try { + await unarchiveRealm(dbAdapter, new URL(realmURL)); + + // A realm's index is left to rot while it is archived, so restoring it + // requires a full reindex to rebuild boxel_index from disk. Enqueue + // (rather than awaiting) so the response returns promptly; the indexer + // owns how a restored realm is brought back into the index sweep. + await queue.publish({ + jobType: `full-reindex`, + concurrencyGroup: `full-reindex-group`, + timeout: 6 * 60, + priority: systemInitiatedPriority, + args: { realmUrls: [realmURL] }, + }); + + let response = createResponse({ + body: JSON.stringify( + { + data: { + type: 'realm', + id: realmURL, + attributes: { archived: false }, + }, + }, + null, + 2, + ), + init: { + status: 200, + headers: { 'content-type': SupportedMimeType.JSONAPI }, + }, + requestContext: { + realm: { url: realmURL } as Realm, + permissions, + }, + }); + await setContextResponse(ctxt, response); + } catch (error: any) { + log.error(`Error unarchiving realm ${realmURL}:`, error); + Sentry.captureException(error); + await sendResponseForSystemError(ctxt, error.message); + } + }; +} diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index e3ff140087..f3777da6e8 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -19,6 +19,8 @@ import handleFetchUserRequest from './handlers/handle-fetch-user.ts'; import handleStripeWebhookRequest from './handlers/handle-stripe-webhook.ts'; import handlePublishRealm from './handlers/handle-publish-realm.ts'; import handleUnpublishRealm from './handlers/handle-unpublish-realm.ts'; +import handleArchiveRealm from './handlers/handle-archive-realm.ts'; +import handleUnarchiveRealm from './handlers/handle-unarchive-realm.ts'; import { healthCheck, jwtMiddleware, @@ -290,6 +292,16 @@ export function createRoutes(args: CreateRoutesArgs) { jwtMiddleware(args.realmSecretSeed), handleUnpublishRealm(args), ); + router.post( + '/_archive-realm', + jwtMiddleware(args.realmSecretSeed), + handleArchiveRealm(args), + ); + router.post( + '/_unarchive-realm', + jwtMiddleware(args.realmSecretSeed), + handleUnarchiveRealm(args), + ); // Grafana operator-action endpoints. All POST-only with // `Authorization: Bearer ` against the shared `grafanaSecret`. diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index d7f74cbd93..8bd7439f6d 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -292,6 +292,7 @@ const ALL_TEST_FILES: string[] = [ './realm-endpoints/search-v2-test', './realm-endpoints/user-test', './search-prerendered-test', + './server-endpoints/archive-realm-test', './server-endpoints/authentication-test', './server-endpoints/bot-commands-test', './server-endpoints/bot-registration-test', diff --git a/packages/realm-server/tests/queries-test.ts b/packages/realm-server/tests/queries-test.ts index 491e68742f..dbb816ac72 100644 --- a/packages/realm-server/tests/queries-test.ts +++ b/packages/realm-server/tests/queries-test.ts @@ -278,7 +278,7 @@ module(basename(import.meta.filename), function () { ); }); - test('fetchArchivedRealmsForOwner returns only the owner’s archived realms', async function (assert) { + test("fetchArchivedRealmsForOwner returns only the owner's archived realms", async function (assert) { const owner = '@owner:localhost'; const otherOwner = '@other:localhost'; diff --git a/packages/realm-server/tests/server-endpoints/archive-realm-test.ts b/packages/realm-server/tests/server-endpoints/archive-realm-test.ts new file mode 100644 index 0000000000..f63c4aee2e --- /dev/null +++ b/packages/realm-server/tests/server-endpoints/archive-realm-test.ts @@ -0,0 +1,224 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import { basename } from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { + insertPermissions, + isRealmArchived, + type RealmPermissions, +} from '@cardstack/runtime-common'; +import { realmSecretSeed } from '../helpers/index.ts'; +import { + insertSourceRealmInRegistry, + upsertPublishedRealmInRegistry, +} from '../../lib/realm-registry-writes.ts'; +import { createJWT as createRealmServerJWT } from '../../utils/jwt.ts'; +import { setupServerEndpointsTest, testRealmURL } from './helpers.ts'; + +function authHeader(user: string) { + return `Bearer ${createRealmServerJWT( + { user, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; +} + +module(`server-endpoints/${basename(import.meta.filename)}`, function () { + module('archive / unarchive realm endpoints', function (hooks) { + let context = setupServerEndpointsTest(hooks); + + // A fresh private realm URL, isolated per test. + function makeRealmURL() { + return `${testRealmURL.origin}/archive-${uuidv4()}/`; + } + + // Seed a source realm: a realm_registry row (the source of truth for + // existence) plus its permissions. + async function seedSourceRealm( + realmURL: string, + permissions: RealmPermissions, + ) { + await insertSourceRealmInRegistry(context.dbAdapter, { + url: realmURL, + diskId: uuidv4(), + ownerUsername: '@archive-owner:localhost', + }); + await insertPermissions( + context.dbAdapter, + new URL(realmURL), + permissions, + ); + } + + test('POST /_archive-realm lets an owner archive a realm', async function (assert) { + const owner = '@archive-owner:localhost'; + const realmURL = makeRealmURL(); + await seedSourceRealm(realmURL, { + [owner]: ['read', 'write', 'realm-owner'], + }); + + let response = await context.request + .post('/_archive-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', authHeader(owner)) + .send(JSON.stringify({ data: { type: 'realm', id: realmURL } })); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.deepEqual(response.body.data, { + type: 'realm', + id: realmURL, + attributes: { archived: true }, + }); + assert.true( + await isRealmArchived(context.dbAdapter, new URL(realmURL)), + 'realm is archived in the database', + ); + }); + + test('POST /_unarchive-realm lets an owner restore a realm and enqueues a full reindex', async function (assert) { + const owner = '@archive-owner:localhost'; + const realmURL = makeRealmURL(); + await seedSourceRealm(realmURL, { + [owner]: ['read', 'write', 'realm-owner'], + }); + + await context.request + .post('/_archive-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', authHeader(owner)) + .send(JSON.stringify({ data: { type: 'realm', id: realmURL } })); + + let response = await context.request + .post('/_unarchive-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', authHeader(owner)) + .send(JSON.stringify({ data: { type: 'realm', id: realmURL } })); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.deepEqual(response.body.data, { + type: 'realm', + id: realmURL, + attributes: { archived: false }, + }); + assert.false( + await isRealmArchived(context.dbAdapter, new URL(realmURL)), + 'realm is active again in the database', + ); + + let reindexJobs = await context.dbAdapter.execute( + `SELECT args FROM jobs WHERE job_type = 'full-reindex'`, + ); + assert.ok( + reindexJobs.some((row: any) => { + let args = row.args as { realmUrls?: string[] }; + return args?.realmUrls?.includes(realmURL); + }), + 'a full-reindex job was enqueued for the restored realm', + ); + }); + + test('POST /_archive-realm returns 403 for a non-owner', async function (assert) { + const owner = '@archive-owner:localhost'; + const intruder = '@intruder:localhost'; + const realmURL = makeRealmURL(); + await seedSourceRealm(realmURL, { + [owner]: ['read', 'write', 'realm-owner'], + [intruder]: ['read'], + }); + + let response = await context.request + .post('/_archive-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', authHeader(intruder)) + .send(JSON.stringify({ data: { type: 'realm', id: realmURL } })); + + assert.strictEqual(response.status, 403, 'HTTP 403 status'); + assert.false( + await isRealmArchived(context.dbAdapter, new URL(realmURL)), + 'realm is not archived', + ); + }); + + test('POST /_archive-realm rejects a public/catalog realm', async function (assert) { + const owner = '@archive-owner:localhost'; + const realmURL = makeRealmURL(); + await seedSourceRealm(realmURL, { + [owner]: ['read', 'write', 'realm-owner'], + '*': ['read'], + }); + + let response = await context.request + .post('/_archive-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', authHeader(owner)) + .send(JSON.stringify({ data: { type: 'realm', id: realmURL } })); + + assert.strictEqual(response.status, 422, 'HTTP 422 status'); + assert.false( + await isRealmArchived(context.dbAdapter, new URL(realmURL)), + 'public realm is not archived', + ); + }); + + test('POST /_archive-realm returns 404 when no source realm_registry row exists, even with an owner permission', async function (assert) { + const owner = '@archive-owner:localhost'; + const realmURL = makeRealmURL(); + // Permission row exists but the realm was never registered — a stale or + // manual grant must not be enough to archive an arbitrary URL. + await insertPermissions(context.dbAdapter, new URL(realmURL), { + [owner]: ['read', 'write', 'realm-owner'], + }); + + let response = await context.request + .post('/_archive-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', authHeader(owner)) + .send(JSON.stringify({ data: { type: 'realm', id: realmURL } })); + + assert.strictEqual(response.status, 404, 'HTTP 404 status'); + assert.false( + await isRealmArchived(context.dbAdapter, new URL(realmURL)), + 'unregistered realm is not archived', + ); + }); + + test('POST /_archive-realm rejects a published realm', async function (assert) { + const owner = '@archive-owner:localhost'; + const sourceRealmURL = makeRealmURL(); + const publishedRealmURL = makeRealmURL(); + await seedSourceRealm(sourceRealmURL, { + [owner]: ['read', 'write', 'realm-owner'], + }); + await upsertPublishedRealmInRegistry(context.dbAdapter, { + publishedRealmURL, + publishedRealmId: uuidv4(), + ownerUsername: owner, + sourceRealmURL, + lastPublishedAt: Date.now(), + }); + await insertPermissions(context.dbAdapter, new URL(publishedRealmURL), { + [owner]: ['read', 'realm-owner'], + }); + + let response = await context.request + .post('/_archive-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', authHeader(owner)) + .send( + JSON.stringify({ data: { type: 'realm', id: publishedRealmURL } }), + ); + + assert.strictEqual(response.status, 422, 'HTTP 422 status'); + assert.false( + await isRealmArchived(context.dbAdapter, new URL(publishedRealmURL)), + 'published realm is not archived', + ); + }); + }); +}); diff --git a/packages/runtime-common/db-queries/realm-metadata-queries.ts b/packages/runtime-common/db-queries/realm-metadata-queries.ts index 0dfe9d19d4..7377af1714 100644 --- a/packages/runtime-common/db-queries/realm-metadata-queries.ts +++ b/packages/runtime-common/db-queries/realm-metadata-queries.ts @@ -67,7 +67,9 @@ export async function fetchArchivedRealmsForOwner( AND rm.url NOT IN (SELECT url FROM realm_registry WHERE kind = 'published') AND rup.username =`, param(username), - `ORDER BY rm.archived_at DESC`, + // Secondary sort on url keeps ordering deterministic when several realms + // share an archived_at second (SQLite's CURRENT_TIMESTAMP is 1s-resolution). + `ORDER BY rm.archived_at DESC, rm.url ASC`, ])) as { url: string }[]; return results.map((r) => r.url); }