Skip to content

[pull] main from appwrite:main - #219

Merged
pull[bot] merged 40 commits into
djacidfx:mainfrom
appwrite:main
Sep 3, 2026
Merged

[pull] main from appwrite:main#219
pull[bot] merged 40 commits into
djacidfx:mainfrom
appwrite:main

Conversation

@pull

@pull pull Bot commented Sep 3, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

HarshMN2345 and others added 30 commits August 30, 2026 23:47
GitHub only echoes the state parameter back when the flow finishes through the
redirect URI. An organisation member requesting owner approval, and an owner
approving that request from GitHub's own settings, both finish on the app's
setup URL instead, which carries no state. The callback then threw a bare 400
whose message blamed the user for being an organisation member -- the check is
only empty($state) and never looks at the caller's GitHub role, so an owner hit
the same page with no way forward.

Authorize now mirrors state into a short-lived, one-shot cookie the callback
falls back to. The error redirect also used a ':' separator instead of '?', so
the console received /settings/git-installations:error=... and could never read
the error back, and empty success/failure values in state overrode the computed
console defaults, turning the remaining failure branch into a bare 400 as well.
The unused setup_action param now distinguishes a submitted request from a
plain permission failure.
/v1/vcs/github/callback is public and took the projectId and both redirect URLs
straight out of the state parameter, so anyone could name the project an
installation gets attached to, or point the redirect anywhere. The OAuth2
providers already guard this: Authorize/Base signs [projectId, success, failure]
with _APP_OPENSSL_KEY_V1 and Callback/Base fails closed on hash_equals. The
GitHub App flow predates that and was never brought across.

Mirror the same signature into the state Authorize builds and verify it before
the projectId is read. This also covers the fallback cookie, since the cookie
carries the signed state verbatim.
The repository endpoints loaded an installation by id and never checked it
belongs to the requesting project. Authorization does not backstop this: on
admin-mode routes outside /v1/projects*, shared/api.php sets the default
authorization status to false, so getDocument('installations', $installationId)
returns another tenant's document.

Apply the same guard Installations/Get.php, Delete.php and Namespaces/XList.php
already use, so a mismatched project gets INSTALLATION_NOT_FOUND rather than
another tenant's repositories, file contents, branches or detections.
…attach

The cookie is set for ten minutes with SameSite=Lax, so a top-level navigation
from another site still sends it. Substituting it for missing state on a request
that carries an installation id let that cookie supply the project for an
installation it was never bound to; the signature covers the project and redirect
URLs, not the installation.

Fall back to the cookie only when there is no installation id, which is the
organisation-member request it exists for. Callbacks that would persist an
installation now require state on the request itself.
The cookie guessed that the browser hitting the callback was the one that
started the flow, which SameSite=Lax does not guarantee: any top-level
navigation from another site sent it. Once the callback refused to let it
attach an installation, the only branch it could still reach was the error
redirect, so all it bought was a nicer message on a flow that cannot succeed
anyway -- an organisation member cannot install the app, an owner has to.

It also never helped the case that matters. An owner approving a member's
request from GitHub's settings is a different browser with no cookie, so that
flow needs reconciliation from the installation webhook rather than anything
carried in the request.

Missing state now reports itself plainly in every case.
Test methods are verb-first everywhere else in tests/e2e, matching the
{restVerb}{Qualifier} rule the sibling testGetInstallation follows, and e2e
suites call builtins unprefixed rather than with a leading backslash.
The project scoping on the repository endpoints and the state signature are
separate concerns from the misleading installation error, and each wants its own
E2E coverage. Split out so this PR carries only the callback behaviour.
The cast came in with the fallback cookie and has no reader now that state is
only appended to the redirect query.
The repository endpoints loaded an installation by id and never checked it
belongs to the requesting project. Authorization does not backstop this: on
admin-mode routes outside /v1/projects*, shared/api.php sets the default
authorization status to false, so getDocument('installations', $installationId)
returns another tenant's document.

Apply the same guard Installations/Get.php, Delete.php and Namespaces/XList.php
already use, so a mismatched project gets INSTALLATION_NOT_FOUND rather than
another tenant's repositories, file contents, branches or detections.
Two console users with their own teams, projects and Gitea accounts: each
connects an installation, neither can use the other's through any of the six
repository endpoints even knowing its id, and the probe by the other tenant's
project id dies on team membership before reaching VCS code. Inviting the
second user into the first team and accepting via the emailed secret makes the
same project-scoped request succeed, while the installation still resolves only
under its own project.

The Gitea OAuth2 helpers move from VCSGiteaConsoleClientTest into VCSGiteaBase
and take the project, headers and Gitea account as parameters, so suites can
run the dance as any tenant. A second Gitea user is created through the admin
API with must_change_password disabled, since the default would turn the
authorize redirect into the change-password page.
…e suite

The missing-state message now says what happened (the installation finished on
GitHub, so it could not be linked to a project) and where to go, instead of a
bare parameter complaint. The request message warns that approving on GitHub is
not enough, since that route drops state and dead-ends the same way.

