Skip to content

[Space] add pairing session - #64

Merged
Herklos merged 4 commits into
masterfrom
feature/add-pairing-session
Aug 10, 2026
Merged

[Space] add pairing session#64
Herklos merged 4 commits into
masterfrom
feature/add-pairing-session

Conversation

@Herklos

@Herklos Herklos commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

No description provided.

… (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
Herklos force-pushed the feature/add-pairing-session branch from ee1e28f to f52ed31 Compare August 7, 2026 10:03
…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
Comment on lines +323 to +329
"space_id": None,
"created": [],
"written": [],
"skipped": [],
"cleared": [],
"failed": [],
"errors": [],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i think those keys should be constants or enums

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.

Right thanks, it's fixed!

@Herklos
Herklos force-pushed the feature/add-pairing-session branch from df3f0bd to 7f87ef1 Compare August 8, 2026 13:06
@Herklos
Herklos marked this pull request as ready for review August 8, 2026 14:15
@Herklos
Herklos force-pushed the feature/add-pairing-session branch from 7f87ef1 to 4ac7434 Compare August 8, 2026 14:19
@Herklos
Herklos enabled auto-merge (rebase) August 8, 2026 16:12


def _as_dict(obj: Any) -> dict[str, Any]:
"""Normalize a dataclass/``Space``/plain-dict node or space into a dict."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we should use dataclasses.asdict() when obj is a dataclass before using introspection



@dataclass
class _SpaceOutcome:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍

…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
Herklos force-pushed the feature/add-pairing-session branch from 4ac7434 to cdbb830 Compare August 8, 2026 16:59
@Herklos

Herklos commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

it's up

@GuillaumeDSM GuillaumeDSM left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍

@Herklos
Herklos merged commit 71a30fb into master Aug 10, 2026
39 checks passed
@Herklos
Herklos deleted the feature/add-pairing-session branch August 10, 2026 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants