From 7c0717bfe7b73a5a991d4257f1183168ebe2ec9c Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Fri, 26 Jun 2026 06:39:49 -0400 Subject: [PATCH 1/3] Address review: deterministic archived-realms ordering + ASCII test title Add a secondary sort on url to fetchArchivedRealmsForOwner so the result order stays stable when several realms share an archived_at second (SQLite's CURRENT_TIMESTAMP is 1-second resolution). Replace a curly apostrophe in a test title with an ASCII one so module filters and copy/paste stay portable. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/realm-server/tests/queries-test.ts | 2 +- packages/runtime-common/db-queries/realm-metadata-queries.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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); } From de05154fea52328ea5c28551b8a586a70ab986b6 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 25 Jun 2026 18:26:21 -0400 Subject: [PATCH 2/3] Add owner-only archive/unarchive realm endpoints POST /_archive-realm and POST /_unarchive-realm set and clear the realm_metadata.archived_at flag for the realm named in the JSON:API body ({ data: { type: 'realm', id: realmURL } }). Both require the requester to be a realm-owner of the target (else 403) and reject public/catalog realms, which are world-readable and not archivable (else 422). Restore enqueues a full-reindex job for the realm to rebuild its index from disk; the indexer owns how a restored realm re-enters the sweep. Shared authorize/validate logic lives in archive-realm-utils. Integration tests cover the owner happy paths, the non-owner 403, and the public-realm rejection. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../handlers/archive-realm-utils.ts | 89 +++++++++++ .../handlers/handle-archive-realm.ts | 64 ++++++++ .../handlers/handle-unarchive-realm.ts | 78 ++++++++++ packages/realm-server/routes.ts | 12 ++ packages/realm-server/tests/index.ts | 1 + .../server-endpoints/archive-realm-test.ts | 141 ++++++++++++++++++ 6 files changed, 385 insertions(+) create mode 100644 packages/realm-server/handlers/archive-realm-utils.ts create mode 100644 packages/realm-server/handlers/handle-archive-realm.ts create mode 100644 packages/realm-server/handlers/handle-unarchive-realm.ts create mode 100644 packages/realm-server/tests/server-endpoints/archive-realm-test.ts 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..9db5270b1e --- /dev/null +++ b/packages/realm-server/handlers/archive-realm-utils.ts @@ -0,0 +1,89 @@ +import type Koa from 'koa'; +import { + ensureTrailingSlash, + fetchRealmPermissions, + type DBAdapter, + type RealmPermissions, +} from '@cardstack/runtime-common'; +import { + fetchRequestFromContext, + sendResponseForBadRequest, + sendResponseForForbiddenRequest, + sendResponseForUnprocessableEntity, + sendResponseForSystemError, +} from '../middleware/index.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). +// +// Authorization rules (shared by both endpoints): +// - 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; + } + + let realmURL = ensureTrailingSlash(realmId); + let { user: ownerUserId } = token; + let permissions = await fetchRealmPermissions(dbAdapter, new URL(realmURL)); + + 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/server-endpoints/archive-realm-test.ts b/packages/realm-server/tests/server-endpoints/archive-realm-test.ts new file mode 100644 index 0000000000..4f838b1c20 --- /dev/null +++ b/packages/realm-server/tests/server-endpoints/archive-realm-test.ts @@ -0,0 +1,141 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import { basename } from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { insertPermissions, isRealmArchived } from '@cardstack/runtime-common'; +import { realmSecretSeed } from '../helpers/index.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()}/`; + } + + test('POST /_archive-realm lets an owner archive a realm', async function (assert) { + const owner = '@archive-owner:localhost'; + const realmURL = makeRealmURL(); + 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, 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 insertPermissions(context.dbAdapter, new URL(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 insertPermissions(context.dbAdapter, new URL(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 insertPermissions(context.dbAdapter, new URL(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', + ); + }); + }); +}); From 2af6872807a6ae3a47779b3c75e483aef5f62133 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Fri, 26 Jun 2026 06:46:20 -0400 Subject: [PATCH 3/3] Address review: normalize realm URL + require source registry row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the body id through normalizeRealmURL so query strings/fragments are stripped and an invalid URL returns 400 instead of throwing a system error. Require a source realm_registry row to exist (404 if missing, 422 for published/bootstrap realms) rather than treating a realm_user_permissions row as proof of existence — a stale or manual realm-owner grant must not let an arbitrary URL be archived, nor have its restore enqueue a reindex for a realm with no disk backing. Add tests that permissions alone yield 404 and that a published realm is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../handlers/archive-realm-utils.ts | 48 +++++++++- .../server-endpoints/archive-realm-test.ts | 93 ++++++++++++++++++- 2 files changed, 132 insertions(+), 9 deletions(-) diff --git a/packages/realm-server/handlers/archive-realm-utils.ts b/packages/realm-server/handlers/archive-realm-utils.ts index 9db5270b1e..15242d2bc4 100644 --- a/packages/realm-server/handlers/archive-realm-utils.ts +++ b/packages/realm-server/handlers/archive-realm-utils.ts @@ -1,7 +1,8 @@ import type Koa from 'koa'; import { - ensureTrailingSlash, fetchRealmPermissions, + param, + query, type DBAdapter, type RealmPermissions, } from '@cardstack/runtime-common'; @@ -9,9 +10,11 @@ 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 { @@ -24,7 +27,12 @@ export interface ArchiveTarget { // request for both archive and unarchive. Returns the resolved target, or // null after writing the appropriate error response (caller should return). // -// Authorization rules (shared by both endpoints): +// 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( @@ -63,9 +71,41 @@ export async function resolveAndAuthorizeArchiveTarget( return null; } - let realmURL = ensureTrailingSlash(realmId); + // 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, new URL(realmURL)); + let permissions = await fetchRealmPermissions(dbAdapter, parsedRealmURL); if (!permissions[ownerUserId]?.includes('realm-owner')) { await sendResponseForForbiddenRequest( diff --git a/packages/realm-server/tests/server-endpoints/archive-realm-test.ts b/packages/realm-server/tests/server-endpoints/archive-realm-test.ts index 4f838b1c20..f63c4aee2e 100644 --- a/packages/realm-server/tests/server-endpoints/archive-realm-test.ts +++ b/packages/realm-server/tests/server-endpoints/archive-realm-test.ts @@ -2,8 +2,16 @@ import QUnit from 'qunit'; const { module, test } = QUnit; import { basename } from 'path'; import { v4 as uuidv4 } from 'uuid'; -import { insertPermissions, isRealmArchived } from '@cardstack/runtime-common'; +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'; @@ -23,10 +31,28 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { 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 insertPermissions(context.dbAdapter, new URL(realmURL), { + await seedSourceRealm(realmURL, { [owner]: ['read', 'write', 'realm-owner'], }); @@ -52,7 +78,7 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { 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 insertPermissions(context.dbAdapter, new URL(realmURL), { + await seedSourceRealm(realmURL, { [owner]: ['read', 'write', 'realm-owner'], }); @@ -97,7 +123,7 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { const owner = '@archive-owner:localhost'; const intruder = '@intruder:localhost'; const realmURL = makeRealmURL(); - await insertPermissions(context.dbAdapter, new URL(realmURL), { + await seedSourceRealm(realmURL, { [owner]: ['read', 'write', 'realm-owner'], [intruder]: ['read'], }); @@ -119,7 +145,7 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { test('POST /_archive-realm rejects a public/catalog realm', async function (assert) { const owner = '@archive-owner:localhost'; const realmURL = makeRealmURL(); - await insertPermissions(context.dbAdapter, new URL(realmURL), { + await seedSourceRealm(realmURL, { [owner]: ['read', 'write', 'realm-owner'], '*': ['read'], }); @@ -137,5 +163,62 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { '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', + ); + }); }); });