Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions brain/knowledge/connections-auth/ce-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses
- Token is a short-lived JWT (7 days) signed with a shared secret. `PrincipalType`: USER, ENGINE, WORKER, SERVICE, UNKNOWN, ONBOARDING.
- Endpoints (all rate-limited via `API_RATE_LIMIT_AUTHN_*`): `POST /v1/authentication/sign-up`, `/sign-in`, `/switch-platform`.
- First sign-up side effects: creates identity → User (PlatformRole.ADMIN) → default PERSONAL project; sends OTP on Cloud prod, auto-verifies otherwise; fires `USER_CREATED` flag + `SIGNED_UP` telemetry.
- **`signUp` has two arms and only one of them can create a platform.** When `params.platformId` is set (self-hosted, or a custom domain) the member joins that existing platform through `getOrCreateWithProject` and no platform is ever created or named. When it is nil (Cloud only) the identity is created first, then `getPreferredPlatformId` looks for a platform the identity already belongs to; finding none it returns an ONBOARDING response and the member finishes at `/create-platform`. `getPreferredPlatformId` returns null on every non-Cloud edition. **The member never types a platform name; they type their own, and the platform name is derived from it.** `completeSignUp` takes a single `fullName` field (that is the whole of `CompleteSignUpRequest`) and calls `signupNames.platformNameFromSignup`, which prefers the company read off a work email domain (`"Activepieces"`) and falls back to the person (`"<FirstName>'s Platform"`, then the capitalised first token of the email local part, then `"My Platform"`). The project name follows from the platform name via `personalProjectName`.
- **ONBOARDING** is the pre-platform principal: `authenticationUtils.getOnboardingResponse` mints it with `platformId: null, projectId: null` for a verified identity that belongs to no platform yet, so the member can call `POST /v1/platforms` (`securityAccess.unscoped([ONBOARDING, USER])`) and land on `/create-platform`. It is Cloud-only in practice, because on self-hosted `platformUtils.getPlatformIdForRequest` falls back to `getOldestPlatform()` and there is always a platform to join. `accessTokenManager.assertUserSession` still revalidates it against `tokenVersion` + `verified`.
- **`signUp` has two arms and only one of them can create a platform.** When `params.platformId` is set (self-hosted, or a custom domain) the member joins that existing platform through `getOrCreateWithProject` and no platform is ever created or named. When it is nil (Cloud only) the identity is created first, then `getPreferredPlatformId` looks for a platform the identity already belongs to; finding none it hands off to `authenticationUtils.provisionOrOnboard`, which creates the platform straight away when the identity already carries a name someone gave us, and only falls back to an ONBOARDING response (finished at `/create-platform`) when the name is the placeholder derived from the email. `getPreferredPlatformId` returns null on every non-Cloud edition. **The member never types a platform name; they type their own, and the platform name is derived from it.** `completeSignUp` takes a single `fullName` field (that is the whole of `CompleteSignUpRequest`) and calls `signupNames.platformNameFromSignup`, which prefers the company read off a work email domain (`"Activepieces"`) and falls back to the person (`"<FirstName>'s Platform"`, then the capitalised first token of the email local part, then `"My Platform"`). The project name follows from the platform name via `personalProjectName`.
- **ONBOARDING** is the pre-platform principal: `authenticationUtils.getOnboardingResponse` mints it with `platformId: null, projectId: null` for a verified identity that belongs to no platform yet **and whose name we only guessed**, so the member can call `POST /v1/platforms` (`securityAccess.unscoped([ONBOARDING, USER])`) and land on `/create-platform`. It is Cloud-only in practice, because on self-hosted `platformUtils.getPlatformIdForRequest` falls back to `getOldestPlatform()` and there is always a platform to join. `accessTokenManager.assertUserSession` still revalidates it against `tokenVersion` + `verified`.
- **Passwordless sign-in** (`EMAIL_LOGIN`) is a typed 6-digit code on the same OTP primitive, offered only when `ApFlagId.SMTP_CONFIGURED` is true, with password as the fallback path. See [000027](../decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md) for the code-not-link, edition-reach and anti-enumeration reasoning.

