feat(base,app,learn): invitations to shared spaces by mail and by link

The instance's half of Halkyon's ADR 0044, step 5 (D37 to D40).

An invitation by mail (sharedSpace.inviteByEmail) goes out through the
register (send-space-invitation), naming who invites by their handle
and nothing of what is shared, with a link to /join/<token>. The
instance keeps only the address's keyed hash, as for sign-in, and the
SHA-256 of the token. Whoever opens it joins with a code mailed to that
address (EmailCodePurpose.joinSpace, proven through the register,
prove-address-code), so a forwarded mail admits nobody else; a code
proving another address is refused. It lapses after 14 days, and the
hourly pruning forgets it; inviting the address again replaces it.

A space's own link (setJoinLink, the Owner's) admits whoever opens it
as a Viewer (joinByLink) while it is on; switched off and on, it gets a
new token. Its token is kept as it is, so that whoever may invite copies
the link again (joinLink). The page at /join/<token>, in English and
German, says who invites and as whom; preview tells the app the same.

A membership someone else made is new to its member until they mark it
seen (isNew, addedBy; mine names who shared it), for the Inbox.

halkyon_app learns the two new refusals (handleNeeded, otherAddress),
which Learn reports as not allowed until its screens offer these ways
in. The ADR records D37 to D40 and how the instance's half is built.

The migration, the same in the module and in each server including it
(Learn, Meditate, Todo, Train):

  ALTER TABLE "shared_space" ADD COLUMN "joinToken" text;
  CREATE UNIQUE INDEX "shared_space_join_token_idx"
    ON "shared_space" ("joinToken");
  CREATE TABLE "shared_space_email_invitation" (…), with its unique
    indexes on the space and address hash, and on the token's hash,
    and its foreign key to "shared_space" ON DELETE CASCADE;
  ALTER TABLE "shared_space_member" ADD COLUMN "addedBy" uuid;
  ALTER TABLE "shared_space_member"
    ADD COLUMN "isNew" boolean NOT NULL DEFAULT false;

Every space gets no token, and the unique index allows any number of
nulls; every membership gets no one who added it and isn't new, so
nothing already shared shows in an Inbox. The new table starts empty.
None of it rewrites a table, and the server before the switch names its
own columns, so it keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 18:28:13 +01:00
commit 554e1604b4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -198,6 +198,25 @@ member reads everything in the space.
row, so one member's would be everyone's, and a Viewer's would be refused. They return
once they are each person's own rows by the card's ID, as reviews are (D26), a change
of the synced tables shipped with a release.
- **D37.** **An email invitation admits only who proves the address** (decided 11 Nov
2026, the agent's recommendation, reading D4): accepting its link asks for a code mailed
to the invited address (halkyon_base's email codes), so a forwarded or leaked mail
admits nobody else; it lapses after 14 days, and someone without an account signs up
first.
- **D38.** **The instance keeps an invited address as a keyed hash alone** (decided 11
Nov 2026, the agent's recommendation): the mail goes out as the invitation is made,
then only HMAC-SHA256 of the address in lower case stays (ADR 0010's pepper), to check
the proof against. Members see "invited by email", never the address; resending means
typing it again.
- **D39.** **A link opens a page on the instance, then the app** (decided 11 Nov 2026,
the agent's recommendation): an email invitation's link and a space's join link (D5)
are pages (`/join/…`) saying what they are and to open Halkyon Learn, with a button
opening the app where it can (Android's app links) and the code to paste in the
Library's *Join with a link* everywhere else.
- **D40.** **What is shared with someone shows in their Inbox and on its card**
(decided 11 Nov 2026, the agent's recommendation): Profile's Inbox lists "@alice shared
Japanese with you" with Open and Leave, counted in its badge until seen, and the
studyset's library card says New until it is opened.
## Steps
@ -221,6 +240,23 @@ member reads everything in the space.
the key rolled once a member goes, and a Viewer's material opened read-only (D35),
burying and reporting hidden in shared studysets (D36).
5. **Invitations by email and by link** (D4, D5), the Inbox.
The instance's half built 11 Nov 2026 (D37 to D40): `sharedSpace.inviteByEmail`
mails through the register (`send-space-invitation`), naming who invites by their
handle (refused without one) and nothing of what is shared, and keeps
`shared_space_email_invitation` (the address's keyed hash, the SHA-256 of the
link's token, the role); `acceptEmailInvitation` takes a code asked for
`EmailCodePurpose.joinSpace`, proven through the register (`prove-address-code`),
and refuses one proving another address (`otherAddress`); inviting the address
again replaces the invitation, and the hourly pruning forgets those lapsed. A
space's link (`setJoinLink`, the Owner's; `joinLink`, whoever may invite;
`joinByLink`, as a Viewer) keeps its token as it is in `shared_space.joinToken`,
unlike a mailed one, so that it can be copied again: the instance decides who is
a member anyway, and switching the link off and on makes a new token, ending the
old one. `preview` and the page at `/join/<token>` (`addJoinPageRoute`, English and
German, `noindex`, `no-store`) say who invites and as whom, alike 404 for a token
that never was, lapsed or was switched off. A membership someone else made is new
to its member (`shared_space_member.isNew`, `addedBy`; `mine` names who shared it,
`markSeen` clears it); joining by a link or a mailed invitation is not.
6. **Agents** (D6, D7, D10, D12).
## Open questions, before building