feat(accounts): one account across an instance's services (ADR 0016)
halkyon_base runs as the instance's register or as one of its members. The register keeps the ways in, handles and profiles; members ask it over HTTP with the instance's shared secret (instanceSecret), and open sessions of their own for its accounts, under the same account ID. - AccountsMode from the environment: INSTANCE_MEMBERS on the register, ACCOUNT_REGISTER_URL on a member. Learn always holds the register (Explore joins the profiles); Todo is a member where it is set. - AccountRegister: LocalRegister, RemoteRegister, an in-memory one in tests. Calls between services on the web server, refused without the secret (constant-time compare); a server needing it doesn't start without it. - Deleting an account deletes it everywhere: notices per member kept in the deletion's transaction (account_deletion_notice), told at once and hourly until confirmed; members prune daily whom the register no longer knows, never while it knows none of them or can't be reached. - Deploy: INSTANCE_SECRET and INSTANCE_MEMBERS in compose and .env. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4107cc3087
commit
65c5cd740d
78 changed files with 41906 additions and 167 deletions
|
|
@ -9,7 +9,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
|
||||||
- **`apps/<app>/`**: each app's packages, as `serverpod create` lays them out (`halkyon_<app>_server`, `_client`, `_flutter`), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in `apps/learn/tool/`, run from `apps/learn`). Learn is the app that runs; Todo is still Serverpod's scaffold; Train and Meditate are to come.
|
- **`apps/<app>/`**: each app's packages, as `serverpod create` lays them out (`halkyon_<app>_server`, `_client`, `_flutter`), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in `apps/learn/tool/`, run from `apps/learn`). Learn is the app that runs; Todo is still Serverpod's scaffold; Train and Meditate are to come.
|
||||||
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
|
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
|
||||||
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of, with texts of its own (English, German).
|
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of, with texts of its own (English, German).
|
||||||
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (anonymous, GitHub, email codes), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics.
|
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (anonymous, GitHub, email codes), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
|
||||||
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, with texts that name the app.
|
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, with texts that name the app.
|
||||||
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
|
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
|
||||||
- **`deploy/`**: the instance deploy at the root, learn.halkyon.eu's today (Learn's `AGENTS.md`, *CI and deploys*), where other services' compose files are to join Learn's. **`snap/`**: Learn's snap, built from the root, which its build needs whole. **`docs/adr/`**: the decision records of every app and package, one numbering. **`tool/`**: the repository's tools (below).
|
- **`deploy/`**: the instance deploy at the root, learn.halkyon.eu's today (Learn's `AGENTS.md`, *CI and deploys*), where other services' compose files are to join Learn's. **`snap/`**: Learn's snap, built from the root, which its build needs whole. **`docs/adr/`**: the decision records of every app and package, one numbering. **`tool/`**: the repository's tools (below).
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ What holds for every app and package of this repository (how work is done here,
|
||||||
|
|
||||||
A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS schedule. Content is rich text (`appflowy_editor` documents) everywhere. Targets: Linux, Windows, macOS, Android, iOS. There is no web build.
|
A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS schedule. Content is rich text (`appflowy_editor` documents) everywhere. Targets: Linux, Windows, macOS, Android, iOS. There is no web build.
|
||||||
|
|
||||||
- **Packages** (`apps/learn/`): `halkyon_learn_server` (endpoints, models, migrations), `halkyon_learn_client` (generated, never edited), `halkyon_learn_flutter` (the app, ID `app.halkyon.learn`), `halkyon_learn_curator` (the dictionaries' converter, below), and `tool/` with the scripts that write the app's assets (maps, icons, Anki fixtures; run them from `apps/learn`). What every Halkyon server has comes from the Serverpod module `halkyon_base` (ADR 0014, decided 2026-10-19; `packages/halkyon_base_server` and `packages/halkyon_base_client`, which Learn's packages take by path): signing in, the account's ways in and its deletion, the pruning, and the instance's basics. The server sets it up for Learn before it starts (`HalkyonBase.configure(pod, learnBase())` in `server.dart`; `lib/src/auth/learn_base.dart`: the app it serves, `learn`, which its instances name in their basics and the app checks (never change it), the app's name in sign-in mails, this project's source, `minimumAppBuild`, Learn's own deletion as a hook), its endpoints are `client.modules.base` in the app, its tables (`email_identity`, `email_code_request`) are the module's, and tests that delete people or ask `instance.info()` set it up as the server does (`useLearnBase(sessionBuilder)`). What every Halkyon app's shell has comes from `halkyon_app` on the module's client (ADR 0014, `packages/halkyon_app`; with `halkyon_core`'s `SerialQueue` and `CoalescingRunner`): the instances and the first start, signing in and switching person, the gates (`InstanceGate`, `SessionGate`, `PersonScope`, `AppScope`), the sync service and where it stands, with texts of its own (`HalkyonAppLocalizations`, in `appLocalizationsDelegates`) that name the app from `learnFrame`. Change what they hold there, not here.
|
- **Packages** (`apps/learn/`): `halkyon_learn_server` (endpoints, models, migrations), `halkyon_learn_client` (generated, never edited), `halkyon_learn_flutter` (the app, ID `app.halkyon.learn`), `halkyon_learn_curator` (the dictionaries' converter, below), and `tool/` with the scripts that write the app's assets (maps, icons, Anki fixtures; run them from `apps/learn`). What every Halkyon server has comes from the Serverpod module `halkyon_base` (ADR 0014, decided 2026-10-19; `packages/halkyon_base_server` and `packages/halkyon_base_client`, which Learn's packages take by path): signing in, the account's ways in and its deletion, the pruning, and the instance's basics. The server sets it up for Learn before it starts (`HalkyonBase.configure(pod, learnBase(accounts: learnAccounts()))` in `server.dart`; `lib/src/auth/learn_base.dart`: Learn holds the instance's register of accounts (ADR 0016), `learnAccounts` reading the members from `INSTANCE_MEMBERS` and refusing to start with `ACCOUNT_REGISTER_URL`; the app it serves, `learn`, which its instances name in their basics and the app checks (never change it), the app's name in sign-in mails, this project's source, `minimumAppBuild`, Learn's own deletion as a hook), its endpoints are `client.modules.base` in the app, its tables (`email_identity`, `email_code_request`) are the module's, and tests that delete people or ask `instance.info()` set it up as the server does (`useLearnBase(sessionBuilder)`). What every Halkyon app's shell has comes from `halkyon_app` on the module's client (ADR 0014, `packages/halkyon_app`; with `halkyon_core`'s `SerialQueue` and `CoalescingRunner`): the instances and the first start, signing in and switching person, the gates (`InstanceGate`, `SessionGate`, `PersonScope`, `AppScope`), the sync service and where it stands, with texts of its own (`HalkyonAppLocalizations`, in `appLocalizationsDelegates`) that name the app from `learnFrame`. Change what they hold there, not here.
|
||||||
- **Instances**: anyone may run one. The app takes the server URL from `--dart-define=SERVER_URL=…`, so never hard-code a host.
|
- **Instances**: anyone may run one. The app takes the server URL from `--dart-define=SERVER_URL=…`, so never hard-code a host.
|
||||||
|
|
||||||
## Working on Learn
|
## Working on Learn
|
||||||
|
|
@ -74,6 +74,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
|
|
||||||
- **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too.
|
- **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too.
|
||||||
- **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub, an email address) attach to it. Content references `AuthUser`, never a provider account. A person may sign in with GitHub and with an address, one of each (`account.signInWays`); the Profile tab lists their ways in and adds the missing one.
|
- **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub, an email address) attach to it. Content references `AuthUser`, never a provider account. A person may sign in with GitHub and with an address, one of each (`account.signInWays`); the Profile tab lists their ways in and adds the missing one.
|
||||||
|
- **One account across the instance** (ADR 0016, decided 2026-10-19; halkyon_base's README): Learn's server holds the instance's register, so its tables keep every service's ways in, handles and profiles, and an account's ID is the `AuthUser` ID in every service. The instance's other services (Todo) are its members: they ask it over its web server (`/halkyon-base/register/…`, `addInstanceRoutes` in `server.dart`, never forwarded by Caddy) with the secret the services share (`instanceSecret`, `INSTANCE_SECRET` in `deploy/.env`), and a member's anonymous person adding a way in becomes an account here under the same ID. Deleting an account (here, or a member passing it on) keeps a notice per member in the same transaction (`account_deletion_notice`), told at once and hourly until each confirms (`tellMembersOfDeletions`). Learn's endpoints and code go through the module as on any register; nothing changes for Learn's apps.
|
||||||
- **GitHub keeps only the numeric GitHub ID**: our own slim login (halkyon_base's `github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks halkyon_base's `instance.basics()`.
|
- **GitHub keeps only the numeric GitHub ID**: our own slim login (halkyon_base's `github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks halkyon_base's `instance.basics()`.
|
||||||
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
|
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
|
||||||
- **Switching devices' person** (halkyon_app's `switchToGitHub`, `switchToEmail`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so.
|
- **Switching devices' person** (halkyon_app's `switchToGitHub`, `switchToEmail`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so.
|
||||||
|
|
@ -112,8 +113,8 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Archives** (decided 2026-10-11; `feature/archive/`): a studyset with everything in it and its progress, as one `.zip`, imported from the Library's Import as the person's own (backups, moves between instances, a library brought from elsewhere). `archive.json` says the format (`{"format": 1}`; a newer one is refused whole); the studyset is laid out as a release (studyset.json, cards/, notes/, paths/, pdfs/, a directory per studyset inside, files.json and files/<sha256>), so the release copier writes it (`ReleaseCopier.write`: unlinked, no `published`, its cards marked `archive` as imported ones are, returning each item's new id). A card may name a dictionary entry in place of its payload (`"entry": {"source", "language", "id"}`): the import makes it from the instance's dictionary as adding from it does (`DictionaryEndpoint.entries`, at most 1,000 ids at once), with the tags that brings; an entry the instance lacks leaves the card out, counted. `reviews.json` lists reviews of its cards by their ids in it (`card`, `rating` 1–4, `reviewedAt`, optional `durationMs` and `mode`), appended to the person's review log once the cards are in, so schedules go on from them. Reading runs on an isolate of its own (`ArchiveReader`; tests read inline). **Exporting** (decided 2026-10-15) is a studyset's Export content, a sheet offering Archive first, with the person's progress or without (a checkbox, on), then Markdown: `ArchiveExporter` writes it as the instance writes a release (keys sorted, indented, a directory per studyset by its id) but unlinked (`published` left out), dictionary cards whole, and refuses (`ArchiveFilesMissing`) when a file its content names can't be had now rather than writing one that looks whole; with the progress also its reviews and what a release leaves out as only its author's (the exam, a card's report, a PDF's deadline). v0.17.0's import refuses a file it doesn't know, so what it doesn't read rides in the files it does, as keys it skips, and the format stays 1: `archive.json`'s `tags` (colours, Jellyfin's marked), a `studyset.json`'s `archived` and `starred`, a step's `answers`, a PDF's `readings` (`ImportArchive` lists them); stars, the archive and tag colours come along without the progress too. `test/feature/archive/archive_export_test.dart` exports a library and imports it as someone else (whose fakes have made items first, so no id matches by chance), and holds the archive to what v0.17.0 reads.
|
- **Archives** (decided 2026-10-11; `feature/archive/`): a studyset with everything in it and its progress, as one `.zip`, imported from the Library's Import as the person's own (backups, moves between instances, a library brought from elsewhere). `archive.json` says the format (`{"format": 1}`; a newer one is refused whole); the studyset is laid out as a release (studyset.json, cards/, notes/, paths/, pdfs/, a directory per studyset inside, files.json and files/<sha256>), so the release copier writes it (`ReleaseCopier.write`: unlinked, no `published`, its cards marked `archive` as imported ones are, returning each item's new id). A card may name a dictionary entry in place of its payload (`"entry": {"source", "language", "id"}`): the import makes it from the instance's dictionary as adding from it does (`DictionaryEndpoint.entries`, at most 1,000 ids at once), with the tags that brings; an entry the instance lacks leaves the card out, counted. `reviews.json` lists reviews of its cards by their ids in it (`card`, `rating` 1–4, `reviewedAt`, optional `durationMs` and `mode`), appended to the person's review log once the cards are in, so schedules go on from them. Reading runs on an isolate of its own (`ArchiveReader`; tests read inline). **Exporting** (decided 2026-10-15) is a studyset's Export content, a sheet offering Archive first, with the person's progress or without (a checkbox, on), then Markdown: `ArchiveExporter` writes it as the instance writes a release (keys sorted, indented, a directory per studyset by its id) but unlinked (`published` left out), dictionary cards whole, and refuses (`ArchiveFilesMissing`) when a file its content names can't be had now rather than writing one that looks whole; with the progress also its reviews and what a release leaves out as only its author's (the exam, a card's report, a PDF's deadline). v0.17.0's import refuses a file it doesn't know, so what it doesn't read rides in the files it does, as keys it skips, and the format stays 1: `archive.json`'s `tags` (colours, Jellyfin's marked), a `studyset.json`'s `archived` and `starred`, a step's `answers`, a PDF's `readings` (`ImportArchive` lists them); stars, the archive and tag colours come along without the progress too. `test/feature/archive/archive_export_test.dart` exports a library and imports it as someone else (whose fakes have made items first, so no id matches by chance), and holds the archive to what v0.17.0 reads.
|
||||||
- **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash.
|
- **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash.
|
||||||
- **Discussions** (ADR 0013, decided 2026-10-15): questions on a published studyset, a topic in it or one of its cards, notes or steps, answered by anyone and commented on (under the question and each answer, never under a comment), as Stack Exchange has them, without votes: the asker accepts an answer, which comes first, the rest oldest first. Unlike feedback they are server tables (`discussion_post`, `discussion_seen`, `lib/src/discussion/`), written through endpoints, online only, so posts can be edited and ordered and the synced tables stay as they are: `DiscussionsEndpoint` (`requireLogin`) lists (by topic, item or title, 30 to a page, last active first) and reads; `DiscussionEndpoint` (`identified`, and a public profile) asks, answers, comments, edits, deletes, accepts, hides (the studyset's author), and keeps the inbox. A question names its target as the release does, and the instance keeps the topics it is within from git's file list (`ReleaseRepository.paths`, `topicsOf`), so a topic lists what is asked within it and below; one about the studyset as a whole names no item. Questions and answers are rich text without files (the composer's Insert menu has no media, and content holding images or sounds is refused: the person's files are theirs alone), comments plain text of up to 600 characters, 30 posts an hour. The author hides posts and blocks people with feedback's own blocks; a withdrawn studyset takes no new questions. A post with others' replies under it is never deleted, only emptied ("Deleted question"), with its writer's account too (`deleteDiscussionsOf` in `deletePerson`). In the app (`feature/discussion/`): a copy's menu opens the discussions within that studyset or topic (`RoutePaths.discussions`, over the tabs), a studied card from a release offers Ask about this, Explore's summary and the Publish sheet open the whole studyset's, and the Profile tab's inbox lists the discussions the person follows, counted on the tab with the feedback (`InboxCountBloc`).
|
- **Discussions** (ADR 0013, decided 2026-10-15): questions on a published studyset, a topic in it or one of its cards, notes or steps, answered by anyone and commented on (under the question and each answer, never under a comment), as Stack Exchange has them, without votes: the asker accepts an answer, which comes first, the rest oldest first. Unlike feedback they are server tables (`discussion_post`, `discussion_seen`, `lib/src/discussion/`), written through endpoints, online only, so posts can be edited and ordered and the synced tables stay as they are: `DiscussionsEndpoint` (`requireLogin`) lists (by topic, item or title, 30 to a page, last active first) and reads; `DiscussionEndpoint` (`identified`, and a public profile) asks, answers, comments, edits, deletes, accepts, hides (the studyset's author), and keeps the inbox. A question names its target as the release does, and the instance keeps the topics it is within from git's file list (`ReleaseRepository.paths`, `topicsOf`), so a topic lists what is asked within it and below; one about the studyset as a whole names no item. Questions and answers are rich text without files (the composer's Insert menu has no media, and content holding images or sounds is refused: the person's files are theirs alone), comments plain text of up to 600 characters, 30 posts an hour. The author hides posts and blocks people with feedback's own blocks; a withdrawn studyset takes no new questions. A post with others' replies under it is never deleted, only emptied ("Deleted question"), with its writer's account too (`deleteDiscussionsOf` in `deletePerson`). In the app (`feature/discussion/`): a copy's menu opens the discussions within that studyset or topic (`RoutePaths.discussions`, over the tabs), a studied card from a release offers Ask about this, Explore's summary and the Publish sheet open the whole studyset's, and the Profile tab's inbox lists the discussions the person follows, counted on the tab with the feedback (`InboxCountBloc`).
|
||||||
- **Deleting a person** goes through halkyon_base's `deletePerson`, in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then Learn's own data of theirs (`deleteLearnData`, `lib/src/auth/account.dart`, the hook Learn configures: their space with all its content through the foreign keys and its stored files, their profile, discussions, published studysets, blocks, what they read, the assistants they connected, their file allowance; their releases' repositories once the deletion holds), then the person with their identities. What they wrote in discussions goes too, emptied where others replied under it (ADR 0013). A new table holding a person's data gets a line in that hook. The module's `account.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
|
- **Deleting a person** goes through halkyon_base's `deletePerson`, in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then Learn's own data of theirs (`deleteLearnData`, `lib/src/auth/account.dart`, the hook Learn configures: their space with all its content through the foreign keys and its stored files, discussions, published studysets, blocks, what they read, the assistants they connected, their file allowance; their releases' repositories once the deletion holds), then the module's (their public profile with its avatar), then the person with their identities; an identified person's deletion also tells the instance's members (ADR 0016), the notices kept in the same transaction. What they wrote in discussions goes too, emptied where others replied under it (ADR 0013). A new table holding a person's data gets a line in that hook. The module's `account.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
|
||||||
- **Pruning**: `PruningFutureCall` (Learn's, calling halkyon_base's functions; people go through its `deletePerson`, so Learn's data of theirs too) deletes anonymous people unused for 90 days, daily, scheduled when the server starts; and hourly the notes Serverpod's rate limit makes of anonymous sign-ups (`serverpod_auth_idp_rate_limited_request_attempt`, each with the IP address it came from) once the limit's hour is past (`forgetOldSignUpAttempts`; email sign-in's notes too, and its codes past their time, `forgetExpiredEmailCodes`): Serverpod deletes them only for its own email sign-in, which this instance doesn't use, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
|
- **Pruning**: `PruningFutureCall` (Learn's, calling halkyon_base's functions; people go through its `deletePerson`, so Learn's data of theirs too) deletes anonymous people unused for 90 days, daily, scheduled when the server starts; and hourly the notes Serverpod's rate limit makes of anonymous sign-ups (`serverpod_auth_idp_rate_limited_request_attempt`, each with the IP address it came from) once the limit's hour is past (`forgetOldSignUpAttempts`; email sign-in's notes too, and its codes past their time, `forgetExpiredEmailCodes`): Serverpod deletes them only for its own email sign-in, which this instance doesn't use, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that; hourly too, the deletions the instance's members haven't confirmed yet (`tellMembersOfDeletions`, ADR 0016). Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
|
||||||
- **Coming back with a field focused brings its keyboard back** (`KeyboardBackOnResume` around the app): Flutter (v3.47.5) gives the window back the keyboard's height after the app was left while the keyboard moved, with no keyboard showing, which left screens pushed up ([flutter#191156](https://github.com/flutter/flutter/issues/191156), open). Remove it once that is fixed.
|
- **Coming back with a field focused brings its keyboard back** (`KeyboardBackOnResume` around the app): Flutter (v3.47.5) gives the window back the keyboard's height after the app was left while the keyboard moved, with no keyboard showing, which left screens pushed up ([flutter#191156](https://github.com/flutter/flutter/issues/191156), open). Remove it once that is fixed.
|
||||||
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.
|
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.
|
||||||
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.
|
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,92 @@
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigration",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigrationAction",
|
||||||
|
"type": "createTable",
|
||||||
|
"createTable": {
|
||||||
|
"__className__": "serverpod.TableDefinition",
|
||||||
|
"name": "account_deletion_notice",
|
||||||
|
"dartName": "AccountDeletionNotice",
|
||||||
|
"module": "halkyon_base",
|
||||||
|
"schema": "public",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "id",
|
||||||
|
"fieldName": "id",
|
||||||
|
"columnType": 6,
|
||||||
|
"isNullable": false,
|
||||||
|
"columnDefault": "serial",
|
||||||
|
"dartType": "int?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "account",
|
||||||
|
"fieldName": "account",
|
||||||
|
"columnType": 7,
|
||||||
|
"isNullable": false,
|
||||||
|
"dartType": "UuidValue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "member",
|
||||||
|
"fieldName": "member",
|
||||||
|
"columnType": 0,
|
||||||
|
"isNullable": false,
|
||||||
|
"dartType": "String"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "createdAt",
|
||||||
|
"fieldName": "createdAt",
|
||||||
|
"columnType": 4,
|
||||||
|
"isNullable": false,
|
||||||
|
"dartType": "DateTime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "attempts",
|
||||||
|
"fieldName": "attempts",
|
||||||
|
"columnType": 6,
|
||||||
|
"isNullable": false,
|
||||||
|
"dartType": "int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "lastAttemptAt",
|
||||||
|
"fieldName": "lastAttemptAt",
|
||||||
|
"columnType": 4,
|
||||||
|
"isNullable": true,
|
||||||
|
"dartType": "DateTime?"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [],
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexDefinition",
|
||||||
|
"indexName": "account_deletion_notice_idx",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexElementDefinition",
|
||||||
|
"type": 0,
|
||||||
|
"definition": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexElementDefinition",
|
||||||
|
"type": 0,
|
||||||
|
"definition": "member"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "btree",
|
||||||
|
"isUnique": true,
|
||||||
|
"isPrimary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"managed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warnings": [],
|
||||||
|
"migrationApiVersion": 1
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ part '20261009224606274-feedback/migration.dart';
|
||||||
part '20261012162411835-studyset-deleted-at/migration.dart';
|
part '20261012162411835-studyset-deleted-at/migration.dart';
|
||||||
part '20261019121406578-halkyon-base/migration.dart';
|
part '20261019121406578-halkyon-base/migration.dart';
|
||||||
part '20261019201023295-profiles-in-halkyon-base/migration.dart';
|
part '20261019201023295-profiles-in-halkyon-base/migration.dart';
|
||||||
|
part '20261019204112282-deletion-notices/migration.dart';
|
||||||
|
|
||||||
/// Migration registry for the client-side database.
|
/// Migration registry for the client-side database.
|
||||||
class MigrationRegistry {
|
class MigrationRegistry {
|
||||||
|
|
@ -36,6 +37,7 @@ class MigrationRegistry {
|
||||||
_Migration20261012162411835_studyset_deleted_at(),
|
_Migration20261012162411835_studyset_deleted_at(),
|
||||||
_Migration20261019121406578_halkyon_base(),
|
_Migration20261019121406578_halkyon_base(),
|
||||||
_Migration20261019201023295_profiles_in_halkyon_base(),
|
_Migration20261019201023295_profiles_in_halkyon_base(),
|
||||||
|
_Migration20261019204112282_deletion_notices(),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// List of all client-side database migration versions.
|
/// List of all client-side database migration versions.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ Future<void> run(List<String> args) async {
|
||||||
final pod = Serverpod(args);
|
final pod = Serverpod(args);
|
||||||
// What every Halkyon server has (ADR 0014): sign-in, the account and its
|
// What every Halkyon server has (ADR 0014): sign-in, the account and its
|
||||||
// deletion, the pruning, the instance's basics, set up for Learn.
|
// deletion, the pruning, the instance's basics, set up for Learn.
|
||||||
HalkyonBase.configure(pod, learnBase());
|
// Learn holds the instance's register of accounts (ADR 0016), telling the
|
||||||
|
// instance's other services when one goes.
|
||||||
|
HalkyonBase.configure(pod, learnBase(accounts: learnAccounts()));
|
||||||
pod
|
pod
|
||||||
// Token managers validate and issue authentication keys; identity
|
// Token managers validate and issue authentication keys; identity
|
||||||
// providers are the ways people can sign in (halkyon_base's
|
// providers are the ways people can sign in (halkyon_base's
|
||||||
|
|
@ -30,6 +32,9 @@ Future<void> run(List<String> args) async {
|
||||||
..addCloudStorage(DatabaseCloudStorage('private'))
|
..addCloudStorage(DatabaseCloudStorage('private'))
|
||||||
..addCloudStorage(DatabaseCloudStorage('public'));
|
..addCloudStorage(DatabaseCloudStorage('public'));
|
||||||
|
|
||||||
|
// The calls of the instance's other services, on the web server
|
||||||
|
// (ADR 0016); Caddy never forwards them.
|
||||||
|
addInstanceRoutes(pod);
|
||||||
// The assistant connector, on the web server (ADR 0006).
|
// The assistant connector, on the web server (ADR 0006).
|
||||||
addConnectorRoutes(pod);
|
addConnectorRoutes(pod);
|
||||||
// Profile pages, `/@handle`, which profile links link back to (ADR 0011),
|
// Profile pages, `/@handle`, which profile links link back to (ADR 0011),
|
||||||
|
|
@ -63,6 +68,15 @@ Future<void> run(List<String> args) async {
|
||||||
.pruning
|
.pruning
|
||||||
.forgetSignUpAttempts();
|
.forgetSignUpAttempts();
|
||||||
|
|
||||||
|
// The deletions the instance's members haven't confirmed yet, hourly
|
||||||
|
// (ADR 0016).
|
||||||
|
await pod.futureCalls.cancel(_deletionNotices);
|
||||||
|
await pod.futureCalls
|
||||||
|
.callRecurring(identifier: _deletionNotices)
|
||||||
|
.cron('52 * * * *')
|
||||||
|
.pruning
|
||||||
|
.tellMembersOfDeletions();
|
||||||
|
|
||||||
// The assistant connector's expired codes and tokens (ADR 0006, rule 5).
|
// The assistant connector's expired codes and tokens (ADR 0006, rule 5).
|
||||||
await pod.futureCalls.cancel(_connectorPruning);
|
await pod.futureCalls.cancel(_connectorPruning);
|
||||||
await pod.futureCalls
|
await pod.futureCalls
|
||||||
|
|
@ -121,6 +135,9 @@ const _pruning = 'prune-abandoned-anonymous-people';
|
||||||
/// The identifier of the hourly forgetting of sign-up attempts.
|
/// The identifier of the hourly forgetting of sign-up attempts.
|
||||||
const _attemptPruning = 'forget-sign-up-attempts';
|
const _attemptPruning = 'forget-sign-up-attempts';
|
||||||
|
|
||||||
|
/// The identifier of the hourly telling of the members of deleted accounts.
|
||||||
|
const _deletionNotices = 'tell-members-of-deletions';
|
||||||
|
|
||||||
/// The identifier of the daily forgetting of the connector's expired secrets.
|
/// The identifier of the daily forgetting of the connector's expired secrets.
|
||||||
const _connectorPruning = 'forget-connector-secrets';
|
const _connectorPruning = 'forget-connector-secrets';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,12 @@ const upstreamSourceCodeUrl =
|
||||||
/// serves `learn` (the app accepts only such an instance; never change it),
|
/// serves `learn` (the app accepts only such an instance; never change it),
|
||||||
/// sign-in mails name the app, the instance names this project's source,
|
/// sign-in mails name the app, the instance names this project's source,
|
||||||
/// works with apps from [minimumAppBuild] on, deletes Learn's own data of a
|
/// works with apps from [minimumAppBuild] on, deletes Learn's own data of a
|
||||||
/// person who goes ([deleteLearnData]), and profile pages name the app at
|
/// person who goes ([deleteLearnData]), profile pages name the app at their
|
||||||
/// their foot. The server sets it up before it starts (`server.dart`), and so
|
/// foot, and it holds the instance's register with [accounts]' members
|
||||||
/// do tests that delete people or read profiles.
|
/// ([learnAccounts]). The server sets it up before it starts (`server.dart`),
|
||||||
HalkyonBase learnBase() => HalkyonBase(
|
/// and so do tests that delete people or read profiles.
|
||||||
|
HalkyonBase learnBase({HoldsRegister accounts = const HoldsRegister()}) =>
|
||||||
|
HalkyonBase(
|
||||||
app: 'learn',
|
app: 'learn',
|
||||||
appName: 'Halkyon Learn',
|
appName: 'Halkyon Learn',
|
||||||
upstreamSourceCodeUrl: upstreamSourceCodeUrl,
|
upstreamSourceCodeUrl: upstreamSourceCodeUrl,
|
||||||
|
|
@ -26,7 +28,25 @@ HalkyonBase learnBase() => HalkyonBase(
|
||||||
deletePersonData: const [deleteLearnData],
|
deletePersonData: const [deleteLearnData],
|
||||||
profilePageNote: (
|
profilePageNote: (
|
||||||
english: 'On Halkyon Learn, a spaced-repetition learning app:',
|
english: 'On Halkyon Learn, a spaced-repetition learning app:',
|
||||||
german: 'Auf Halkyon Learn, einer Lern-App mit verteilter Wiederholung:',
|
german:
|
||||||
|
'Auf Halkyon Learn, einer Lern-App mit verteilter Wiederholung:',
|
||||||
link: Uri.parse('https://halkyon.app/learn'),
|
link: Uri.parse('https://halkyon.app/learn'),
|
||||||
),
|
),
|
||||||
|
accounts: accounts,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Learn's part in the instance's accounts (ADR 0016), from the operator's
|
||||||
|
/// [environment]: it holds the register, telling the members
|
||||||
|
/// `INSTANCE_MEMBERS` names when an account goes. Never a member: Explore
|
||||||
|
/// searches studysets by their authors' handles in the same query as the
|
||||||
|
/// releases (`explore_endpoint.dart`), so the profiles must be in Learn's
|
||||||
|
/// own database. An `ACCOUNT_REGISTER_URL` stops the server as it starts.
|
||||||
|
HoldsRegister learnAccounts([Map<String, String>? environment]) =>
|
||||||
|
switch (AccountsMode.fromEnvironment(environment)) {
|
||||||
|
final HoldsRegister accounts => accounts,
|
||||||
|
MemberOfRegister() => throw StateError(
|
||||||
|
"Halkyon Learn holds its instance's register (ADR 0016) and is "
|
||||||
|
"never a member of another's: unset ACCOUNT_REGISTER_URL, and name "
|
||||||
|
'the other services in INSTANCE_MEMBERS.',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import 'package:serverpod/serverpod.dart';
|
||||||
|
|
||||||
/// Deletes the anonymous people nobody can return to, daily, the IP
|
/// Deletes the anonymous people nobody can return to, daily, the IP
|
||||||
/// addresses of sign-up attempts the rate limit is done with and the email
|
/// addresses of sign-up attempts the rate limit is done with and the email
|
||||||
/// sign-in codes past their time, hourly, and
|
/// sign-in codes past their time, hourly, the deletions the instance's
|
||||||
/// the assistant connector's expired secrets, daily; the server schedules
|
/// members haven't confirmed, hourly, and the assistant connector's expired
|
||||||
/// them when it starts (`server.dart`). All but the last are halkyon_base's
|
/// secrets, daily; the server schedules them when it starts (`server.dart`).
|
||||||
/// (ADR 0014), and people go through its `deletePerson`, so Learn's own data
|
/// All but the last are halkyon_base's (ADR 0014, ADR 0016), and people go
|
||||||
/// of theirs goes too.
|
/// through its `deletePerson`, so Learn's own data of theirs goes too.
|
||||||
class PruningFutureCall extends FutureCall {
|
class PruningFutureCall extends FutureCall {
|
||||||
/// Prunes one batch and logs how many people went.
|
/// Prunes one batch and logs how many people went.
|
||||||
Future<void> pruneAnonymousPeople(Session session) async {
|
Future<void> pruneAnonymousPeople(Session session) async {
|
||||||
|
|
@ -39,4 +39,11 @@ class PruningFutureCall extends FutureCall {
|
||||||
final codes = await forgetExpiredEmailCodes(session, now: now);
|
final codes = await forgetExpiredEmailCodes(session, now: now);
|
||||||
session.log('Forgot $forgotten sign-up attempts and $codes email codes.');
|
session.log('Forgot $forgotten sign-up attempts and $codes email codes.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tells the instance's members again of the accounts gone that they
|
||||||
|
/// haven't confirmed yet (ADR 0016), and logs how many confirmed.
|
||||||
|
Future<void> tellMembersOfDeletions(Session session) async {
|
||||||
|
final confirmed = await deliverDeletionNotices(session);
|
||||||
|
session.log('Members confirmed $confirmed deleted accounts.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,8 @@ class FutureCalls extends _is.FutureCallDispatch<_FutureCallRef> {
|
||||||
PruningForgetConnectorSecretsFutureCall(),
|
PruningForgetConnectorSecretsFutureCall(),
|
||||||
'PruningForgetSignUpAttemptsFutureCall':
|
'PruningForgetSignUpAttemptsFutureCall':
|
||||||
PruningForgetSignUpAttemptsFutureCall(),
|
PruningForgetSignUpAttemptsFutureCall(),
|
||||||
|
'PruningTellMembersOfDeletionsFutureCall':
|
||||||
|
PruningTellMembersOfDeletionsFutureCall(),
|
||||||
'DictionaryReloadReloadDictionariesFutureCall':
|
'DictionaryReloadReloadDictionariesFutureCall':
|
||||||
DictionaryReloadReloadDictionariesFutureCall(),
|
DictionaryReloadReloadDictionariesFutureCall(),
|
||||||
'LearnerCountCountLearnersFutureCall':
|
'LearnerCountCountLearnersFutureCall':
|
||||||
|
|
@ -243,6 +245,13 @@ class _PruningFutureCallDispatcher {
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> tellMembersOfDeletions() {
|
||||||
|
return _invokeFutureCall(
|
||||||
|
'PruningTellMembersOfDeletionsFutureCall',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DictionaryReloadFutureCallDispatcher {
|
class _DictionaryReloadFutureCallDispatcher {
|
||||||
|
|
@ -322,6 +331,19 @@ class PruningForgetSignUpAttemptsFutureCall extends _is.FutureCall
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tells the instance's members again of the accounts gone that they
|
||||||
|
/// haven't confirmed yet (ADR 0016), and logs how many confirmed.
|
||||||
|
class PruningTellMembersOfDeletionsFutureCall extends _is.FutureCall
|
||||||
|
implements _is.InvokableFutureCall {
|
||||||
|
@override
|
||||||
|
_ida.Future<void> invoke(
|
||||||
|
_is.Session session,
|
||||||
|
_is.SerializableModel? object,
|
||||||
|
) async {
|
||||||
|
await _igevg04y.PruningFutureCall().tellMembersOfDeletions(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Loads the dictionary files that changed since they were loaded.
|
/// Loads the dictionary files that changed since they were loaded.
|
||||||
class DictionaryReloadReloadDictionariesFutureCall extends _is.FutureCall
|
class DictionaryReloadReloadDictionariesFutureCall extends _is.FutureCall
|
||||||
implements _is.InvokableFutureCall {
|
implements _is.InvokableFutureCall {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,92 @@
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigration",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.DatabaseMigrationAction",
|
||||||
|
"type": "createTable",
|
||||||
|
"createTable": {
|
||||||
|
"__className__": "serverpod.TableDefinition",
|
||||||
|
"name": "account_deletion_notice",
|
||||||
|
"dartName": "AccountDeletionNotice",
|
||||||
|
"module": "halkyon_base",
|
||||||
|
"schema": "public",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "id",
|
||||||
|
"fieldName": "id",
|
||||||
|
"columnType": 6,
|
||||||
|
"isNullable": false,
|
||||||
|
"columnDefault": "serial",
|
||||||
|
"dartType": "int?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "account",
|
||||||
|
"fieldName": "account",
|
||||||
|
"columnType": 7,
|
||||||
|
"isNullable": false,
|
||||||
|
"dartType": "UuidValue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "member",
|
||||||
|
"fieldName": "member",
|
||||||
|
"columnType": 0,
|
||||||
|
"isNullable": false,
|
||||||
|
"dartType": "String"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "createdAt",
|
||||||
|
"fieldName": "createdAt",
|
||||||
|
"columnType": 4,
|
||||||
|
"isNullable": false,
|
||||||
|
"dartType": "DateTime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "attempts",
|
||||||
|
"fieldName": "attempts",
|
||||||
|
"columnType": 6,
|
||||||
|
"isNullable": false,
|
||||||
|
"dartType": "int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.ColumnDefinition",
|
||||||
|
"name": "lastAttemptAt",
|
||||||
|
"fieldName": "lastAttemptAt",
|
||||||
|
"columnType": 4,
|
||||||
|
"isNullable": true,
|
||||||
|
"dartType": "DateTime?"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [],
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexDefinition",
|
||||||
|
"indexName": "account_deletion_notice_idx",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexElementDefinition",
|
||||||
|
"type": 0,
|
||||||
|
"definition": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__className__": "serverpod.IndexElementDefinition",
|
||||||
|
"type": 0,
|
||||||
|
"definition": "member"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "btree",
|
||||||
|
"isUnique": true,
|
||||||
|
"isPrimary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"managed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warnings": [],
|
||||||
|
"migrationApiVersion": 1
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- ACTION CREATE TABLE
|
||||||
|
--
|
||||||
|
CREATE TABLE "account_deletion_notice" (
|
||||||
|
"id" bigserial PRIMARY KEY,
|
||||||
|
"account" uuid NOT NULL,
|
||||||
|
"member" text NOT NULL,
|
||||||
|
"createdAt" timestamp without time zone NOT NULL,
|
||||||
|
"attempts" bigint NOT NULL,
|
||||||
|
"lastAttemptAt" timestamp without time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE UNIQUE INDEX "account_deletion_notice_idx" ON "account_deletion_notice" USING btree ("account", "member");
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- MIGRATION VERSION FOR halkyon_learn
|
||||||
|
--
|
||||||
|
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||||
|
VALUES ('halkyon_learn', '20261019204112282-deletion-notices', now())
|
||||||
|
ON CONFLICT ("module")
|
||||||
|
DO UPDATE SET "version" = '20261019204112282-deletion-notices', "timestamp" = now();
|
||||||
|
|
||||||
|
--
|
||||||
|
-- MIGRATION VERSION FOR halkyon_base
|
||||||
|
--
|
||||||
|
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||||
|
VALUES ('halkyon_base', '20261019202835333-deletion-notices', now())
|
||||||
|
ON CONFLICT ("module")
|
||||||
|
DO UPDATE SET "version" = '20261019202835333-deletion-notices', "timestamp" = now();
|
||||||
|
|
||||||
|
--
|
||||||
|
-- MIGRATION VERSION FOR serverpod
|
||||||
|
--
|
||||||
|
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||||
|
VALUES ('serverpod', '20260824182259319', now())
|
||||||
|
ON CONFLICT ("module")
|
||||||
|
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
|
||||||
|
|
||||||
|
--
|
||||||
|
-- MIGRATION VERSION FOR serverpod_auth_idp
|
||||||
|
--
|
||||||
|
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||||
|
VALUES ('serverpod_auth_idp', '20260924105404509', now())
|
||||||
|
ON CONFLICT ("module")
|
||||||
|
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
|
||||||
|
|
||||||
|
--
|
||||||
|
-- MIGRATION VERSION FOR serverpod_offline_sync
|
||||||
|
--
|
||||||
|
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||||
|
VALUES ('serverpod_offline_sync', '20260914143806119', now())
|
||||||
|
ON CONFLICT ("module")
|
||||||
|
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
|
||||||
|
|
||||||
|
--
|
||||||
|
-- MIGRATION VERSION FOR serverpod_auth_core
|
||||||
|
--
|
||||||
|
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||||
|
VALUES ('serverpod_auth_core', '20260924105232991', now())
|
||||||
|
ON CONFLICT ("module")
|
||||||
|
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
|
||||||
|
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -34,3 +34,4 @@
|
||||||
20261017110130653
|
20261017110130653
|
||||||
20261019121406578-halkyon-base
|
20261019121406578-halkyon-base
|
||||||
20261019201023295-profiles-in-halkyon-base
|
20261019201023295-profiles-in-halkyon-base
|
||||||
|
20261019204112282-deletion-notices
|
||||||
|
|
|
||||||
|
|
@ -2047,6 +2047,21 @@ class _PruningFutureCall {
|
||||||
await _localUniqueSession.close();
|
await _localUniqueSession.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> tellMembersOfDeletions(
|
||||||
|
_ist.TestSessionBuilder sessionBuilder,
|
||||||
|
) async {
|
||||||
|
var _localUniqueSession =
|
||||||
|
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
|
||||||
|
try {
|
||||||
|
await _iip9210p.PruningTellMembersOfDeletionsFutureCall().invoke(
|
||||||
|
_localUniqueSession,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await _localUniqueSession.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DictionaryReloadFutureCall {
|
class _DictionaryReloadFutureCall {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:halkyon_base_server/base.dart';
|
||||||
import 'package:halkyon_learn_server/src/auth/learn_base.dart';
|
import 'package:halkyon_learn_server/src/auth/learn_base.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
|
@ -6,4 +7,36 @@ void main() {
|
||||||
'apps accept', () {
|
'apps accept', () {
|
||||||
expect(learnBase().app, 'learn');
|
expect(learnBase().app, 'learn');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group("Learn's part in the instance's accounts (ADR 0016)", () {
|
||||||
|
test('is the register, telling the members the operator names', () {
|
||||||
|
final accounts = learnAccounts(const {
|
||||||
|
'INSTANCE_MEMBERS': 'http://todo:8082',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(accounts.members, [Uri.parse('http://todo:8082')]);
|
||||||
|
expect(learnBase(accounts: accounts).accounts, same(accounts));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is the register with no members unless the operator names some', () {
|
||||||
|
expect(learnAccounts(const {}).members, isEmpty);
|
||||||
|
expect(learnBase().accounts, isA<HoldsRegister>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is never a member: Explore searches the profiles in its own '
|
||||||
|
'database', () {
|
||||||
|
expect(
|
||||||
|
() => learnAccounts(const {
|
||||||
|
'ACCOUNT_REGISTER_URL': 'http://todo:8082',
|
||||||
|
}),
|
||||||
|
throwsA(
|
||||||
|
isA<StateError>().having(
|
||||||
|
(error) => error.message,
|
||||||
|
'message',
|
||||||
|
contains('ACCOUNT_REGISTER_URL'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Todo needs the same thing.
|
What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Todo needs the same thing.
|
||||||
|
|
||||||
- **A shell on the shared packages** (decided 2026-10-19): signing in, the instances, the account and the look work; nothing of a person's is kept or synced yet, which waits for Todo's first model (the developer decides what it keeps and syncs). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
|
- **A shell on the shared packages** (decided 2026-10-19): signing in, the instances, the account and the look work; nothing of a person's is kept or synced yet, which waits for Todo's first model (the developer decides what it keeps and syncs). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
|
||||||
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, no data of its own to delete with a person yet (add a `deletePersonData` hook with Todo's first table). Authentication through the module's builders: anonymous, GitHub (with the instance's own OAuth app) and email codes, server-side sessions. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts. Session logs are never persisted, in any run mode. One migration so far, the initial one (2026-10-19): Serverpod's tables, its auth modules' and the module's (`email_identity`, `email_code_request`), nothing of Todo's own. Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
|
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, no data of its own to delete with a person yet (add a `deletePersonData` hook with Todo's first table). Authentication through the module's builders: anonymous, GitHub (with the instance's own OAuth app) and email codes, server-side sessions. **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone (GitHub's are Todo's own OAuth app's), and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts: anonymous people daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`) and `deletion-notices` (`account_deletion_notice`), nothing of Todo's own. Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
|
||||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own three sentences) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app with an empty home and the account sheet (whose account on which instance, Switch account, Sign out, which asks first for an anonymous account as it deletes it, Delete account, which asks first, and About with the source of what the instance runs and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data show by their codes until halkyon_app names countries itself. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
|
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own three sentences) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app with an empty home and the account sheet (whose account on which instance, Switch account, Sign out, which asks first for an anonymous account as it deletes it, Delete account, which asks first, and About with the source of what the instance runs and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data show by their codes until halkyon_app names countries itself. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
|
||||||
- **The icon** is Lucide's `list-checks` (lucide-static 1.50.0) in the brand's blues on white: `apps/todo/tool/app_icons.py` (run from `apps/todo`) fetches it, draws its strokes with Pillow (no SVG renderer needed; other path commands fail loudly), writes `assets/icon/halkyon_todo_icon.png` and Lucide's ISC licence beside it, then every platform's launcher icon through flutter_launcher_icons as Learn's tool does. `registerLucideLicense` puts the notice in the licence list.
|
- **The icon** is Lucide's `list-checks` (lucide-static 1.50.0) in the brand's blues on white: `apps/todo/tool/app_icons.py` (run from `apps/todo`) fetches it, draws its strokes with Pillow (no SVG renderer needed; other path commands fail loudly), writes `assets/icon/halkyon_todo_icon.png` and Lucide's ISC licence beside it, then every platform's launcher icon through flutter_launcher_icons as Learn's tool does. `registerLucideLicense` puts the notice in the licence list.
|
||||||
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.
|
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue