Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .changeset/security-explain-envelope-convergence.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
"@objectstack/rest": patch
---

fix(rest): one error envelope across the `/security/explain` pair (#8073)

`registerSecurityExplainEndpoints` — `GET/POST /api/v1/security/explain` and
`GET /api/v1/security/my-delegable-scope` — answered two retired dialects across
its eight refusal arms: the 401 / 501 / 400 / 403 arms were flat
`{ code, message }`, and the two 500s were `{ code, error: 'a bare string' }`. So
`body.error.code`, the one position ADR-0112 D5 declares, read `undefined` on all
six — while the immediately adjacent registrar (`/security/suggested-bindings`,
converged in #7981) already answered the declared shape. A client calling
`explain` and then `suggested-bindings` met two envelopes inside one `security`
family.

Every arm now emits `{ success: false, error: { code, message } }` through the
shared `sendError` from `@objectstack/types` — the same builder every conformant
route module writes through — so the family agrees by construction rather than by
eight literals happening to match. The 400 arm's Zod-issue dump moves from a
top-level `detail` sibling to `error.details`, the slot `ApiErrorSchema` declares
for structured context.

No status code moves, and no code VALUE changes: `UNAUTHORIZED`,
`NOT_IMPLEMENTED`, `VALIDATION_FAILED`, `PERMISSION_DENIED`, `EXPLAIN_FAILED` and
`DELEGABLE_SCOPE_FAILED` are all already registered, so nothing in
`packages/spec` moves. `ObjectStackClient` reads both envelopes' declared spots
(`errorBody?.code ?? errorBody?.error?.code`, and a bare-string limb for the
message), so `client.security.explain()` and
`client.security.describeDelegableScope()` keep throwing identical `err.code` and
`err.message` — re-measured against these call paths rather than inherited from
#7981. `err.details` does change on refusals, from "the whole response body" to
the structured slot or the new body.
95 changes: 71 additions & 24 deletions packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,6 +56,11 @@ import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js';
import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api';
import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api';
import type { FieldErrorCode } from '@objectstack/spec/api';
// [#8073] The closed ADR-0112 error vocabulary, so the explain family's single
// refusal emitter types its `code` parameter as the vocabulary rather than as
// `string` — an invented code is a compile error at the call site instead of a
// runtime surprise on whichever arm a test happens to drive.
import type { ErrorCode } from '@objectstack/spec/api';
// The async-import row ceiling has exactly one definition, in the spec, whose
// TSDoc is its public statement (#6535). rest is the only enforcer, so it reads
// that export rather than re-declaring the literal beside a "mirrors spec" comment.
Expand DownExpand Up@@ -9268,6 +9273,45 @@ export class RestServer {
catch { return undefined; }
};

/**
* [#8073] The ONE refusal emitter for this route family — every arm of
* both handlers goes through it, so "explain and my-delegable-scope
* answer the same shape" is a property of the code rather than of
* eight literals that happen to agree.
*
* Before this, the family carried BOTH dialects ADR-0112 D5 retires:
* the 401/501/400/403 arms were flat `{ code, message }` and the two
* 500s were `{ code, error: 'a bare string' }`, so `body.error.code` —
* the one position D5 declares — read `undefined` on all six. #7035
* (PR #7293) had already removed both from this file's `/meta`
* refusals and #7981 (PR #8071) from `registerSecurityEndpoints`, the
* immediately ADJACENT registrar: a client calling `explain` and then
* `suggested-bindings` met two shapes inside one `security` family.
*
* Emitted through the SHARED builder (`sendError` from
* `@objectstack/types`, imported as `sendEnvelopeError` because this
* module has a local `sendError` of its own — the sanitizing responder
* for THROWN errors, a different thing). That is what makes this the
* reference shape by construction rather than a ninth local literal
* agreeing with the eight it replaced, and it types `code` to the
* closed vocabulary for free.
*
* ⛔ Status codes are untouched: only the POSITION of `code` and
* `message` moves. `detail` — the 400 arm's Zod-issue dump — moves to
* `error.details`, the slot `ApiErrorSchema` actually declares for
* structured context; as a top-level sibling it was undeclared.
*/
const respondError = (
res: any,
status: number,
code: ErrorCode,
message: string,
details?: unknown,
): void => sendEnvelopeError(
res, status, code, message,
details === undefined ? undefined : { details },
);

const handler = async (req: any, res: any) => {
try {
const environmentId = isScoped ? req.params?.environmentId : undefined;
Expand All@@ -9276,18 +9320,18 @@ export class RestServer {
if (!context?.userId) {
// The explain surface stays authenticated-only — it is an
// admin diagnosis tool. (Anonymous is already 401ed above.)
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'The access-explanation endpoint requires an authenticated caller.',
});
return respondError(
res, 401, 'UNAUTHORIZED',
'The access-explanation endpoint requires an authenticated caller.',
);
}

const svc = await resolveService(environmentId, req);
if (!svc || typeof svc.explain !== 'function') {
return res.status(501).json({
code: 'NOT_IMPLEMENTED',
message: 'Access explanation is not available on this deployment (no security service with explain).',
});
return respondError(
res, 501, 'NOT_IMPLEMENTED',
'Access explanation is not available on this deployment (no security service with explain).',
);
}

// GET reads the request from the query string, POST from the
Expand All@@ -9310,11 +9354,11 @@ export class RestServer {
...(src.recordId != null && src.recordId !== '' ? { recordId: src.recordId } : {}),
});
if (!parsed.success) {
return res.status(400).json({
code: 'VALIDATION_FAILED',
message: 'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string }.',
detail: String(parsed.error?.message ?? '').slice(0, 1000),
});
return respondError(
res, 400, 'VALIDATION_FAILED',
'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string }.',
String(parsed.error?.message ?? '').slice(0, 1000),
);
}

const decision = await svc.explain(parsed.data, context);
Expand All@@ -9326,10 +9370,13 @@ export class RestServer {
error?.name === 'PermissionDeniedError' ||
msg.startsWith('[Security] Access denied')
) {
return res.status(403).json({ code: 'PERMISSION_DENIED', message: msg.slice(0, 1000) });
return respondError(res, 403, 'PERMISSION_DENIED', msg.slice(0, 1000));
}
logError('[REST] Security explain error:', error);
res.status(500).json({ code: 'EXPLAIN_FAILED', error: msg.slice(0, 500) });
// The 500 arm keeps its 500-char cap: an unexpected fault's
// message is not a contract, and truncating it stays a
// sanitization step — only the position of the words moves.
respondError(res, 500, 'EXPLAIN_FAILED', msg.slice(0, 500));
}
};

