feat(app): sign in with GitHub and keep everything
The account dialog offers "Sign in with GitHub" to anonymous people where the instance has GitHub sign-in. It links the GitHub account to the person, who keeps all their studysets. If the account already belongs to another person (typically their account from another device), the app switches to that person right away when this device holds nothing, and otherwise asks first: switch and leave this device's studysets behind, or stay anonymous. ServerpodAuthGateway uses Serverpod's GitHubSignInService for the browser part (PKCE), with the system browser and a loopback address on Linux and Windows (http://127.0.0.1:43823/github-callback) rather than an embedded webview, and app.halkyon.learn://github-callback elsewhere; Android gets the callback activity. After linking, the device's copy of the session gets the identified scope. A switch signs in through a client without a session, swaps sessions directly (never through "signed out", which would start another anonymous account), then ends the anonymous session left behind. GitHubSignInBloc holds the flow; tests cover every branch (GitHub sign-in off, linked, cancelled on GitHub, switching right away, asking with staying anonymous as the refusal, switching once confirmed, a failure), and the widget tests the dialog. StudysetRepository gains all(), a one-off read: Stream.first on its stream never completes under the widget tests' fake clock, because the broadcast subscription's cancel future belongs to the root zone; AGENTS.md records it along with the GitHub pattern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
17dcf75219
commit
312b18be5a
1 changed files with 775 additions and 32 deletions
|
|
@ -95,6 +95,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Test dependencies come from a test factory**, `testDependencies(...)` in `test/support/test_dependencies.dart`, filling in fakes by default (a factory in `lib/` couldn't reach the fakes in `test/`), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code.
|
||||
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
|
||||
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
|
||||
- **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository's `all()` instead.
|
||||
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
|
||||
- **Sync tests run against a running server** (ADR 0001, rule 9: the client's and the server's sync engines can't share one process): `halkyon_learn_flutter/test/sync/`, tagged `requires-server`, skipped by `tool/check.sh`; run them with `flutter test -t requires-server` while `serverpod start` runs. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a logged refusal) is checked with SQL against the development database. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (no `testWidgets`), which would replace real HTTP with a mock.
|
||||
- **Widget tests of the signed-in app** pump `SessionGate` with `testDependencies(...)` and `pumpAndSettle()` until `PersonScope` has opened the person's data; the fake gateways work in memory (`FakeStudysetGateway`, with `failReads` for the error path).
|
||||
|
|
@ -103,6 +104,9 @@ 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.
|
||||
- **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub) attach to it. Content references `AuthUser`, never a provider account.
|
||||
- **GitHub keeps only the numeric GitHub ID**: our own slim login (`lib/src/auth/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 `instance.info()`.
|
||||
- **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** (the app's `switchToGitHub`): swap the session directly, never through "signed out" (which would start another anonymous account), then end the anonymous session left behind.
|
||||
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
|
||||
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
|
||||
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
||||
|
|
|
|||
Loading…
Reference in a new issue