### Gotchas
Expand All @@ -27,10 +27,11 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses
- Sessions are invalidated by rotating `tokenVersion` on `UserIdentity`.
- **A new unauthenticated endpoint must be added to `disallowedRoutes` in `packages/web/src/lib/api.ts`**, otherwise the SPA attaches whatever stale bearer token is still in storage and the call fails in exactly the situation the endpoint exists for.
- **The three signup guards in `authentication-utils.ts` differ in what they leak.** `assertEmailAuthIsEnabled` and `assertDomainIsAllowed` describe platform configuration, so surfacing their errors is safe. `assertUserIsInvitedToPlatformOrProject` describes one address, so surfacing it turns any public auth endpoint into an invitation oracle. All three are also inert unless `plan.ssoEnabled`.
- **We ask for a name only when we do not already have one, and `signupNames.isPlaceholderName` is what decides.** A name counts as a placeholder when the last name is empty *and* the first name matches `firstNameFromEmail` for that address case-insensitively — exactly what `requestCode` seeds an emailed-code identity with. Anything else provisions the platform without a second question, and the two other producers of a name cannot collide with the placeholder shape: `SignUpRequest` types `firstName`/`lastName` as `SAFE_STRING_PATTERN` (`^[^./]+$`, so an empty last name is a 400 at the schema, not just a required field in the form), and the Google callback substitutes `'john'`/`'doe'` when the provider omits a name. The comparison must stay case-insensitive: `requestCode` derives the name from the raw address while the identity stores it lowercased, so `AhmadTash@…` would otherwise look like a name its owner typed.
- **A nil `projectId` on the principal means "go to /create-platform" in four separate places.** Anything that mints a platform-less session has to satisfy all of them, not just the route guard.
- **Platform naming reads the email domain first, and "is this a work address" is a denylist of consumer brands.** `ahmad@activepieces.com` yields `"Activepieces"` while `ahmad@gmail.com` yields `"Ahmad's Platform"`. Two details are easy to get wrong when touching `signup-names.ts`. The denylist is keyed on the **registrable label**, not the full domain, so `yahoo.co.uk` is caught by the single entry `yahoo`. And the label is picked as the second-to-last domain part, stepping back one more when the part before the TLD is itself a public suffix (`co`, `com`, `ac`, ...), so `mail.activepieces.com`, `activepieces.co.uk` and `eu.activepieces.co.uk` all resolve to `Activepieces` rather than to `Mail`, `Co` or `Eu`. It is a heuristic, not a public-suffix list: a company sitting on an unlisted two-part suffix gets the suffix as its name. Only new signups are affected; existing platforms keep their names.
- **The route no longer decides sign-in vs sign-up — the card does.** `/sign-in`, `/sign-up` and `/create-platform` all render the same `AuthLanding`; `/sign-up` is a bare redirect to `/sign-in`. Which form you get is a function of two flags: with `SMTP_CONFIGURED` the card opens on the email-code step and the classic password form exists *only* behind the "Use password" link; without it you land on a password form directly, and `USER_CREATED` picks sign-up (first ever account, no mode switch offered) over sign-in. So the same URL renders three different DOMs across Cloud, a seeded self-host, and a fresh install — anything scripting this screen has to branch, and password sign-*up* is simply unreachable once SMTP is on.
- **`/create-platform` is that same card opening on its name step**, off the ONBOARDING token rather than a route param — submitting the name is what mints the platform and project and swaps ONBOARDING for USER. The field is the *person's* `Full Name` (`data-testid="auth-full-name"`), not a workspace name. A brand-new account therefore needs *two* form submissions before it has a project, which is easy to miss when automating first-run signup.
- **`/create-platform` is that same card opening on its name step**, off the ONBOARDING token rather than a route param — submitting the name is what mints the platform and project and swaps ONBOARDING for USER. The field is the *person's* `Full Name` (`data-testid="auth-full-name"`), not a workspace name. **Only the emailed-code path reaches it**: password sign-up and Google already collected a name, so those sessions are provisioned in the same request and land in the product with one form submission.

