[Space] add pairing session - #64
Merged
Merged
Conversation
… (3.0.0-alpha.70)
Generalizes starfish-replica's ReplicaManager from a single hardcoded HTTP
data path into a scheduler driving a pluggable ReplicaChannel, and adds a
second channel that mirrors a local data source into a Starfish space instead
of a local ObjectStore. Ports the whole thing to Python at parity. Also fixes
five starfish-spaces bugs found while building it — four of which made the
package unusable against a real server, and were previously worked around
with patch-package / pnpm patches downstream.
starfish-replica / starfish_replica (TS + Python)
- ReplicaChannel / ReplicaCallContext / ChannelSchedule / ScheduledChannel:
the seam scheduling now happens against.
- ChannelScheduler: the scheduler proper (interval loop, on_pull cooldown,
error funnel), extracted so it has NO dependency on starfish-server.
ReplicaManager extends it and keeps the back-compat HTTP constructor plus
remoteFor/proxyPush; ReplicaManager.fromChannels builds one from arbitrary
channels.
- HttpReplicaChannel: the original primary->replica-server path, now a
standalone channel. Its sync body moved verbatim, so every existing test
passes unedited.
- ./space subpath (TS) and starfish_replica.space (Python, via a new `space`
optional extra): createSpaceMirrorChannel — space find-or-create, node
find-or-create, CAS-write, clear-on-disable, optional source-hash skip —
plus planSpaceMirror, findOrCreateSpace, SpacePort, and (TS only)
readSpaceMirror for a session-less grant holder. Importing it never pulls in
starfish-server, which is what lets a React Native / browser consumer bundle
it at all; the root entry cannot, and that was found by a real Metro failure
on node:dns/promises.
- Fixed: the on_pull cooldown never engaged for a no-op sync — it was stamped
only inside a successful write, so every on_pull hit the primary until one
landed.
- Fixed: a "scheduled" entry omitting intervalMs span an unthrottled sync loop
(setInterval(fn, 0)). Defaults to 60s now, matching Python.
starfish-spaces / starfish_spaces (TS + Python)
- Fixed: createNode self-minted an invalid objinvlog "member" cap for its own
creator. assertMemberCapShape rejects subUserId === issUserId, so every
createNode call threw against a real server. The mint served no purpose —
the owner already reaches their own nodes through the account/owner scope.
- Fixed: pullSpacesDoc raced StarfishClient.push()'s fire-and-forget cache
write-through. readSpaces right after createSpace could serve the pre-write
snapshot, causing duplicate spaces or a stale-hash CAS 409. Now
network-first; staleWhileRevalidate dropped.
- Fixed (Python): create_node's and set_node_access's mutator closures were
async def while update_object_index calls them synchronously, so both threw
TypeError on every call. Never caught by a test before.
- Fixed: a plaintext node was handed an encryptor. Every tier builds one when
it can, and tier 5 falls back to the space keyring, which exists in any space
holding one encrypted node. A node declared access:"public", enc:false came
back sealable — writing through it puts ciphertext in a collection the server
declares encryption="none" with read_roles ["public"]: HTTP 200, silent,
world-readable, permanent. Python's get_node_access/build_node_access gained
the `node` parameter TS always had, plus the tier-0 short-circuit.
- Fixed: getNodeAccess's handle cache was keyed ${spaceId}:${nodeId} — neither
the resolving identity nor the tier. A second identity in the same process
got the first one's cap-bearing client, and a node's plaintext and encrypted
views collided, reaching the same seal-a-plaintext-node failure through the
cache.
Defence in depth: the space port refuses to push at all when a node declares
enc:false but the handle carries an encryptor, so the hole stays shut even if
a custom SpacePort or a later refactor stops feeding the resolver its axes.
Verification: 1414 TS tests and 331 Python tests pass; typecheck and build
clean; dist/space contains zero starfish-server references; the Python wheel
ships starfish_replica/space. Each fix's tests were checked non-vacuous by
reverting the fix and confirming they fail.
Bumps all TS packages to 3.0.0-alpha.70 and all Python packages to 3.0.0a70
(the Python line was on a67 and catches up here). Publish workflows updated:
publish-replica now waits on publish-spaces in both ecosystems, and the TS one
pins the new peer deps plus the previously-unpinned starfish-identities.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwj6oBJXJvwSdnEoNqJ3YG
… mirror tiers (alpha.71) Squashes this branch's work since the alpha.70 release (v3.0.0-alpha.70, 7e6bc22) into one commit. An unrelated `starfish-search` SQLite/CRDT search extension had gotten bundled into one of the original commits by mistake — it was never referenced by anything else in the tree and is dropped entirely here, not part of this release. ## starfish-spaces: device-code space-join pairing (join-request.ts / join_request.py) A generic, reusable "requester shows a short code, a human types it into an approving app, the approver grants space membership" primitive — fills the gap between `createSpaceInviteLink` (a bearer link, grant materializes at creation, no approval step) and `makeJoinRequest` (no transport at all). - One collection, one address: both request and grant phases live at the SAME storage path (`SpaceLayout.joinSessionPull`/`joinSessionPush`, default `_pairing/session/{code}`), keyed by the human code alone — no separate high-entropy session id, since that added no confidentiality the KEM seal doesn't already provide. - Still KEM-sealed and PoP-bound, deliberately not PIN-sealed (this flow has one out-of-band channel, not two — see CHANGELOG for the full rationale). `code` is bound into both the PoP signature and the seal AAD. - CAS discipline: request write is create-only, grant write is a CAS update against the request's own hash, so a racing bogus write is a detectable conflict rather than a silent overwrite. - The grant slot is re-pollable, not one-shot — a live pairing is read repeatedly over its lifetime; `clearSpaceJoinGrant` is explicit best-effort cleanup at unpair time, not automatic. - Python twin (`starfish_spaces.join_request`) is wire-parity verified against the TS side by a dedicated cross-language test suite. Hardening from two review rounds, folded in: - `SpaceJoinGrantIntegrityError` (TS) / bare `ValueError` (Python) let `awaitSpaceJoinGrant`/`await_space_join_grant` fail fast on a malformed/forged grant instead of polling it all the way to timeout. - `clearSpaceJoinGrant` now shares the package's `runCas` retry helper (jittered exponential backoff) instead of a hand-rolled 3-attempt loop, and treats a 404 pull as "nothing published yet" rather than an error. - Origin/authority validation rewritten from a rewrite-then-`urlparse` approximation to a purpose-built authority parser matching WHATWG `URL` semantics exactly (backslash-as-authority-slash, space-after-colon, userinfo splitting at the last `@`, IPv6 bracket handling) — verified against Node's `new URL()` as ground truth. - `joinRequestFromSpaceJoinRequest` takes an optional userId-deriver override instead of always using the global default. ## starfish-replica: per-collection storage tiers for the space-mirror channel - `objdoc` (private, E2EE, default) vs `objpub` (plaintext, world-readable) per collection, TS + Python. - BREAKING: `docPath` now takes the collection id first — `(collectionId, spaceId, nodeId) => string`, was `(spaceId, nodeId)` — so a caller can route a tier to its own path prefix. - `readSpaceMirror` no longer throws on an all-public space it could actually read in full: the space keyring is now pulled lazily, on the first node that actually carries `_encrypted` content, not unconditionally up front. - New `readPublicSpaceMirror`: anonymous, session-less read of a space's public tier — no grant, no cap, no keyring, matching `readObjectDirectory`. - A space-mirror sync cycle no longer takes every collection in every space down with it when one write fails (oversized doc, exhausted CAS retries, dropped connection): each write/clear is isolated, `result` is always replaced before a failure is reported, and `SpaceMirrorResult.created` now reflects what actually succeeded rather than the sync plan's intent. ## Versioning Every `@drakkar.software/starfish-*` (npm) and `starfish-*` (PyPI) package bumped to 3.0.0-alpha.71. CHANGELOG's `Unreleased` section retitled to `3.0.0-alpha.71` with a summary; the existing `3.0.0-alpha.70` entry (already tagged/released) is untouched. Squashes: 3476818, c4c12dc, 63250b6, 9b60bcf, 53550aa, e937ead, 4540dce, 875bf7b, ee1e28f (search-extension content from 3476818 excluded). Full pre-squash history preserved at backup/feature-add-pairing-session-pre-squash-20260807. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwj6oBJXJvwSdnEoNqJ3YG
Herklos
force-pushed
the
feature/add-pairing-session
branch
from
August 7, 2026 10:03
ee1e28f to
f52ed31
Compare
…pha.72)
A space's keyring is space-wide — every enc node in it shares one CEK, and a
space:member cap's scope covers spaces/{spaceId}/**. So a grant over one
"private" node is really a grant over every private node in that space, and
the only way to isolate two sensitivities was one space per sensitivity, per
user.
The new tier: "isolated" resolves to {access: "invite", enc: true}, sealing
the node under its OWN per-node keyring. A grant minted via
inviteToNode(..., {isolated: true}) reaches exactly that node,
revokeNodeAccess rotates exactly that node's epoch, and the grant holder is
never added to the space roster — so objindex (space:member, no cap fallback)
stays unreadable and they learn nothing about what other nodes exist.
Callers route isolated collections to objinv via docPath rather than objdoc,
whose read roles are space:member only. objinv is declared encryption:"none"
server-side, meaning the server applies no envelope of its own, exactly like
objblob — content is still E2EE, sealed client-side before the push.
Resolution is fail-closed. TS's getNodeAccess already routes invite+enc to the
node keyring with the throwing variant, so the port only adds the seeding step
(ensureNodeKeyring) it cannot do itself. Python's owner tier DOES fall back to
the space keyring when the node keyring is missing, which would silently seal
isolated content under the key every space member holds, so the Python port
does ensure-then-open explicitly and raises instead.
Also: a public or isolated node no longer takes the clearedNodes
short-circuit on the clear path (previously public only) — stale isolated
content stays readable by every holder of a still-valid grant, so a skipped
clear is not symmetric with the space-private case.
Behaviour change worth flagging: nodeEnc: {access:"invite", enc:true} as a raw
override now routes through the per-node keyring wherever those axes appear.
Strictly safer than the previous silent fallback, but it is a change — use
tier: "isolated", which also seeds the keyring.
Python 239 tests pass, TS 108 pass, both typecheck clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwj6oBJXJvwSdnEoNqJ3YG
GuillaumeDSM
reviewed
Aug 8, 2026
Comment on lines
+323
to
+329
| "space_id": None, | ||
| "created": [], | ||
| "written": [], | ||
| "skipped": [], | ||
| "cleared": [], | ||
| "failed": [], | ||
| "errors": [], |
Member
There was a problem hiding this comment.
i think those keys should be constants or enums
Contributor
Author
There was a problem hiding this comment.
Right thanks, it's fixed!
Herklos
force-pushed
the
feature/add-pairing-session
branch
from
August 8, 2026 13:06
df3f0bd to
7f87ef1
Compare
Herklos
marked this pull request as ready for review
August 8, 2026 14:15
Herklos
force-pushed
the
feature/add-pairing-session
branch
from
August 8, 2026 14:19
7f87ef1 to
4ac7434
Compare
Herklos
enabled auto-merge (rebase)
August 8, 2026 16:12
GuillaumeDSM
reviewed
Aug 8, 2026
|
|
||
|
|
||
| def _as_dict(obj: Any) -> dict[str, Any]: | ||
| """Normalize a dataclass/``Space``/plain-dict node or space into a dict.""" |
Member
There was a problem hiding this comment.
we should use dataclasses.asdict() when obj is a dataclass before using introspection
GuillaumeDSM
reviewed
Aug 8, 2026
|
|
||
|
|
||
| @dataclass | ||
| class _SpaceOutcome: |
…d tier readSpaceMirror cannot serve a per-node grant: it enumerates the space's object index (space:member, and an isolated grant holder is deliberately not on the roster) and opens the one space keyring (which an isolated node is not sealed under). readIsolatedSpaceMirror takes the node list from the grant instead of enumerating, and opens each node's own keyring. A node whose content or keyring pull fails is omitted rather than failing the batch — with per-node grants, one revoked node is a normal state, not an error for the rest. Also a cleanup + simplify pass over everything this branch touched, ~640 lines net removed with no behaviour change: Dead API removed. ReplicaManager.fromChannels / .from_channels had no caller in any repo; Python's was a trivial alias for constructing a ChannelScheduler and did not even match TS's return type. Removing the TS one also drops the @internal three-arg constructor overload that existed only to serve it, so ReplicaManager is back to one signature. _LastHashView, a 42-line MutableMapping shim forwarding manager._last_hash[name] to the owning channel's scalar, existed for exactly one line in one test; that test now pokes the channel directly, and the 11 tests covering the shim itself are gone. starfish_replica.space stops re-exporting DEFAULT_NODE_ENC / PUBLIC_NODE_ENC, which nothing imported and which TS keeps module-private. _as_dict now routes dataclasses through dataclasses.asdict() rather than raw __dict__ introspection (PR review). to_dict still wins where a type ships one: ObjectTreeNode uses it to rename fields to their wire form, which asdict would not do. Comment volume cut hard across join_request.py/.ts and the space mirror channel/reader in both languages, keeping the load-bearing constraints (tier -> storage-collection routing, the fail-closed isolated-node keyring resolution, the stored-axes clear ordering, wire-parity rules) and dropping the history narration and decision essays. TS gains an emptyOutcome() helper so SpaceOutcome construction matches Python's _SpaceOutcome() defaults. Fixes 3 pre-existing failures in the opt-in append-only stress suite (STARFISH_STRESS=1), unrelated to this branch: they called handleAppendOnlyPull with a null checkpoint, which has returned 400 "pull bound required" since alpha.63, so pulledItems() dereferenced an undefined body.data. They now pass "0", the explicit from-the-start checkpoint their own names describe. Verified: pnpm -r build, pnpm -r typecheck, pnpm -r test (2465 passed) all clean; every Python package green (replica 228, spaces 244, server 790); both opt-in stress suites green (TS 12, Python 35). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fwj6oBJXJvwSdnEoNqJ3YG
Herklos
force-pushed
the
feature/add-pairing-session
branch
from
August 8, 2026 16:59
4ac7434 to
cdbb830
Compare
Contributor
Author
|
it's up |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.