Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2710e92ab | ||
|
|
dee9f3d8dc |
No files matched your search
@@ -0,0 +1,70 @@
|
||||
# Row214 C2-11a work record
|
||||
|
||||
Assignment sage-2794 at2026-10-22T16:33:20Z, Q220/mos-1301, issue#1715.
|
||||
New isolated branch herb/row214 on aca16404c. The earlier197/215 worktrees
|
||||
and their dirty/untracked historical packets are untouched. node_modules is
|
||||
the required plain symlink to /mnt/storage/src/mosaic-stack/node_modules.
|
||||
|
||||
Read C2-11a in both pinned briefs. The section is unchanged at51cc0445c;
|
||||
Q222 changes C2-06's reply-limit settings, not wikilinks. The editor model,
|
||||
entry, view, existing model/browser/provenance tests, build/toolchain and
|
||||
vendored markdown parser/serializer were read. No third_party source changes.
|
||||
|
||||
## Static finding and authored work
|
||||
|
||||
model.mjs stores a canonical sig and the raw original block, and serialise
|
||||
returns raw when the sig matches. The vendored text writer escapes brackets
|
||||
(to_markdown.ts:433-437). It is therefore possible for no-edit round-trips
|
||||
to pass while dirty-block output escapes a wikilink. This is a source-based
|
||||
hypothesis, not a measured result. Both outcomes are in DESIGN.md.
|
||||
|
||||
Authored, unrun: the pinned-bundle measurement; ten fixture round-trips;
|
||||
three browser regressions; inert wikilink schema/tokenizer/serializer. The
|
||||
schema includes headings, and front matter remains raw. The serializer
|
||||
validates its spelling before bypassing escaping. HTML/entity-like targets
|
||||
stay text under the existing escaping rules. No clicking or resolution.
|
||||
|
||||
Local static git diff --check is clean. No Node, npm, compiler, browser,
|
||||
measurement, test, Docker, HTTPS Git or push has run. The queue claim waits
|
||||
for a gap because scripts/mosaic invokes Node. No candidate or pass claim.
|
||||
Only design/path documents may be committed before the granted red/green
|
||||
checks; product, tests and measurement remain uncommitted until validated.
|
||||
|
||||
## Next granted steps
|
||||
|
||||
1. Claim from canonical during a CLEAR/gap.
|
||||
2. Run measure.mjs against aca16404c's pinned Git blobs; save JSON unchanged.
|
||||
3. Run the structural/edit regression alone on the baseline bundle (red).
|
||||
4. Install the already-pinned esbuild0.28.2 in this worktree's packages/webui
|
||||
with npm ci --ignore-scripts --no-audit --no-fund; no root install or lock edit.
|
||||
5. Build with the existing editor/build.mjs, then the whole new test file and
|
||||
existing editor, editor-browser and editor-provenance files, each singly.
|
||||
6. Kill the serializer-removal mutant, restore, rebuild, focused green.
|
||||
7. Commit only after the applicable checks, push only when Sage clears HTTPS
|
||||
Git, send Filbert and Mos the candidate SHA/hash manifest directly, cc Sage.
|
||||
8. After253 lands, new branch on its tip preserving all landed changes.
|
||||
9. Filbert and Sage's whole webui and scripts gate finish the row.
|
||||
|
||||
All runs wait for explicit Sage CLEAR. Gander's row253 run5 owns the slot.
|
||||
The whole suite gate and any unexpected failure use mos-0823's stop rule.
|
||||
|
||||
Proposed SESSIONS text for Sage's post-253 doc sweep (do not append here):
|
||||
2026-10-22 | Herb (gpt-6.1-sol, f6cf4287) | row214 C2-11a editor wikilinks authoring | On herb/row214 at aca16404c, authored baseline round-trip measurement, designs for equal/unequal results, inert inline node and regression tests under sage-2794/Q220. Claim, runs and pushes held while253 owns the slot; measurement and test results unavailable.
|
||||
|
||||
## Filbert static design/path review accepted, R1 and R2 required
|
||||
|
||||
Review at2026-10-22T16:43:35Z read dee9f3d8d's packet only, no product or
|
||||
runs. R1 is now in the plan: set MOSAIC_REQUIRE_EDITOR_BUILD=1 on focused
|
||||
provenance and whole webui runs, grep ^# skip plus TAP/spec skip directives,
|
||||
and name every skip. Missing/wrong pinned esbuild must fail, not skip.
|
||||
R2: baseline no-edit roundTrips are expected to pass. They test retention
|
||||
of raw bytes and are not red evidence for the node. Only the baseline
|
||||
same-block structural/edit regression is expected red.
|
||||
|
||||
Two added fixtures cover a reference-definition collision and an atom
|
||||
inside ordinary link text, taking the planned file to10 no-edit tests and
|
||||
3 browser tests. The explicit & exclusion preserves the existing escaped
|
||||
HTML/entity writer for dirty blocks; Q&A stays text and is a known limit.
|
||||
Measurement VM setup matches loadEditor. Browser TMPDIR must use the short
|
||||
non-/tmp scratch root. Sweep Node/Chromium after mutant/timeout and at clear.
|
||||
All remain authored and unrun while253 owns the slot.
|
||||
@@ -0,0 +1,128 @@
|
||||
# C2-11a: an inert editor wikilink node
|
||||
|
||||
Base: aca16404c. Owner: Herb. Reviewer: Filbert. Issue: #1715.
|
||||
Authority: sage-2794, Q220/mos-1301. The assignment permits authoring the
|
||||
node and tests before the measurement slot. Runs, claim and pushes wait
|
||||
for Sage's CLEAR. After row253 lands, a new branch will carry the change
|
||||
onto its landing with every landed hunk retained.
|
||||
|
||||
## Measurement before choosing the explanation
|
||||
|
||||
Run agents/herb/work/queue-214/measure.mjs only on a granted slot. It loads
|
||||
aca16404c's editor.js and markdown-preview.js directly from local Git
|
||||
objects. It verifies the editor bundle hash against that base's BUILD.json.
|
||||
It cannot accidentally measure a candidate rebuild. Its JSON records the
|
||||
full baseline SHA, bundle hash, timestamp, input, output and equality for
|
||||
each fixture. The first five are the brief's plain, labelled, heading,
|
||||
list and front-matter cases; extra cases cover a heading block, combined
|
||||
heading/label, Unicode/spacing, adjacency and an inert embed. No result is
|
||||
available yet. Static reasoning is not a measurement.
|
||||
|
||||
Outcome A, every baseline round-trip is equal: record that wikilinks do
|
||||
not trigger Source fallback for the measured inputs. Do not claim that
|
||||
bug exists. The source's raw/sig cache explains why an unchanged block can
|
||||
keep its bytes even when its canonical serializer escapes brackets. Add
|
||||
the node for structured wikilinks and dirty-block preservation, as the
|
||||
brief requests. A baseline structural/edit regression must fail even if
|
||||
all unchanged-file checks pass.
|
||||
|
||||
Outcome B, one or more differ: record the exact differing bytes and affected
|
||||
contexts. Keep the existing Source fallback fail-closed. The node restores
|
||||
the required spellings where the block is structured. Retain the same
|
||||
no-edit and dirty-block regressions. If the mismatch comes from a separate
|
||||
block, line-ending or front-matter defect, report it before widening scope.
|
||||
|
||||
## Representation and conversion
|
||||
|
||||
The schema gains one inline atom, wikilink, with a single source attribute.
|
||||
Its source is the complete spelling, not reconstructed target/label parts.
|
||||
It has no href, navigation, fetch or click behavior. A span with data-source
|
||||
and a text child displays the spelling. Heading content includes the new
|
||||
atom; paragraphs, quotes and lists already accept inline content.
|
||||
|
||||
One markdown-it inline rule runs before ordinary links, after escapes and
|
||||
backticks. A sticky match reads a complete single-line [[...]] or ![[...]]
|
||||
without copying every suffix or crossing state.posMax. The node writer
|
||||
writes its validated spelling without bracket escaping, so editing adjacent
|
||||
text cannot rewrite a wikilink. Front matter remains a raw block. No new
|
||||
file-read, save, routing, documentPath or target-resolution behavior is added.
|
||||
|
||||
The accepted atom grammar excludes brackets within the body, line breaks,
|
||||
angle brackets and ampersands. This is an editor representation boundary,
|
||||
not a library resolver or a refusal to open such a file. Malformed,
|
||||
HTML/entity-like and code syntax stays under the existing text/raw rules.
|
||||
This preserves the existing escaping of HTML/entities when a block changes.
|
||||
DOM paste accepts only that same grammar. Invalid attributes constructed
|
||||
programmatically are flattened to one line and escaped by the text writer.
|
||||
No input string is parsed as HTML and embeds never load an image.
|
||||
|
||||
## Acceptance and review
|
||||
|
||||
The measurement JSON goes in BUILD.md and the final manifest packet.
|
||||
Ten fixture round-trips run with and without a final newline. A browser
|
||||
test inspects real ProseMirror atom types and attributes, edits the same
|
||||
block and reparses it, for each fixture. Two more browser tests cover
|
||||
code/escape/malformed/hostile syntax and DOM/programmatic-attribute safety.
|
||||
Tests use the existing fixture and Chromium harness and the shipped bundle.
|
||||
They do not export new production APIs just for testing.
|
||||
|
||||
Before rebuilding, run only the structural regression against the baseline
|
||||
bundle and observe it fail. Rebuild the candidate with the exact pinned
|
||||
esbuild0.28.2 binary, run the whole new test file, existing editor tests,
|
||||
editor browser tests and provenance test. Remove only the wikilink serializer
|
||||
handler for one mutant; the dirty-block assertion must fail. Restore, rebuild
|
||||
and rerun the focused regression. All execution needs Sage's CLEAR, including
|
||||
installation, checks, builds, browser tests and the measurement.
|
||||
|
||||
The checked-in bundle and editor/BUILD.json must be regenerated by the
|
||||
existing build script. No hand edits of generated JavaScript or provenance.
|
||||
The current shared esbuild is0.28.1 and the local webui install is absent.
|
||||
A granted npm ci --ignore-scripts --no-audit --no-fund in packages/webui is
|
||||
needed before building; the root node_modules remains its plain symlink.
|
||||
No dependency or lockfile change is planned.
|
||||
|
||||
Final gate: Filbert approves the manifest, then the whole webui suite and
|
||||
every scripts/test-*.sh on Sage's slot. Two failures stop a gate. A failure
|
||||
outside this row is reported to Sage before any rerun. Source, generated
|
||||
output, mode/hash manifest and raw granted logs identify the candidate.
|
||||
|
||||
## Static review follow-through (Filbert, 2026-10-22T16:43:35Z)
|
||||
|
||||
R1: the focused editor-provenance run and Sage's whole webui gate must set
|
||||
MOSAIC_REQUIRE_EDITOR_BUILD=1. A missing, wrong-version or unpinned binary
|
||||
then fails. Check every log for ^# skip, as well as TAP # SKIP directives
|
||||
and the spec reporter's skipped-test lines; record each skipped name and
|
||||
reason. A skipped rebuild is not proof of bundle reproducibility. The CSS
|
||||
output must stay byte-identical and match BUILD.json.
|
||||
|
||||
R2: no-edit roundTrip checks are expected to pass on the baseline because
|
||||
parse sets sig=canonical and an unchanged serialise returns lead+raw. Their
|
||||
green result is compatibility evidence, never evidence that the atom was
|
||||
needed or installed. The same-block edit/structural regression is the red
|
||||
that proves the new node. The required measurement still runs and is kept.
|
||||
|
||||
N1: bus.js checks visual mode at949 on this base and also requires
|
||||
MosaicMarkdown. The measurement uses the existing helper's bare-window VM
|
||||
shape, markdown-preview.js first, then editor.js, both from pinned Git blobs.
|
||||
|
||||
N2: the exclusion of & and angle brackets is deliberate: the existing
|
||||
text serializer escapes entities/HTML when it rewrites a block for another
|
||||
renderer. An atom's verbatim writer must not bypass that rule, including
|
||||
for pasted/programmatic values. This keeps those targets as ordinary text;
|
||||
[[Q&A]] will still be escaped if its own block changes. It is a known
|
||||
representation limit beyond the five brief cases, not a resolver change.
|
||||
It is included among the text-only cases. Unedited bytes remain intact.
|
||||
|
||||
N3: the reference-definition fixture [[a]] with [a]: u deliberately becomes
|
||||
one inert wikilink rather than a bracket around a reference link. Literal
|
||||
spelling and the separate definition block remain intact.
|
||||
|
||||
N4: [x [[a]]](u) is an explicit fixture. The atom inherits the enclosing
|
||||
ordinary link mark, whose existing target gate still applies. The atom
|
||||
itself supplies no href or new click behavior; the fixture checks its
|
||||
structure and spelling before/after a same-block edit.
|
||||
|
||||
N6: every granted browser/test process gets TMPDIR from
|
||||
mktemp -d /mnt/storage/scratch/tmp/gXXX. Never /tmp. Sweep owned Chromium
|
||||
and Node processes by cwd and the temp-root path after the mutant, after
|
||||
any timeout, and before clearing the slot. Retain/report uncertain owners.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Row214 implementation manifest, draft before measurement
|
||||
|
||||
Pinned base: aca16404c. Brief: docs/plans/2026-10-20_case2-rows.md,
|
||||
C2-11a. Also read at gosalyn/case2-06-idea-caps51cc0445c; that section is
|
||||
unchanged. Q222's reply-limit settings belong to C2-06, not this row.
|
||||
|
||||
| Path | Change |
|
||||
| --- | --- |
|
||||
| packages/webui/editor/model.mjs | inert inline atom, heading content, tokenizer rule and serializer |
|
||||
| packages/webui/tests/editor-wikilink.test.mjs | ten round-trips and three browser regressions |
|
||||
| packages/webui/tests/helpers/wikilink-cases.mjs | exact fixtures shared with measurement |
|
||||
| packages/webui/src/public/editor.js | generated by the pinned editor build, pending CLEAR |
|
||||
| packages/webui/editor/BUILD.json | generated provenance, pending CLEAR |
|
||||
| agents/herb/work/queue-214/measure.mjs | baseline Git-object measurement, authored and unrun |
|
||||
| agents/herb/work/queue-214/DESIGN.md | both measurement outcomes, implementation and acceptance |
|
||||
| agents/herb/work/queue-214/BUILD.md | rulings and receipts, pending runs |
|
||||
| agents/herb/work/queue-214/MANIFEST.md | this reviewed path list |
|
||||
|
||||
editor.css is an unchanged build output; no source CSS is touched. No
|
||||
entry/view, library, route, save, click, documentPath or dependency changes.
|
||||
Generated changes do not exist yet. This is a design/path manifest, not a
|
||||
frozen tested candidate. Candidate hashes and measurement results follow
|
||||
only after the granted runs. Filbert reviews the plan before the build.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Row 275 (193b1b, #1780): viewer verbs and board refusals
|
||||
|
||||
Morgana, 2026-10-22, for Duckworth (reviewer) and Sage (gate owner). Brief: docs/plans/2026-10-20_human-users-build.md,
|
||||
"Brief: 193b1b, viewer verbs and board refusals" (blob de3a6310b). Design: row 193 DESIGN.md r5 e2d161e2e
|
||||
(morgana/row193), sections 4.3, 8 and 9. Base: refactor aca16404c, branch morgana/row275. Assignment: sage-2788.
|
||||
|
||||
## What the code did before
|
||||
|
||||
- `#userGrants` (broker.mjs, FED R3) kept only grants that cover at level `collaborator` (`USER_LEVEL`).
|
||||
A viewer-only user was refused `access-revoked` at `bindUser` and on every request.
|
||||
- `#granted` and `#grantedThread` asked `collaborator` for every user verb, reads included.
|
||||
- The reader session's board list was an inline array at the request gate.
|
||||
|
||||
## The change
|
||||
|
||||
1. `VIEWER_VERBS = ['project.list', 'thread.list', 'thread.messages', 'names']`, exported. `userLevel(verb)`
|
||||
gives `viewer` for these and `collaborator` for the other user verbs (thread.create, thread.open,
|
||||
thread.send). `USER_LEVEL` is gone.
|
||||
2. `#userGrants` keeps a live grant at any level (`covers(..., {level: 'viewer'})`), so a viewer binds and
|
||||
passes the request gate. The verb check against `USER_VERBS` is unchanged and still comes first.
|
||||
3. `#granted(s, verb, project, instance)` and `#grantedThread(s, verb, t)` take the verb and ask
|
||||
`userLevel(verb)`. Each handler passes its own verb name. A viewer gets `access-refused` on create,
|
||||
open and send, the code design 4.3 names.
|
||||
4. `BOARD_VERBS` exports the reader list unchanged (inbox, agents, thread.claims, tasks, trail, decisions,
|
||||
credentials, replies, messages, state, usage), and the reader gate uses it. None is in `USER_VERBS`, so
|
||||
every user is refused them `access-refused`. The test reads the same constant and pins it written out.
|
||||
|
||||
Downgrade path (design 4.3 note 2): a collaborator lowered to viewer still lists and reads its own threads
|
||||
and can't open or send. The runtime's user transport calls `bindUser`, so a viewer can now bind over the
|
||||
socket too. That is the intended effect; the verbs it reaches are the four above.
|
||||
|
||||
Not in this row: the share verbs, `thread.list {shared: true}` and `#readable`'s share rules (193b2, row 206);
|
||||
the revocation timer, `access.reload` and the level-loss test (FED R4, which mutant 5 moved to). FED R4 has
|
||||
not landed; this row checks the level per request only, which is what R3 already reads per request.
|
||||
|
||||
## Existing test changes (mos-1311 ruling)
|
||||
|
||||
Mos approved both changes in mos-1311 (16:44:56Z), under mos-1272. They follow row 193 DESIGN r5
|
||||
(e2d161e2e, approved by Duckworth) section 4.3: the thread.list row at DESIGN.md:125 ("own threads still
|
||||
covered (from an earlier collaborator grant)") and note 2 at :139 ("A viewer can still read its own
|
||||
threads when a collaborator grant is lowered to viewer, but can't send or open them"). Condition 3: Duckworth
|
||||
confirms in review that both match section 4.3.
|
||||
|
||||
All in `packages/bus/tests/user-principal.test.mjs` (FED R3, row 274):
|
||||
|
||||
| Test | Assertion | Old expectation | New expectation | Design line |
|
||||
|---|---|---|---|---|
|
||||
| "R3: bindUser binds a granted user and refuses one with no live grant" (title was "...no live collaborator grant") | `bindUser` with `grant({ level: 'viewer' })` | refused `access-revoked` (case `'viewer level'` in the refused list) | binds: `assert.ok(user('alice'), 'viewer level')`; the case leaves the refused list | :125 viewer column; 4.3 viewer verbs need a bound viewer |
|
||||
| "R3: a removed or expired grant refuses the next request access-revoked..." | `thread.list` after `level: 'viewer'` ('downgraded') | `access-revoked` | `null` (answers) | :125 and :139 |
|
||||
| same test, added (mos-1311 condition 1) | `thread.send` and `thread.open` on A after the downgrade | (not asserted) | each `access-refused`; `messages` and `thread_opens` counts unchanged | :139 "can't send or open them" |
|
||||
|
||||
The header comment of the file is updated to say a viewer grant binds. Every other R3 refusal (no grant,
|
||||
another user, another business, expired, unreadable file, no file, removed, file refused) is unchanged.
|
||||
No other test names a viewer grant on the bus (git grep for `level: 'viewer'` outside packages/business).
|
||||
|
||||
## Tests, packages/bus/tests/viewer.test.mjs (new)
|
||||
|
||||
1. VIEWER_VERBS is the ruled list and sits inside USER_VERBS.
|
||||
2. A viewer binds; project.list, names, thread.list and thread.messages answer what its grant covers
|
||||
(alice downgraded, bob viewer from the start).
|
||||
3. A viewer is refused thread.create, thread.open and thread.send `access-refused`, and messages, threads
|
||||
and thread_opens counts are unchanged. Kills mutant 6 at alice's send. r3 (duckworth-0166 B1): bob is
|
||||
asked create only, as `#thread` refuses a non-owner `thread-not-owned` before the level check; open and
|
||||
send are asked on alice's own thread. Bob stays the viewer who never held a collaborator grant.
|
||||
4. Every verb off USER_VERBS refuses a viewer `access-refused` before its own checks.
|
||||
5. Mixed grants: collaborator on project a, viewer on b. Create in a works, create in b refuses; send in b's
|
||||
old thread refuses, open refuses, messages reads the message sent while collaborator (r3, N1).
|
||||
6. Board reads (BOARD_VERBS, the project file verbs, limits, plugins, instances, routes) refuse alice
|
||||
(downgraded viewer), bob (viewer) and carol (collaborator) `access-refused`. Kills mutant 10.
|
||||
7. Governance pin, user side: one row per item of the 5.7 sentence (decisions, inbox entries, claims as
|
||||
`agents` and `thread.claims`, tasks as `tasks` and `state`, project files as `project.files` and
|
||||
`project.file`), each refused to a collaborator and a viewer while a run in carol's thread has raised a
|
||||
decision the owner sees; carol still reads her own message with thread.messages. The owner side stays
|
||||
in message-filter.test.mjs (row 205).
|
||||
|
||||
## Mutants (design 8)
|
||||
|
||||
| # | Mutation | Killed by |
|
||||
|---|---|---|
|
||||
| 6 | `'thread.send'` added to `VIEWER_VERBS` | viewer.test 3 (send answers); test 1 pin fails too |
|
||||
| 10 | `'trail'` added to `USER_VERBS` | viewer.test 6 (trail no longer access-refused); R3's exact USER_VERBS pin fails too |
|
||||
|
||||
The kill rule for each mutant names the behaviour test above, not the pin.
|
||||
|
||||
## Gate (brief)
|
||||
|
||||
Duckworth approves the manifest. On Sage's gate: the touched test files whole (viewer, user-principal,
|
||||
message-filter), the bus suite and every `scripts/test-*.sh` green, mutants 6 and 10 killed. broker.mjs
|
||||
is imported by the cli, harness, business and seat suites as well; I'll propose them for the gate as in
|
||||
row 277 (sage-2590). Nothing has run yet: built during Gander's 253 slot with no node.
|
||||
+20
-30
@@ -132,15 +132,8 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
|
||||
// refuses a user access-refused before its own checks: owner verbs, board reads, message.*,
|
||||
// decision.*, project files, spec.read, assistant.stop, launch.*, name.set and usage.*.
|
||||
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
|
||||
// Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They
|
||||
// read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a
|
||||
// collaborator grant on the project and instance, so a viewer gets access-refused on each.
|
||||
export const VIEWER_VERBS = Object.freeze(['project.list', 'thread.list', 'thread.messages', 'names']);
|
||||
// The least level a user verb needs (R1's covers, morgana-0005; LEVELS runs from most to least).
|
||||
const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collaborator');
|
||||
// Row 275 (row 193 design 4.3): the owner's board reads, which a reader session may call. A user,
|
||||
// collaborator or viewer, is refused every one at the USER_VERBS gate.
|
||||
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
|
||||
// The level a user's grant must reach for USER_VERBS (R1's covers, morgana-0005).
|
||||
const USER_LEVEL = 'collaborator';
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
@@ -771,8 +764,7 @@ export class Broker {
|
||||
return s.tester ? 'tester' : s.user ? 'user:' + s.user : s.human;
|
||||
}
|
||||
// FED R3 (design 3.5): the user's live grants in the session's business, from one read of the
|
||||
// access file. Any failure to read leaves none. Row 275: a grant at any level counts here; each
|
||||
// verb's own check asks the level it needs (#granted).
|
||||
// access file. Any failure to read leaves none.
|
||||
#userGrants(s) {
|
||||
let doc = null;
|
||||
try {
|
||||
@@ -782,22 +774,21 @@ export class Broker {
|
||||
}
|
||||
const now = new Date();
|
||||
return (doc?.grants ?? []).filter((g) => g.user === s.user && g.business === s.business &&
|
||||
covers(g, { business: s.business, project: g.projects[0], instance: g.instances[0] }, now, { level: 'viewer' }));
|
||||
covers(g, { business: s.business, project: g.projects[0], instance: g.instances[0] }, now, { level: USER_LEVEL }));
|
||||
}
|
||||
// Does one of the request's grants cover a thread of `instance` in `project`, at the level `verb`
|
||||
// needs? A null `instance` asks about the project alone, a null `project` about the instance alone.
|
||||
#granted(s, verb, project, instance) {
|
||||
// Does one of the request's grants cover a thread of `instance` in `project`? A null `instance`
|
||||
// asks about the project alone, a null `project` about the instance alone.
|
||||
#granted(s, project, instance) {
|
||||
const now = new Date();
|
||||
return s.grants.some((g) => covers(g, {
|
||||
business: s.business,
|
||||
project: project ?? g.projects[0],
|
||||
instance: instance ?? g.instances[0],
|
||||
}, now, { level: userLevel(verb) }));
|
||||
}, now, { level: USER_LEVEL }));
|
||||
}
|
||||
// A thread the request's grants still cover at the level `verb` needs, else access-refused
|
||||
// (design 3.5, thread.open/send/messages; row 193 design 4.3 for the level).
|
||||
#grantedThread(s, verb, t) {
|
||||
if (s.user && !this.#granted(s, verb, t.project, t.instance)) fail('access-refused');
|
||||
// A thread the request's grants still cover, else access-refused (design 3.5, thread.open/send/messages).
|
||||
#grantedThread(s, t) {
|
||||
if (s.user && !this.#granted(s, t.project, t.instance)) fail('access-refused');
|
||||
return t;
|
||||
}
|
||||
// Row 205 (193b1a, #1703, row 193 design 5.7): a message in a thread a user other than the caller
|
||||
@@ -1020,8 +1011,7 @@ export class Broker {
|
||||
}
|
||||
// FED R3 (#414, design 3.6): the trusted user transport invokes this only after row 196's
|
||||
// verifyPrincipal checked the user CLI and its Console parent. It binds only a user the access
|
||||
// file gives a live grant in the business (check 3), else access-revoked. Row 275: a viewer grant
|
||||
// binds too; the verbs it reaches are VIEWER_VERBS. A user id
|
||||
// file gives a live collaborator grant in the business (check 3), else access-revoked. A user id
|
||||
// is never the bus's own principals' or the business human's, whatever the file says.
|
||||
bindUser(record) {
|
||||
keys(record, ['business', 'user'], ['business', 'user']);
|
||||
@@ -4236,7 +4226,7 @@ export class Broker {
|
||||
return result;
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
if (s.reader && !BOARD_VERBS.includes(request.verb))
|
||||
if (s.reader && !['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage'].includes(request.verb))
|
||||
fail('read-only');
|
||||
if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused');
|
||||
// A tester reads the human's views, as a reader does, and its message verbs are its own.
|
||||
@@ -5689,7 +5679,7 @@ export class Broker {
|
||||
return this.#store.all(
|
||||
'SELECT instance,name,at FROM instance_names n WHERE business=? AND seq=(SELECT max(seq) FROM instance_names WHERE business=n.business AND instance=n.instance) ORDER BY instance',
|
||||
s.business,
|
||||
).filter((r) => !s.user || this.#granted(s, 'names', null, r.instance));
|
||||
).filter((r) => !s.user || this.#granted(s, null, r.instance));
|
||||
}
|
||||
// Row 121 (#1615): projects group the user's threads (REQ-ORG-2). `repo` names a
|
||||
// project from the business file; the rename to repos is a P19 follow-up.
|
||||
@@ -5719,7 +5709,7 @@ export class Broker {
|
||||
// FED R3 (design 3.5): a user lists granted projects only.
|
||||
return this.#store
|
||||
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
|
||||
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
|
||||
.filter((p) => !s.user || this.#granted(s, p.id, null));
|
||||
}
|
||||
// A thread belongs to the user who made it and to the business's one thread instance.
|
||||
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
|
||||
@@ -5731,7 +5721,7 @@ export class Broker {
|
||||
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
|
||||
if (s.user) {
|
||||
const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
|
||||
if (threaded.length !== 1 || !this.#granted(s, identifier(a.project), threaded[0])) fail('access-refused');
|
||||
}
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
|
||||
fail('project-not-found');
|
||||
@@ -5769,14 +5759,14 @@ export class Broker {
|
||||
s.business,
|
||||
this.#owner(s),
|
||||
...(a.project === undefined ? [] : [a.project]),
|
||||
).filter((t) => !s.user || this.#granted(s, 'thread.list', t.project, t.instance));
|
||||
).filter((t) => !s.user || this.#granted(s, t.project, t.instance));
|
||||
}
|
||||
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1). Row 264a: a thread
|
||||
// verb names one session of the thread, or works on its default (D3).
|
||||
case 'thread.open': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['thread', 'session'], ['thread']);
|
||||
const t = this.#grantedThread(s, 'thread.open', this.#thread(s, a.thread));
|
||||
const t = this.#grantedThread(s, this.#thread(s, a.thread));
|
||||
const session = this.#threadSession(t, a.session);
|
||||
this.#store.run('INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)', this.#now(), t.id, session, this.#owner(s));
|
||||
return { opened: t.id };
|
||||
@@ -5786,7 +5776,7 @@ export class Broker {
|
||||
case 'thread.send': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['thread', 'session', 'body', 'class'], ['thread', 'body']);
|
||||
const t = this.#grantedThread(s, 'thread.send', this.#thread(s, a.thread));
|
||||
const t = this.#grantedThread(s, this.#thread(s, a.thread));
|
||||
const session = this.#threadSession(t, a.session);
|
||||
// Row 120's lists hold here as on message.send: the instance must hear from the human.
|
||||
this.#checkMessaging(s.business, 'human', t.instance);
|
||||
@@ -5815,7 +5805,7 @@ export class Broker {
|
||||
case 'thread.messages': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['thread', 'session', 'after', 'limit'], ['thread']);
|
||||
const t = this.#grantedThread(s, 'thread.messages', this.#thread(s, a.thread));
|
||||
const t = this.#grantedThread(s, this.#thread(s, a.thread));
|
||||
const session = this.#threadSession(t, a.session);
|
||||
const limit = a.limit ?? THREAD_PAGE;
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// FED R3 (#414, federation design 3.5, 3.6 and the R3 row of section 7): the broker's user principal.
|
||||
// A user binds only with a live grant (row 275: a viewer grant binds too; viewer.test.mjs has the
|
||||
// viewer cases), reaches the exact collaborator verb list, sees only what its grants cover, and
|
||||
// talks to the granted instance in its own threads. The access
|
||||
// A user binds only with a live collaborator grant, reaches the exact collaborator verb list, sees
|
||||
// only what its grants cover, and talks to the granted instance in its own threads. The access
|
||||
// document comes in as a function, as the runtime passes R1's loadAccess; each test changes it.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -84,17 +83,15 @@ function granted(t, options) {
|
||||
return { ...ctx, p1, p2, A };
|
||||
}
|
||||
|
||||
test('R3: bindUser binds a granted user and refuses one with no live grant', (t) => {
|
||||
test('R3: bindUser binds a granted user and refuses one with no live collaborator grant', (t) => {
|
||||
const { b, access, user } = setup(t);
|
||||
assert.ok(user('alice'));
|
||||
// Row 275 (193b1b): a viewer grant binds; it used to refuse here as the case 'viewer level'.
|
||||
access.doc.grants = [grant({ level: 'viewer' })];
|
||||
assert.ok(user('alice'), 'viewer level');
|
||||
for (const [what, grants] of [
|
||||
['no grant', []],
|
||||
['another user', [grant({ user: 'bob' })]],
|
||||
['another business', [grant({ business: 'other' })]],
|
||||
['expired', [grant({ expires: '2000-01-01T00:00:00Z' })]],
|
||||
['viewer level', [grant({ level: 'viewer' })]],
|
||||
]) {
|
||||
access.doc.grants = grants;
|
||||
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, what);
|
||||
@@ -300,7 +297,7 @@ test('R3: a user thread run cannot message the owner, while its uncited gated ra
|
||||
});
|
||||
|
||||
test('R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request', (t) => {
|
||||
const { call, code, access, store, A } = granted(t);
|
||||
const { call, code, access } = granted(t);
|
||||
const reads = access.reads;
|
||||
call('alice', 'thread.list');
|
||||
call('alice', 'project.list');
|
||||
@@ -310,15 +307,8 @@ test('R3: a removed or expired grant refuses the next request access-revoked, an
|
||||
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'removed');
|
||||
access.doc.grants = kept.map((g) => ({ ...g, expires: '2000-01-01T00:00:00Z' }));
|
||||
assert.equal(code('alice', 'project.list'), 'access-revoked', 'expired');
|
||||
// Row 275 (193b1b, design 4.3): a grant lowered to viewer still reads the user's own threads.
|
||||
access.doc.grants = kept.map((g) => ({ ...g, level: 'viewer' }));
|
||||
assert.equal(code('alice', 'thread.list'), null, 'downgraded');
|
||||
// mos-1311 condition 1: the downgraded viewer still can't send or open, and nothing is stored.
|
||||
const stored = () => ['messages', 'thread_opens'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n);
|
||||
const before = stored();
|
||||
assert.equal(code('alice', 'thread.send', { thread: A, body: 'still mine?' }), 'access-refused', 'downgraded send');
|
||||
assert.equal(code('alice', 'thread.open', { thread: A }), 'access-refused', 'downgraded open');
|
||||
assert.deepEqual(stored(), before, 'downgraded: nothing stored');
|
||||
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'downgraded');
|
||||
access.fail = true;
|
||||
access.doc.grants = kept;
|
||||
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'file refused');
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
// Row 275 (193b1b, #1780, row 193 design 4.3, 8 and 9): the viewer level on the bus. A viewer grant
|
||||
// binds and reaches VIEWER_VERBS only; thread.create, thread.open and thread.send need a collaborator
|
||||
// grant on the project and instance. Board reads refuse every user, collaborator or viewer, and the
|
||||
// user-side governance pin names each item of the 5.7 user sentence. The fixture is user-principal's.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import {
|
||||
Broker, ACTIONS, USER_VERBS, VIEWER_VERBS, BOARD_VERBS, TESTER_VERBS, TESTER_ORGANIZATION_VERBS, PROJECT_FILE_VERBS,
|
||||
} from '../src/broker.mjs';
|
||||
|
||||
const shipped = (name) =>
|
||||
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const grant = (over = {}) => ({
|
||||
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
|
||||
});
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-viewer-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [grant()] } };
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
},
|
||||
},
|
||||
access: () => access.doc,
|
||||
});
|
||||
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const code = (who, verb, args = {}) => {
|
||||
try {
|
||||
call(who, verb, args);
|
||||
} catch (e) {
|
||||
return e.code;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const user = (name) => (caps[name] = b.bindUser({ business: 'demo', user: name }));
|
||||
const launch = (name, thread) => {
|
||||
caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
|
||||
return call(name, 'role.claim');
|
||||
};
|
||||
return { b, store, access, caps, call, code, user, launch };
|
||||
}
|
||||
|
||||
// Two projects. Alice, a collaborator on p1, made thread A there and sent one message; then her grant
|
||||
// is lowered to viewer (the downgrade path, design 4.3). Bob is a viewer on p1 from the start.
|
||||
function viewers(t) {
|
||||
const ctx = setup(t);
|
||||
const p1 = ctx.call('human', 'project.create', { name: 'shared' }).id;
|
||||
const p2 = ctx.call('human', 'project.create', { name: 'private' }).id;
|
||||
ctx.access.doc.grants = [grant({ projects: [p1] })];
|
||||
ctx.user('alice');
|
||||
const A = ctx.call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
|
||||
const sent = ctx.call('alice', 'thread.send', { thread: A, body: 'hello' }).id;
|
||||
ctx.access.doc.grants = [
|
||||
grant({ projects: [p1], level: 'viewer' }),
|
||||
grant({ id: 'g-b', user: 'bob', projects: [p1], level: 'viewer' }),
|
||||
];
|
||||
ctx.user('bob');
|
||||
return { ...ctx, p1, p2, A, sent };
|
||||
}
|
||||
|
||||
test('275: VIEWER_VERBS is the ruled read list, inside USER_VERBS', () => {
|
||||
assert.deepEqual(VIEWER_VERBS, ['project.list', 'thread.list', 'thread.messages', 'names']);
|
||||
for (const v of VIEWER_VERBS) assert.ok(USER_VERBS.includes(v), v);
|
||||
});
|
||||
|
||||
test('275: a viewer binds, and each viewer verb answers with what its grant covers', (t) => {
|
||||
const { call, p1, A, sent } = viewers(t);
|
||||
call('human', 'name.set', { instance: 'assistant', name: 'ada' });
|
||||
call('human', 'name.set', { instance: 'chief', name: 'cleo' });
|
||||
for (const who of ['alice', 'bob']) {
|
||||
assert.deepEqual(call(who, 'project.list').map((p) => p.id), [p1], who);
|
||||
assert.deepEqual(call(who, 'names').map((n) => n.instance), ['assistant'], who);
|
||||
}
|
||||
// Alice's own old thread still lists and reads; bob has none.
|
||||
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]);
|
||||
assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent]);
|
||||
assert.deepEqual(call('bob', 'thread.list'), []);
|
||||
});
|
||||
|
||||
// Mutant 6 (row 193 design 8): a viewer allowed thread.send. This test kills it.
|
||||
test('275: a viewer is refused thread.create, thread.open and thread.send access-refused, and nothing is stored', (t) => {
|
||||
const { store, code, p1, A } = viewers(t);
|
||||
const counts = () => ['messages', 'threads', 'thread_opens'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n);
|
||||
const before = counts();
|
||||
for (const who of ['alice', 'bob'])
|
||||
assert.equal(code(who, 'thread.create', { project: p1, name: `${who} new` }), 'access-refused', `${who} create`);
|
||||
// Only alice owns a thread: #thread refuses a non-owner thread-not-owned before the level check
|
||||
// (duckworth-0166 B1), so open and send are asked on her own A.
|
||||
assert.equal(code('alice', 'thread.open', { thread: A }), 'access-refused', 'alice open');
|
||||
assert.equal(code('alice', 'thread.send', { thread: A, body: 'still here?' }), 'access-refused', 'alice send');
|
||||
assert.deepEqual(counts(), before);
|
||||
});
|
||||
|
||||
test('275: every verb off the user list refuses a viewer access-refused before its own checks', (t) => {
|
||||
const { b, caps } = viewers(t);
|
||||
const others = new Set([...ACTIONS, ...TESTER_VERBS, ...TESTER_ORGANIZATION_VERBS, ...PROJECT_FILE_VERBS, ...BOARD_VERBS, 'no.such.verb']);
|
||||
for (const v of USER_VERBS) others.delete(v);
|
||||
assert.ok(others.size > 40);
|
||||
for (const verb of others)
|
||||
assert.throws(() => b.request(caps.bob, { verb, args: { junk: true } }), { code: 'access-refused' }, verb);
|
||||
});
|
||||
|
||||
test('275: mixed grants, collaborator on one project and viewer on another, take the level per project', (t) => {
|
||||
const { call, code, access, user } = setup(t);
|
||||
const pa = call('human', 'project.create', { name: 'a' }).id;
|
||||
const pb = call('human', 'project.create', { name: 'b' }).id;
|
||||
access.doc.grants = [grant({ projects: [pa] }), grant({ id: 'g-2', projects: [pb] })];
|
||||
user('alice');
|
||||
const B = call('alice', 'thread.create', { project: pb, name: 'b work' }).id;
|
||||
const inB = call('alice', 'thread.send', { thread: B, body: 'in b, before' }).id;
|
||||
access.doc.grants = [grant({ projects: [pa] }), grant({ id: 'g-2', projects: [pb], level: 'viewer' })];
|
||||
const A = call('alice', 'thread.create', { project: pa, name: 'a work' }).id;
|
||||
assert.ok(A, 'create in the collaborator project works');
|
||||
assert.equal(code('alice', 'thread.create', { project: pb, name: 'b more' }), 'access-refused', 'create in the viewer project');
|
||||
assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [pa, pb]);
|
||||
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [B, A]);
|
||||
assert.ok(call('alice', 'thread.send', { thread: A, body: 'in a' }).id);
|
||||
assert.equal(code('alice', 'thread.send', { thread: B, body: 'in b' }), 'access-refused');
|
||||
assert.equal(code('alice', 'thread.open', { thread: B }), 'access-refused');
|
||||
assert.deepEqual(call('alice', 'thread.messages', { thread: B }).map((m) => m.id), [inB]);
|
||||
});
|
||||
|
||||
// Mutant 10 (row 193 design 8): a user allowed `trail`. This test kills it; R3's exact USER_VERBS
|
||||
// pin in user-principal.test.mjs fails too. Carol is a collaborator, so the refusal isn't the level's.
|
||||
test('275: board reads refuse users and viewers access-refused', (t) => {
|
||||
const { code, access, user, p1, A } = viewers(t);
|
||||
assert.deepEqual(BOARD_VERBS, ['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
|
||||
access.doc.grants = [...access.doc.grants, grant({ id: 'g-c', user: 'carol', projects: [p1] })];
|
||||
user('carol');
|
||||
for (const who of ['alice', 'bob', 'carol']) {
|
||||
for (const verb of [...BOARD_VERBS, ...PROJECT_FILE_VERBS, 'limits', 'plugins', 'instances', 'routes'])
|
||||
assert.equal(code(who, verb, verb === 'trail' ? { subject: A } : {}), 'access-refused', `${who} ${verb}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 193's user sentence, from the user's side (mos-1023 item 2, design 8): "The business owner sees
|
||||
// your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your
|
||||
// messages." The owner side is message-filter.test.mjs's pin. Here each item the owner sees is a
|
||||
// board read the user is refused, at either level, while the user still reads its own thread.
|
||||
test('governance pin, user side: decisions, inbox entries, claims, tasks and project files are the owner\'s, the user reads its messages', (t) => {
|
||||
const { call, code, access, user, launch, p1 } = viewers(t);
|
||||
access.doc.grants = [...access.doc.grants, grant({ id: 'g-c', user: 'carol', projects: [p1] })];
|
||||
user('carol');
|
||||
const C = call('carol', 'thread.create', { project: p1, name: 'carol work' }).id;
|
||||
const sent = call('carol', 'thread.send', { thread: C, body: 'please plan it' }).id;
|
||||
launch('a', C);
|
||||
const d = call('a', 'decision.raise', { action: 'deploy', question: 'Ship?', options, recommendation: 'yes', blocking: false }).id;
|
||||
assert.ok(call('human', 'inbox').some((x) => x.id === d), 'the owner sees the raise');
|
||||
const items = [
|
||||
['decisions', 'decisions', { list: 'recent' }],
|
||||
['inbox entries', 'inbox', {}],
|
||||
['claims', 'agents', {}],
|
||||
['claims', 'thread.claims', {}],
|
||||
['tasks', 'tasks', {}],
|
||||
['tasks', 'state', {}],
|
||||
['project files', 'project.files', { project: 'stack' }],
|
||||
['project files', 'project.file', { project: 'stack', path: 'docs/plan.md' }],
|
||||
];
|
||||
for (const who of ['carol', 'bob'])
|
||||
for (const [item, verb, args] of items) assert.equal(code(who, verb, args), 'access-refused', `${item}: ${who} ${verb}`);
|
||||
assert.deepEqual(call('carol', 'thread.messages', { thread: C }).map((m) => m.id), [sent], 'messages: the user reads its own');
|
||||
});
|
||||
Reference in new issue
Block a user