### Key files
Entry point: `authenticationService`, a log-taking factory called per request from `authentication.controller.ts`, registered as `authenticationModule` in `app.ts`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ A piece that uploads a large file to an external service (Amazon S3, Dropbox, Go
- There is **no** `AP_MAX_FILE_SIZE_MB` **ceiling** on the streamed URL input. Intentional — the feature exists to move large files, and the prior buffered path was likewise unbounded. A cap is deferred; it would need a counting pass-through stream that aborts past the limit.
- The `body` is **one-shot** — no whole-stream retry. Since #14347 `lib-storage` buffers each \~5 MB part before sending, so transient part-level errors (connection reset, throttling, HTTP 500) *are* retried within the upload. What remains non-retryable is the transfer as a whole: the source `Readable` cannot be re-read, so a failure that outlives the part retries cannot be replayed. Whole-stream retry would require buffering, which defeats streaming.
- **A stream body bypasses `httpClient`'s retry loop entirely** (`isStream ? 0 : retries` in `packages/pieces/common/src/lib/http/core/fetch-http-client.ts`). The loop reuses the body serialized before the first attempt, and both shapes it treats as a stream — a raw `Readable` and the `PassThrough` a `form-data` payload is piped into — are one-shot, so a retried request replays a drained stream and sends an empty or truncated body. Silently corrupting an upload is worse than not retrying it. The blast radius is wider than the file pieces: this is shared `pieces-common` behaviour, so *any* piece passing a stream or `form-data` body to `httpClient` now loses its `retries` setting. The rejected alternative was a body-*factory* API (`() => Readable`, as Azure's `BufferScheduler` does per block): neither source is re-readable, so every caller would have to learn to rebuild its body — a large change for a case the chunking uploaders (S3 `Upload`, Azure `uploadStream`) already handle better.
- Engines older than 0.87.0 ignore the `streaming` flag and deliver a buffered `ApFile` (no `body`, no `size`), and the registry serves latest piece versions to them regardless. Consumers must therefore never read `.body`/`.size` off the prop directly: `streamUtils.toStreamingBody` in `pieces-common` normalizes both shapes, and `FileProperty` types the streaming value as `ApStreamingFile | ApFile` so a direct read fails to compile (GIT-1808).
- The URL `fetch` opens the source connection at **input-resolution time** (before `run()`), like the buffered path.
- SSRF posture is unchanged from the existing buffered `handleUrlFile`: the same raw `fetch` also legitimately retrieves AP's own internal http `readUrl`s, so the https-only + `redirect:'error'` guard used by external-only piece code (e.g. SimplyPrint) is deliberately **not** applied here.
2 changes: 2 additions & 0 deletions brain/knowledge/engineering/ci-pr-review-hygiene.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def
- **The preview-server remove tool can't clean containers once the repo dir is gone.** Its `stop()` skips `docker compose down` when `repos/<subdomain>/docker-compose.yml` doesn't exist, so an env whose repo folder was deleted first leaves containers running forever — re-running `remove` is a no-op for them. Clean those manually via compose labels: `docker ps -aq --filter "label=com.docker.compose.project=<subdomain>"` (same filter works for `docker volume ls`). When auditing envs against PR state: read the real branch from the clone's HEAD (`git -C repos/<subdomain> symbolic-ref --short HEAD`) since subdomains flatten `/` to `-`; a clone sitting on `main` means the branch was deleted after merge; and an env with **no PR at all** is a manual `workflow_dispatch` preview — don't auto-delete those (bulk cleanup 2026-08-20 removed 27 closed-PR envs, reclaimed 32.5GB).
- **A unit test added under `packages/server/api/test/unit/` never runs in CI.** `ci.yml` runs exactly two test commands: `turbo run test` filtered to engine/shared/sandbox/ai-providers/pieces-framework/web, and `turbo run test-ce test-ee test-cloud check-migrations --filter=api`. The api package *has* a `test-unit` script (`vitest run test/unit`), but no workflow invokes it and the root `test-unit` filter list does not include api — so the 10+ files already sitting in `test/unit/**` are dead weight, and a new one passes review while protecting nothing. `packages/core/execution` is in the same position. Until the wiring changes, put api coverage that must actually gate merges in `test/integration/ce|ee|cloud`, and if you do add a unit test, say in the PR that you ran it locally and paste the result.
- **`tools/scripts/` is outside the lint and test wiring.** ESLint ignores it, and `npm run test-unit` only covers engine/shared/web. A script there with real policy logic must run its own tests from its own workflow — `pr-size.yml` runs `bun test tools/scripts/pr-size-check.test.ts` as a step before the check itself.
- **Reopening a bot-closed external PR is futile until a core member adds `keep-open` first.** `close-external-prs.yml` triggers on `pull_request_target` `[opened, reopened]`, so every reopen re-runs the same comment-then-close step; its `if` exempts OWNER/MEMBER/COLLABORATOR, bots, and the `keep-open` label, and nothing else. A docs PR from an outside contributor ([#15031](https://github.com/activepieces/activepieces/pull/15031)) was reopened 13 times over two days and closed 13 times within seconds of each, until a member labelled it `keep-open` and reopened it once. The same job also runs a nightly `actions/stale` pass that closes any PR idle 60 days. The lasting fix for a change worth keeping is to re-open it from a branch owned by someone with write access — author association, not the diff, is what the gate reads.
- **`license/cla` keys off the commit author email, so re-opening someone else's branch under your own name does not clear it.** CLA-assistant walks every commit in the PR rather than the PR author, and an author email that matches no GitHub account can never be matched to a signature — the 47 commits carried over onto [#15092](https://github.com/activepieces/activepieces/pull/15092) were authored as `ashrafsam@mac.lan`, a local hostname, so the check sat at `not_signed` on a PR opened by a member. It is not in the `main` ruleset's required-checks list, but it is red on the page and a reviewer reads that as unmergeable. Either the original author signs through the PR link, or the commits get re-authored to an email tied to their GitHub account before you open it.
- **A branch that predates the `brain/` → `brain/knowledge/` move cannot edit a brain page in place — GitHub will call the PR conflicting even when `git merge` is clean locally.** Git follows the rename and merges the modification into the new path; GitHub's mergeability check does not, so it reports `modify/delete` on the old path and the PR goes `dirty`. Local `git merge-tree --write-tree` exits 0 and hides the problem; reproduce what GitHub sees with `git merge -X no-renames origin/main`. Fix: merge `origin/main` into the branch first, which lands the edit at the new path, then push.
24 changes: 24 additions & 0 deletions brain/knowledge/engineering/helm-chart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
icon: ⛵
---

# Helm Chart

The Kubernetes install we ship to self-hosters, at `deploy/activepieces-helm/`. It is the Kubernetes peer of the `docker-compose.yml` on the Docker page — same app, different orchestrator — and it is **not** how our own Cloud deploys (see *Cloud Deployment Paths*, which runs Kamal and k3s).

## Two paths for an AP_* variable
`templates/deployment.yaml` builds one `env:` list from two values keys, in this fixed order:

- **`activepiecesConfig`** — a flat map rendered as plain `value:` entries. Rendered **first**. The shipped default holds only `AP_CONTAINER_TYPE`.
- **`activepiecesEnvVariables`** — a map of *secret name* → *list of var names*, rendered as `secretKeyRef` with `optional: true`. Rendered **second**. The shipped default routes `AP_EDITION`, `AP_EXECUTION_MODE`, `AP_ENCRYPTION_KEY`, `AP_JWT_SECRET` and the queue/auth vars through three secrets the chart does not create.

## What the chart creates
Only two secrets, both `data: {}` with mittwald `secret-generator` annotations that fill them in-cluster: `<release>-secrets` (encryption key) and `<release>-jwt-secret`. Postgres and Redis come from the Bitnami subcharts unless disabled.

## Key files
- `deploy/activepieces-helm` — chart, `values.yaml`, and `templates/`

## Gotchas
- **Setting the same `AP_*` var in both values keys puts two entries with one name in the pod spec, and the secret wins.** `activepiecesConfig` renders before `activepiecesEnvVariables`, and for duplicate env names the later entry is what the container process sees. Since the shipped `values.yaml` already lists `AP_EDITION` and `AP_EXECUTION_MODE` under `activepieces-config-secrets`, a user who follows the docs *and* has created that secret silently gets the secret's edition, not the one they set. `optional: true` saves the common case — with no such secret the ref is skipped and the plain value survives — so this reads as "works on my cluster" right up until someone populates the secret. Set each variable in exactly one place.
- **`activepieces-config-secrets`, `activepieces-auth-secrets` and `activepieces-queue-secrets` do not exist until you make them, and the script `values.yaml` names for the job is not in the repo.** The comment points at `deploy/scripts/apply-secrets.sh --secret-name <name>`; there is no `deploy/scripts/` directory. Every ref is `optional: true`, so a fresh `helm install` comes up anyway on the app's own defaults — which is why the gap survived: nothing fails, the vars are just quietly absent.
- **`AP_EDITION=ee` needs `AP_EXECUTION_MODE` set in the same breath or the pod will not boot.** `system-validator.ts` throws for `cloud`/`ee` in production unless the mode is one of `SANDBOX_PROCESS`, `SANDBOX_CODE_ONLY`, `SANDBOX_CODE_AND_PROCESS`, and the default is `UNSANDBOXED`. The error names the execution mode, not the edition, so it reads as a sandboxing problem rather than the edition switch that caused it.
Loading
Loading