Expand DownExpand Up@@ -9369,25 +9416,25 @@ export class RestServer {
const context = await this.resolveExecCtx(environmentId, req);
if (this.enforceAuth(req, res, context)) return;
if (!context?.userId) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'The delegable-scope endpoint requires an authenticated caller.',
});
return respondError(
res, 401, 'UNAUTHORIZED',
'The delegable-scope endpoint requires an authenticated caller.',
);
}

const svc = await resolveService(environmentId, req);
if (!svc || typeof svc.describeDelegableScope !== 'function') {
return res.status(501).json({
code: 'NOT_IMPLEMENTED',
message: 'Delegated administration is not available on this deployment (no security service with describeDelegableScope).',
});
return respondError(
res, 501, 'NOT_IMPLEMENTED',
'Delegated administration is not available on this deployment (no security service with describeDelegableScope).',
);
}

res.json(await svc.describeDelegableScope(context));
} catch (error: any) {
const msg = String(error?.message ?? error ?? '');
logError('[REST] Delegable scope error:', error);
res.status(500).json({ code: 'DELEGABLE_SCOPE_FAILED', error: msg.slice(0, 500) });
respondError(res, 500, 'DELEGABLE_SCOPE_FAILED', msg.slice(0, 500));
}
};

Expand Down
Loading
Loading