api-oauth crate handles start/callback for the authorization code flow
with PKCE, CSRF state carried in a short-lived signed cookie instead of
server-side storage. storage gains auth_provider/linked_identity
lookups against stage 1's existing schema. main.rs merges the oauth
router into the HTTP listener alongside GraphQL, since both are
reached by the browser now.
me resolves the current user from the session cookie (no separate
auth mutation needed, same cookie the OAuth callback already sets).
unlinkIdentity refuses to drop a user to zero identities, since
nothing else can start them a session. CORS is scoped to the same
allowed_redirect_origins stage 3 already validates redirect_to
against, since the front calling GraphQL cross-subdomain is the same
trust boundary.
GET /user/emails needs the App's Email addresses permission — until
that's granted on GitHub's side this just silently finds nothing,
same as any other best-effort profile fetch failing. Also fixes two
account.rs integration tests colliding on fixed usernames across
repeated runs against the same test database.
GitHub's /user/emails returns the full list; upsert_email now takes
verified/primary per row instead of assuming exactly one email, so a
secondary address survives alongside the primary instead of only ever
tracking one.
addEmail sends a link instead of adding the address outright, since
an unverified address is worse than none once anything trusts
verified. GET /email/verify needs no session — the token is the proof,
because the browser opening the link may not be the one that asked
for it. New mailer crate wraps lettre/SMTP behind the same interface
shape as the rest of the app's external-service calls.
Linking: /auth/{provider}/start?mode=link attaches a provider to the
already-signed-in user instead of logging in — the callback branches
on link_user_id carried through the pending cookie, and reuses the
existing session rather than issuing a new one.
Emails: is_primary is no longer whatever a provider called primary —
that was never something the user chose. sync_provider_email leaves
it alone except to bootstrap the very first email; setPrimaryEmail is
the only way it changes after that. source (github/manual) lets
removeEmail refuse provider-sourced rows a login would just recreate.
addEmail already worked as a de-facto resend when called again with
the same address; the gap was visibility — nothing showed a request
was in flight. me.pendingEmailVerifications surfaces it (one row per
address, newest token), and a 60s cooldown keeps repeat clicks from
queuing a mail each time.
Split api-graphql into query/mutation modules — lib.rs was closing in
on the 250-line limit before this addition.
The callback handler was hardcoded to GitHub's two-call fetch — a
single fetch_profile(provider_kind, token) dispatch replaces it, with
each provider module owning its own API shape (GitLab and Google
return one call's worth of user+email, GitHub and Codeberg two).
Nothing changes about auth_provider or the OAuth flow itself; every
provider still just needs a row with its client id/secret and OAuth
endpoints to go live.
An address confirmed by two linked providers used to silently lose
one source on every sync — user_email_source normalizes that into a
proper set instead of a single overwritten column. removeEmail now
checks 'does any provider still claim this' instead of a literal
'manual' string.
Also pulls avatar_url from each provider's profile response and
refreshes it (and the username) on every login, not just the first —
linked accounts were showing neither before.
Mirrors the email_verification pattern: a mailed confirm link starts
the flow, a hashed token proves it, and a second mailed restore link
undoes it within 30 days. The account lock/unlock/hard-delete itself
still happens in Gitea (repo/org/package ownership lives there), so
the confirm handler calls into Gitea's new private API before
committing confirmed_at — a still-owned account gets a specific
reason back instead of a silent lock.
New crate gitea-client wraps that internal API (same shared secret
syncode-control already uses). An hourly sweep in main.rs hard-deletes
accounts whose restore window has closed.
Adds the storage/binary side of the one-time ETL that backfills
syncode-identity from Gitea's existing users/orgs/teams/repos/grants
(0.4 stage 4, Faza 1): idempotent seed_* functions (upsert by natural
key — username, slug, org+name, owner+name — so the import tool is
safe to re-run) plus a new insert_repository, and a bin/import_seed
that reads the Go side's export-seed JSON and writes back a
{gitea_id: uuid} mapping for Go's identity_link backfill. Grant rows
aren't deduplicated on re-run — harmless since capabilities are
unioned, not counted, but means this only runs once per environment
against a reviewed file.
Also changes find_or_create_user_by_link to check verified_emails
against user_email before creating a new user — a match on both this
forge's and the login's independently-verified claim is what makes
Faza 1's pre-seeded accounts (verified email, no linked_identity yet)
claimable on first login instead of ending up duplicated, and covers
the same case organically for anyone linking a second provider that
shares a verified address with their first.
Faza 1's migration needs to run against dev/prod's real Postgres, which
isn't reachable from outside the compose network — shipping the binary
in the image lets it run as a one-off via
\`docker compose run --entrypoint import_seed identity ...\` instead of
requiring a separate build/deploy path just for this tool.
Bootstraps organization-admin grants for each org's Owners team (Faza 1
only ever seeded repo-level grants), and adds storage + GraphQL
plumbing for teams and grants: list/create/delete teams, add/remove
members, grant and revoke repository access, all authorized via a new
require_capability GraphQL guard mirroring the gRPC bridge's existing
check_capability.
Team.grants (id, resource, capabilities) lists what the frontend needs
to render and revoke existing grants — without it, revokeGrant had no
way to discover a grant id to act on.
Exercises the actual HTTP GraphQL endpoint (cookie session, real
resolvers) rather than just storage — covers the FORBIDDEN path for a
non-admin, a full team/member/grant round trip, and the cross-org
security check on revokeGrant (an org-B admin can't revoke an org-A
grant by guessing its id).
The GitHub/GitLab access (and refresh, when given) tokens from an OAuth
exchange were fetched once for the profile call and then dropped —
nothing persisted them. The new heatmap sync jobs (0.4 stage 5, część
2) need to call these providers' APIs later in the background, so the
callback now encrypts and stores them via a new syncode-identity-token-
crypto crate (ChaCha20-Poly1305, key from
SYNCODE_IDENTITY_TOKEN_ENCRYPTION_KEY). Drops the long-dead, never-
written linked_identity.token_ref column in favor of proper
access/refresh/expiry columns.
Three 24h tokio::spawn loops (mirroring the existing deletion sweep):
native pulls per-repo, per-day counts from Gitea's new internal
heatmap endpoint and resolves them into identity's own repository ids;
GitHub/GitLab pull from each provider's contribution API using the
tokens saved by the previous commit, splitting public/private per
provider's own shape (GitHub: diff against its unauthenticated public
events; GitLab: each event's project visibility directly). All three
upsert into contribution_day.
Also fixes a real pre-existing gap in that table's unique constraint:
Postgres never treats NULL as equal to NULL for uniqueness, and every
row here has source_provider_id or source_resource_id NULL by design,
so the original UNIQUE(...) silently allowed duplicates. A
COALESCE-to-sentinel index closes it.
userHeatmap(username) returns three layers: native (each day filtered
by whether the viewer can read that repository — public repos always,
private ones only with an active read grant, reusing the same
resolve_effective_capabilities machinery Etap 5 część 1 built), linked
public (always shown), and linked private (gated by
heatmap_privacy_setting's opt-in and audience). Works for an anonymous
caller too, since public data shouldn't require signing in to see.
setHeatmapPrivacy is a personal-setting mutation, checked by identity
rather than require_capability — nobody else's grant should be able to
flip someone's own privacy preference.
GraphQL-level tests cover the security-relevant paths: a private
repo's contributions are invisible without a grant and appear once one
exists, and linked-private honors both the opt-in and the audience.
Codeberg was one of the three linkable providers but had no sync job
at all — a real gap, not the "needs a relink" situation GitHub/GitLab
are in. Codeberg (a Forgejo instance) still exposes the same
unauthenticated /users/{u}/heatmap endpoint this fork just removed
from its own public surface, so no token is needed — always
linked_public, since that endpoint never returns anything a private
caller couldn't already see.
userHeatmap's linkedPublic/linkedPrivate now return one entry per
provider (GitHub/GitLab/Codeberg) instead of a single merged sum, so
the front can offer per-provider filtering instead of only a combined
view.
Also reaches much further back: GitHub's contributionsCollection loops
across up to 10 one-year windows (its API rejects a wider single span),
GitLab's /events is now paginated up to 20 pages instead of one. Both
were previously capped at ~365 days for no real reason beyond "that's
what fit in one call."
A local target/ directory (release + debug artifacts, easily 10+ GB
after a normal working session) was getting uploaded as Docker build
context on every manual `docker buildx build` — CI never hits this
since it always starts from a clean checkout, but it made a local
rebuild (needed today when the amd64 CI runner sat queued indefinitely)
take much longer than it should have.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Promote the verified 0.4 implementation from develop to main after successful dev cutover, E2E, resilience tests and architecture audit.
Linking: /auth/{provider}/start?mode=link attaches a provider to the already-signed-in user instead of logging in — the callback branches on link_user_id carried through the pending cookie, and reuses the existing session rather than issuing a new one. Emails: is_primary is no longer whatever a provider called primary — that was never something the user chose. sync_provider_email leaves it alone except to bootstrap the very first email; setPrimaryEmail is the only way it changes after that. source (github/manual) lets removeEmail refuse provider-sourced rows a login would just recreate.Adds the storage/binary side of the one-time ETL that backfills syncode-identity from Gitea's existing users/orgs/teams/repos/grants (0.4 stage 4, Faza 1): idempotent seed_* functions (upsert by natural key — username, slug, org+name, owner+name — so the import tool is safe to re-run) plus a new insert_repository, and a bin/import_seed that reads the Go side's export-seed JSON and writes back a {gitea_id: uuid} mapping for Go's identity_link backfill. Grant rows aren't deduplicated on re-run — harmless since capabilities are unioned, not counted, but means this only runs once per environment against a reviewed file. Also changes find_or_create_user_by_link to check verified_emails against user_email before creating a new user — a match on both this forge's and the login's independently-verified claim is what makes Faza 1's pre-seeded accounts (verified email, no linked_identity yet) claimable on first login instead of ending up duplicated, and covers the same case organically for anyone linking a second provider that shares a verified address with their first.Codeberg was one of the three linkable providers but had no sync job at all — a real gap, not the "needs a relink" situation GitHub/GitLab are in. Codeberg (a Forgejo instance) still exposes the same unauthenticated /users/{u}/heatmap endpoint this fork just removed from its own public surface, so no token is needed — always linked_public, since that endpoint never returns anything a private caller couldn't already see.