Skip to content
13 changes: 7 additions & 6 deletions packages/realm-server/tests/card-operations-dispatch-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type OperationScope,
type OperationTarget,
} from '@cardstack/runtime-common/card-operations';
import { GateTrace } from '@cardstack/runtime-common/card-operations/gate-trace';
import { fileContentToBytes } from '@cardstack/runtime-common/stream';
import { urlNamesFile } from '@cardstack/runtime-common/file-def-code-ref';
import type { CodeRef } from '@cardstack/runtime-common/code-ref';
Expand Down Expand Up @@ -1751,7 +1752,6 @@ module(basename(import.meta.filename), function () {
{ kind: 'unattributed' },
'a scope built without naming a caller has none',
);
assert.strictEqual(newOperationScope(core).proposed, undefined);
});

test('a scope with a caller resolves exactly as one without', async function (assert) {
Expand All @@ -1773,7 +1773,8 @@ module(basename(import.meta.filename), function () {
let batch = newOperationScope(core, {
caller: scopeCallerFor('@someone:example.com'),
});
let entry = batch.derive({ proposed: { title: 'Q3' } });
let trace = new GateTrace();
let entry = batch.derive({ trace });
await batch.peekInstance(url);
await entry.peekInstance(url);
assert.strictEqual(
Expand All @@ -1782,18 +1783,18 @@ module(basename(import.meta.filename), function () {
'the two invocations read the row once between them',
);
assert.deepEqual(entry.caller, batch.caller);
assert.deepEqual(entry.proposed, { title: 'Q3' });
assert.strictEqual(entry.trace, trace);
assert.strictEqual(
batch.proposed,
batch.trace,
undefined,
'deriving leaves the scope it came from as it was',
);
let unattributed = entry.derive({ caller: { kind: 'unattributed' } });
assert.deepEqual(unattributed.caller, { kind: 'unattributed' });
assert.strictEqual(
unattributed.proposed,
unattributed.trace,
undefined,
'a proposed document is one invocation’s and does not carry over',
'a trace is one invocation’s and does not carry over',
);
Comment on lines 1792 to 1798

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 🤖] Two problems with this test:

  1. The new assertion repeats assert.deepEqual(entry.caller, batch.caller) from four lines up. It can only fail if derive mutated entry in place, and scopeFor always builds a fresh object, so it never fails.
  2. With proposed gone, nothing tests the other half of derive's contract: the part that does not carry over. Its comment now says "a trace belongs to one invocation and never does", and derive is the only place a trace is attached (capabilities.ts does scope.derive({ trace })). The removed proposed assertions were the only check that a per-invocation member is dropped.

Swapping the duplicate for a trace check would cover it:

let traced = entry.derive({ trace: someTrace });
assert.strictEqual(traced.trace, someTrace);
assert.strictEqual(
  traced.derive({}).trace,
  undefined,
  'a trace is one invocation’s and does not carry over',
);

Regression in coverage, not in behavior. Non-blocking.

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 in 6533a8f. The duplicate assertion is gone. The test now derives with a GateTrace, then checks three things: the derived scope carries it, the original scope still has none, and a further derive that names no trace drops it. That is the member-does-not-carry-over check the old proposed assertions covered.

});

Expand Down
50 changes: 13 additions & 37 deletions packages/realm-server/tests/card-operations-transforms-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,16 +698,17 @@ module(basename(import.meta.filename), function () {

const realmConfig = async () => ({});

test('a create’s scope carries the document its stages produced, not the caller’s payload', async function (assert) {
test('a create is staged from the document its stages produced, not the caller’s payload', async function (assert) {
let core = stub();
let sent = { lid: 'draft-1', meta: { x: 1 }, note: 'from the caller' };
let scope = newOperationScope(core, { caller: scopeCallerFor(ACTOR) });
let staged = await stageWriteEntry(
{
entry: createEntry(sent),
target: { kind: 'type', codeRef: PERSON, realm: REALM },
definition: TITLED_CREATE,
decision: { kind: 'coarse' },
scope: newOperationScope(core, { caller: scopeCallerFor(ACTOR) }),
scope,
},
{
name: 'draft',
Expand All @@ -717,24 +718,24 @@ module(basename(import.meta.filename), function () {
},
);
assert.deepEqual(
staged.scope.proposed,
paramsFor(staged.entry),
{ note: 'from the caller', title: 'Untitled' },
'the proposed document holds the param the input supplied, and ' +
'not the envelope members a named create’s params leave out',
'the params hold the one the input supplied, and not the envelope ' +
'members a named create’s params leave out',
);
assert.strictEqual(
(sent as Record<string, unknown>).title,
undefined,
'which the caller never sent',
);
assert.deepEqual(
staged.scope.caller,
{ kind: 'user', actor: ACTOR },
'and the batch’s caller travels with it',
assert.strictEqual(
staged.scope,
scope,
'and the entry keeps the scope it was resolved under',
);
});

test('a plain create’s proposed document is the resource it is minted from', async function (assert) {
test('a plain create is staged from the whole resource it is minted from', async function (assert) {
let resource = {
lid: 'draft-1',
attributes: { title: 'Q3' },
Expand All @@ -751,13 +752,13 @@ module(basename(import.meta.filename), function () {
{ name: 'create', params: {}, actor: ACTOR, realmConfig },
);
assert.deepEqual(
staged.scope.proposed,
staged.entry.data,
resource,
'the whole resource, local id and type included, as it is staged',
);
});

test('a create the params check refuses stages no proposed document', async function (assert) {
test('a create the params check refuses is not staged', async function (assert) {
let { input: _input, ...withoutInput } = TITLED_CREATE;
let error = await refusal(() =>
stageWriteEntry(
Expand All @@ -774,31 +775,6 @@ module(basename(import.meta.filename), function () {
assert.strictEqual(error.code, 'invalid-params');
});

test('a write that is not a create carries no proposed document', async function (assert) {
let staged = await stageWriteEntry(
{
entry: {
op: 'invoke',
position: 0,
name: 'addComment',
href: CARD,
data: { body: 'hello' },
},
target: { kind: 'instance', url: CARD },
definition: { base: 'transform', deterministic: true },
decision: { kind: 'coarse' },
scope: newOperationScope(stub(), { caller: scopeCallerFor(ACTOR) }),
},
{
name: 'addComment',
params: { body: 'hello' },
actor: ACTOR,
realmConfig,
},
);
assert.strictEqual(staged.scope.proposed, undefined);
});

test('a batch entry’s projection has to remain a result object', async function (assert) {
let entry: EnvelopeEntry = {
op: 'invoke',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ import { setupCatalogTestSubset } from './helpers/catalog-test-subset.ts';
// registering for updates. Both are made as the submitter the realm's own
// `realm.json` names. Closing a petition is opened to signed-in callers only,
// and `claim`, which reads the caller, is opened to everyone but admits only
// signed-in callers.
// signed-in callers. A notice declares `sign` too and no grant names it, and a
// ballot's only grant is made as a key the realm's config doesn't hold.
const CIVIC = 'http://127.0.0.1:4444/civic/';
// A board anyone may read, whose policy opens only a declared write, `sign`,
// to callers who aren't signed in.
// A board anyone may read, whose policy opens only a declared write, `sign` of
// an open petition, to callers who aren't signed in.
const BOARD = 'http://127.0.0.1:4444/board/';
const ORG = 'http://127.0.0.1:4444/org/';
const CIVIC_POLICY = `${ORG}policies/civic`;
Expand Down Expand Up @@ -88,6 +89,18 @@ const CIVIC_MODULE = `
set: { signature: actor() },
};
}
export class Notice extends CardDef {
@field title = contains(StringField);
@field status = contains(StringField);
@field signature = contains(StringField);

@operation static sign = {
base: 'transform',
params: { name: StringField },
set: { signature: params('name') },
};
}
export class Ballot extends Notice {}
export class Signup extends CardDef {
@field email = contains(StringField);
@field source = contains(StringField);
Expand All @@ -111,8 +124,11 @@ const SIGNUP = type(CIVIC, 'Signup');
const OPEN = `${CIVIC}petitions/open`;
const LOCAL = `${CIVIC}petitions/local`;
const CLOSED = `${CIVIC}petitions/closed`;
const NOTICE = `${CIVIC}notices/open`;
const BALLOT = `${CIVIC}ballots/open`;
const MISSING = `${CIVIC}petitions/nowhere`;
const BOARD_PETITION = `${BOARD}petitions/open`;
const BOARD_CLOSED = `${BOARD}petitions/closed`;

function petition(title: string, status: string, name = 'Petition') {
return JSON.stringify({
Expand Down Expand Up @@ -155,6 +171,9 @@ const POLICY = policyCard([
rule(SIGNUP, [
{ operation: 'register', anonymous: true, actingUser: 'submitter' },
]),
rule(type(CIVIC, 'Ballot'), [
{ operation: 'sign', anonymous: true, actingUser: 'missing' },
]),
]);

module(basename(import.meta.filename), function (hooks) {
Expand Down Expand Up @@ -197,6 +216,8 @@ module(basename(import.meta.filename), function (hooks) {
'open',
'LocalPetition',
),
'notices/open.json': petition('Quiet hours', 'open', 'Notice'),
'ballots/open.json': petition('Name the park', 'open', 'Ballot'),
},
permissions: {
[EDITOR]: ['read', 'write', 'realm-owner'],
Expand All @@ -214,6 +235,7 @@ module(basename(import.meta.filename), function (hooks) {
}),
'civic.gts': CIVIC_MODULE,
'petitions/open.json': petition('Paint the fence', 'open'),
'petitions/closed.json': petition('Fix the gate', 'closed'),
},
permissions: {
'*': ['read'],
Expand All @@ -228,7 +250,12 @@ module(basename(import.meta.filename), function (hooks) {
'policies/civic.json': POLICY,
'policies/board.json': policyCard([
rule(type(BOARD, 'Petition'), [
{ operation: 'sign', anonymous: true, actingUser: 'submitter' },
{
operation: 'sign',
anonymous: true,
actingUser: 'submitter',
where: '.status == "open"',
},
]),
]),
},
Expand Down Expand Up @@ -357,7 +384,11 @@ module(basename(import.meta.filename), function (hooks) {
return `Bearer ${createJWT(civic, EDITOR, ['read', 'write', 'realm-owner'])}`;
}

async function explain(target: string, operation: string) {
async function explain(
target: string,
operation: string,
policy = CIVIC_POLICY,
) {
let response = await request
.post(`${new URL(ORG).pathname}_operations`)
.set('X-HTTP-Method-Override', 'QUERY')
Expand All @@ -371,7 +402,7 @@ module(basename(import.meta.filename), function (hooks) {
JSON.stringify({
'boxel:operations': [
invoke('explain', {
href: CIVIC_POLICY,
href: policy,
data: { actor: '', target, operation },
}),
],
Expand Down Expand Up @@ -612,4 +643,40 @@ module(basename(import.meta.filename), function (hooks) {
'a closed petition',
);
});

test('explain reports a refusal as the caller who is not signed in receives it', async function (assert) {
let refusals: [string, string, string][] = [
['a predicate that does not hold', CLOSED, 'predicate-false'],
['an operation that reads the caller', LOCAL, 'reads-actor'],
['a type no grant names', NOTICE, 'no-grant'],
['a grant whose acting user is not in the config', BALLOT, 'no-grant'],
];
for (let [label, target, reason] of refusals) {
let explained = await explain(target, 'sign');
assert.strictEqual(explained.reason, reason, `${label}: ${reason}`);
let response = await sign(target, 'Ada');
unauthenticated(response, label, assert);
assert.deepEqual(
explained.refusal,
{ status: 401, code: 'actor-required' },
`${label}: explain reports the 401 the realm sends`,
);
}
// A realm anyone may read tells such a caller to authenticate for a write
// it refused too, and explain says the same.
let explained = await explain(BOARD_CLOSED, 'sign', BOARD_POLICY);
assert.strictEqual(explained.reason, 'predicate-false');
unauthenticated(
await operations(BOARD, [
invoke('sign', { href: BOARD_CLOSED, data: { name: 'Ada' } }),
]),
'a realm anyone may read',
assert,
);
assert.deepEqual(
explained.refusal,
{ status: 401, code: 'actor-required' },
'a realm anyone may read: explain reports the 401 the realm sends',
);
});
});
25 changes: 7 additions & 18 deletions packages/runtime-common/card-operations/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,25 +349,20 @@ export interface RunOperationOptions {
// snapshot of it. The memo lives for the invocation and no longer: a core is
// long-lived and must never hold a card's row across requests.
//
// A scope also says who the invocation is for, whether the realm ACL declined
// them, and, for a create, what it would write. Those are the facts the policy
// gate reads that the target does not carry. They travel here rather than as
// A scope also says who the invocation is for and whether the realm ACL
// declined them. Those are the facts the policy gate reads that the target
// does not carry (see `GateScope`). They travel here rather than as
// parameters of `resolveOperation` because the scope already reaches every
// place an operation is resolved.
// place an operation is resolved. A create's payload is not among them: a
// create against a type is judged by the card it mints once staged, and a
// named create anchored on a card by that card.
export interface OperationScope {
peekInstance(url: URL): Promise<InstanceOrError | undefined>;
readonly caller: ScopeCaller;
// What the realm ACL declined for the request this invocation belongs to.
// Where it declined an invocation, the policy gate decides whether it runs;
// everywhere else the gate does nothing at all.
readonly coarseDeclined: CoarseDeclined;
// The payload a `create` entry is staged from, as its `input` stage and its
// `params` check left it — what the operation would actually write, not the
// bytes the caller sent. For a plain create that is the resource the card is
// minted from; for a named one, the params its template is filled with.
// Absent for every other invocation, and for a create until those two
// stages have run.
readonly proposed: Record<string, unknown> | undefined;
// Where the policy gate records how it reached its decision, for an explain
// to report, or for a capability check to read why the gate refused. Absent
// on every invocation a caller makes.
Expand All @@ -390,8 +385,7 @@ export interface OperationScope {
// memo so the invocations of one request still cost one read of each row
// between them. The caller, the ACL's verdict, the seal and whether the
// request is advisory carry over unless named, and so does its route; a
// proposed document belongs to one invocation and never does, and neither
// does a trace.
// trace belongs to one invocation and never does.
derive(invocation: ScopeInvocation): OperationScope;
}

Expand All @@ -415,7 +409,6 @@ export type ScopeCaller =
export interface ScopeInvocation {
caller?: ScopeCaller;
coarseDeclined?: CoarseDeclined;
proposed?: Record<string, unknown>;
trace?: GateTrace;
seal?: Error;
advisory?: boolean;
Expand Down Expand Up @@ -466,7 +459,6 @@ export function newOperationScope(
let scopeFor = (
caller: ScopeCaller,
coarseDeclined: CoarseDeclined,
proposed: Record<string, unknown> | undefined,
trace: GateTrace | undefined,
seal: Error | undefined,
advisory: boolean,
Expand All @@ -475,7 +467,6 @@ export function newOperationScope(
peekInstance,
caller,
coarseDeclined,
proposed,
trace,
seal,
advisory,
Expand All @@ -485,7 +476,6 @@ export function newOperationScope(
scopeFor(
next.caller ?? caller,
next.coarseDeclined ?? coarseDeclined,
next.proposed,
next.trace,
next.seal ?? seal,
next.advisory ?? advisory,
Expand All @@ -495,7 +485,6 @@ export function newOperationScope(
return scopeFor(
invocation.caller ?? { kind: 'unattributed' },
invocation.coarseDeclined ?? 'none',
invocation.proposed,
invocation.trace,
invocation.seal,
invocation.advisory ?? false,
Expand Down
Loading
Loading