The suite signs its state the way authorize does: harmless while the callback
does not verify signatures, and keeps this file green once signing lands. The
state validator gets headroom (4096) because the redirect URLs it carries are
not length-limited, so a server-built state can outgrow 2048 on its own.
The fix in these branches is the separator character and the request-aware
message; inverting the guards on top of that buried a one-character change in a
rewritten block.
GitHub sends install, update or request; install and update always carry an
installation_id, so this branch treats them and anything unrecognized as the
permissions case.
Present-but-empty state, the request message from the match, the
project-not-found redirect, and the computed default redirect when state
carries empty URLs. States are built through one signer so redirects for
missing projects and empty URLs stay verifiable once signing lands.
The multi-tenant tests move into VCSGiteaConsoleClientTest and the OAuth2
dance takes its project, headers and Gitea account as parameters in place, so
the suite gains the coverage without relocating helpers or adding a class.
…des only

The match now names GitHub's three documented values and gives anything else
its own message instead of folding it into the permissions case. Tests assert
status codes and redirect targets; error prose is not a contract.
The long-state case is the one that distinguishes the widened validator from
the old one; the invalid-JSON case covers the null-guarded decode through to
the project lookup; the unexpected setup_action selects the match's default
arm.
The callback renders the HTML error view, so the body is a string and has no
type field to read.
Assigning an empty literal narrowed the cookie jar to array{}, so the reads
after each Gitea call were flagged; the Rector fix casts the email body before
the string assertion.
Without signing the decode leaves no project and the callback answers 404;
with signing it fails the signature first and answers 400. The point of the
case is that neither is a fatal error.
Authorize defaults success and failure to empty strings and signs them, so the
shared OAuth2 callback's array_merge let those empty values win over the
computed console URLs: Gitea, GitLab and Bitbucket redirect to '' on success
and throw instead of redirecting on failure. Same defect the GitHub callback
had, on three more providers.

The state validators also rejected an empty value before the friendly message
and capped at 2048, which the signature can push a server-built state past.

Tests move onto Gitea, where the callback can be driven end to end without
provider credentials: missing state, empty state, a state whose project was
changed after signing, the failure redirect carrying its error as a query
string, and empty redirect URLs falling back to the console default.
Empty state, project not found, the default redirect, invalid JSON and the
widened state length all run against the shared callback on Gitea. What stays
is GitHub's own file: setup_action, installation_id, and the missing-state 400
users actually hit.
The Gitea suite drives the shared callback end to end without provider
credentials, so the GitHub copies of those cases are redundant.
The long-state case is the one that tells the widened validator apart from the
old one; unknown project exercises the not-found redirect; invalid JSON lands
on the signature check.
One process with six coroutines instead of one process with one, matching the other workers. SMTP is stream-based and coroutine-hooked, so sends overlap on one event loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Authorize signs empty success and failure into state when none are given, and
before the fallback fix the callback then redirected to an empty location. The
helper's existing assertion on the callback's Location now proves the console
default is used instead. Also corrects a test comment that described GitHub's
separator bug, which the shared callback never had.
chore: run the mails worker with 6 coroutines
HarshMN2345 and others added 10 commits September 2, 2026 19:13
…scope

fix(vcs): scope installation repository endpoints to the project
# Conflicts:
#	tests/e2e/Services/VCSGitea/VCSGiteaConsoleClientTest.php
…allback

fix(vcs): stop the installation callback dead-ending, on every provider
…nnot be created

Bucket creation writes the bucket document, then creates the
bucket_<sequence> collection. The second step is DDL, cannot share a
transaction with the first, and needs the document's sequence for its
name. Any failure between the two (a lock wait timeout, a killed pod)
left a bucket document with no collection under it: a bucket that can
never hold a file, and that every later request fails on with
"Collection not found". Cloud found 227 such buckets across regions.

Delete the document when collection creation throws, then rethrow, so
a failed create leaves nothing behind. The test asserts the buckets
collection is empty after the failed request, and fails on the
unpatched code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
It asserted the double's bookkeeping of a deleteDocument call rather
than anything a user can observe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(storage): remove the bucket document when its files collection cannot be created
…ibution

perf: cache geo and audit attribution lookups
The callback builds its fallback redirect from the project's region, but
the expected URL hardcoded project-default-. Cloud CI creates its test
projects in another region, so testCreateInstallationWithEmptyRedirects
and testCreateInstallationWithoutRedirects fail there since #13426.

Expose the created project's region from the ProjectCustom scope and
build the expected URL from it, so the test reads no environment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQPB3JWhybuoo1gHhqDBLa
test(vcs): expect the Gitea callback redirect in the project's region
@pull pull Bot locked and limited conversation to collaborators Sep 3, 2026
@pull pull Bot added the ⤵️ pull label Sep 3, 2026
@pull
pull Bot merged commit b3a843f into djacidfx:main Sep 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants