Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions packages/realm-server/handlers/archive-realm-utils.ts
Original file line number Diff line number Diff line change
@@ -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<ArchiveTarget | null> {
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<string, any>;
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: <realmURL> } }',
);
return null;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Codex] Please run the body id through normalizeRealmURL here instead of ensureTrailingSlash + new URL. ensureTrailingSlash is just string concatenation, so an id like https://host/realm?token=abc becomes https://host/realm?token=abc/ and will miss the canonical permission/metadata rows; an invalid URL also throws before the handler try, producing a server error instead of the expected 400. utils/realm-url.ts calls out that every realm-URL keying path needs the shared normalizer, and the delete/upsert-permission handlers already use it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed — the resolver now runs the body id through normalizeRealmURL, so query strings/fragments are stripped to the canonical realm root and an invalid URL returns 400 instead of throwing a system error before the handler try. 2af6872.

// 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 };
}
64 changes: 64 additions & 0 deletions packages/realm-server/handlers/handle-archive-realm.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
};
}
78 changes: 78 additions & 0 deletions packages/realm-server/handlers/handle-unarchive-realm.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void>({
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);
}
};
}
12 changes: 12 additions & 0 deletions packages/realm-server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <token>` against the shared `grafanaSecret`.
Expand Down
1 change: 1 addition & 0 deletions packages/realm-server/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/realm-server/tests/queries-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ module(basename(import.meta.filename), function () {
);
});

test('fetchArchivedRealmsForOwner returns only the owners 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';

Expand Down
Loading
Loading