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
9 changes: 7 additions & 2 deletions brain/knowledge/ai-intelligence/ai-providers.md

Large diffs are not rendered by default.

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,20 +16,21 @@ 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 names the platform themselves at `/create-platform`. `getPreferredPlatformId` returns null on every non-Cloud edition. There is no `"<firstName>'s Platform"` autoname in production; that string lives only in `dev-seeds.ts`.
- **`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`.
- **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
- Email-auth checks and domain allow-listing guards are **skipped on Community** edition.
- OTP verification only sent on Cloud production; CE/EE and Cloud-dev (`AP_ENVIRONMENT=development`) auto-verify the identity.
- **OTP verification is sent on Cloud unless `AP_ENVIRONMENT` is exactly `dev`, in which case the identity is silently auto-verified and no email goes out.** `sendVerificationOrAutoVerify` compares against `ApEnvironment.DEVELOPMENT`, whose value is the string **`dev`** — not `development`, which an earlier version of this line claimed. The distinction is not cosmetic: `AP_ENVIRONMENT=dev` on Cloud takes the `verify()` branch and the email-code flow becomes untestable locally, while any other value (including a typo like `development`, which fails the system validator with a warning and nothing more) falls through to `otpService.createAndSend` and really does email. So to exercise sign-up email locally on Cloud, `AP_ENVIRONMENT` must not be `dev`; `prod` also works but switches on the newsletter POST to a live endpoint. CE/EE take the other edition arm entirely.
- Telemetry PII (email/name) sent only on Cloud; CE/EE send non-PII fields (`pickTelemetryPii`). Sign-in telemetry covers password sign-in only, not SSO.
- 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`.
- **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. 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. A brand-new account therefore needs *two* form submissions before it has a project, which is easy to miss when automating first-run signup.

### 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
@@ -0,0 +1,28 @@
---
status: accepted
---

# Chat onboarding asks only users with no row, and a backfill closes the door behind existing ones

## Decision
The first-run onboarding card ("Who am I teaming up with?") renders when the caller has **no `chat_personalization` row at all**, a state the service synthesizes as `UNSET` and never persists. To keep that from firing for the entire existing user base on ship day, the shipping migration **backfills one terminal row per existing user** rather than gating the card on a hardcoded ship date.

## Context
The card's gate is `isEmpty && !incognito && (isFirstRun || promptOpen)`, where `isFirstRun` is `status === UNSET`. `UNSET` means "no row exists", not "new account", so on first deploy every user who had never answered would have met it, including the grandfathered cloud users from the 200-user chat rollout cap. Those people have been chatting for months and would have opened a normal new chat to find a new-user welcome takeover where their greeting used to be.

The obvious fix is `user.created > SHIP_DATE`. It works, and it leaves a date constant in the code that nobody ever deletes and no one dares change.

## Why
Backfilling puts the fact in the data, where it is true, instead of in a branch that has to keep being true. After the migration `UNSET` means exactly what it says: a user who has never been asked. The gate needs no second clause, no clock, and no knowledge of when we shipped.

It also keeps the door open in a way the date constant does not. A backfilled row is a real row we can query, so "existing users we never onboarded" is a population we can count and later invite deliberately, rather than a set defined by an inequality against a magic number.

The legacy rows take a **distinct terminal status, not the existing `SKIPPED`**. `SKIPPED` means a user saw the question and declined it, which is a genuine signal about that person. Reusing it would blur those two groups together permanently and make the invite-them-later query impossible to write.

Rejected: gating on `user.created` against a ship-date constant, for the reasons above.

## Consequences
- The migration writes one row per existing user in a single `INSERT ... SELECT`. It is the hardest thing in this feature to reverse, since un-writing it cannot distinguish a backfilled row from a real one. The distinct legacy status is what makes it reversible at all.
- Anyone adding a status to the enum must decide whether it is terminal for this gate. A non-terminal addition silently re-opens the card for everyone holding it.
- The card is still gated on an empty conversation, so a backfilled user never sees it even if a row is later cleared by hand.
- Shipped with the feature on `feat/chat-onboarding-personalization`, as `BackfillChatPersonalizationForExistingUsers`. It skips users with a null `platformId`, which the `user` table still permits, because the target column is `NOT NULL`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
title: First-party AI vendors run on one hardcoded endpoint
icon: 🛡️
status: accepted
---

# First-party AI vendors run on one hardcoded endpoint

## Decision

The six OpenAI-compatible vendor providers (xAI, DeepSeek, Z.ai, Qwen, MiniMax, Moonshot) carry an
empty config and route through `OPENAI_COMPATIBLE_VENDOR_BASE_URLS`, a hardcoded map to each vendor's
international endpoint. There is no user-facing endpoint setting of any kind — no base URL, no region.

## Context

These vendors run separate China and international endpoints, so the first cut shipped an optional
free-text `baseUrl`. Greptile then caught that only *model discovery* went through `safeHttp.axios`:
inference hands the URL to `createOpenAICompatible`, which uses the AI SDK's own fetch and bypasses the
SSRF filter. A platform admin could point a provider at an internal or cloud-metadata host and read the
response back as generated text, with the stored API key attached. That was replaced by a region enum,
which closed the SSRF surface but still cost a config field, a schema in two packages, a resolver, a
form control, translation keys and a dialog branch.

## Why

The endpoint setting never earned its keep. Every defect in the change came from that one optional
field: it had to be ordered ahead of the empty schemas in the untagged `AIProviderConfig` union, and the
admin dialog's own generic branch stripped it silently before submit. Deleting the field deleted the bug
class along with the SSRF surface, and it kept exactly the code path that was verified against live keys —
international was the only endpoint ever tested.

The China platforms (`platform.moonshot.cn`, `bigmodel.cn`, DashScope Beijing) issue **separate accounts**,
not merely different URLs, so a China key would not have authenticated against the international host
regardless. `AIProviderName.CUSTOM` already exists for arbitrary OpenAI-compatible endpoints and covers
that audience without any of this machinery.

## Consequences

- A customer on a China vendor account configures it through `CUSTOM`, not through the named provider entry.
- Changing or adding an endpoint is a code change and a release, not a user setting.
- `CUSTOM` still accepts an arbitrary admin `baseUrl` on the unfiltered inference path, and its schema is a
bare `z.string()`. This decision does not close that; the filtered-transport work is still owed.
3 changes: 3 additions & 0 deletions brain/knowledge/engineering/ci-pr-review-hygiene.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ Which team gets asked to review comes entirely from `.github/CODEOWNERS` — the
Enforcement is the **`Codeowners review` repository ruleset** (active on the default branch), not classic branch protection: `require_code_owner_review: true` plus `required_approving_review_count: 1` and `required_review_thread_resolution: true`. Eight bypass actors are configured, which is why an owner-team request can look non-blocking on some PRs.

## Gotchas
- **Engine tests that call a live host are flakes waiting to happen, and the SSRF guard is off in tests so loopback is the fix.** `flow-rerun.test.ts` was the repo's top CI flake for months — two live calls to `cloud.activepieces.com` (a 404 plus `GET /api/v1/pieces`, the full catalog) inside a self-imposed 10s budget. It timed out 3× in one night on [#14966](https://github.com/activepieces/activepieces/pull/14966), a pieces-metadata-only PR, and 3 runs straight on [#14987](https://github.com/activepieces/activepieces/pull/14987), always within ~35ms of the limit; on a good day it merely *passed* at 8,163ms of 10,000ms. It was finally fixed by serving both responses from a `node:http` server on an ephemeral loopback port (8,163ms → 846ms), not by a bigger timeout — mid-investigation the host went fully unreachable, and no timeout value fixes a host that does not answer. Three facts that generalise: **(1)** `ssrfGuard`'s `isGuardEnabled` keys off `AP_NETWORK_MODE === STRICT`, which `packages/server/engine/vitest.config.ts` never sets, so the guard is inert in engine tests and a loopback server needs no config change — and `ssrf-guard.test.ts` passes explicit `allowList`s, so it is unaffected either way. **(2)** The engine's vitest default is already `testTimeout: 20000`; `flow-rerun` was the only file overriding it *downward*, which is why `flow-piece.test.ts` survived a 10,262ms call in the same run (it overrides *up* to 30s). Never override below the project default. **(3)** `piecePath.resolve` → `findInDistFolder` scans every dist `package.json` under `packages/pieces` (400+) on **every** call — only `pieceRunner.describe` results are cached, not the path — so the cold cost lands entirely in whichever test in a file runs first. That still applies to every other piece-loading engine test.
- **Repo-wide regenerators sweep `main`'s pending drift into your PR — run them, then keep only your own lines.** `npm run i18n:extract` reorders all of `en/translation.json` and rewrites nine locale files (130 moved lines for six new keys), and `bun install` after a version bump writes back every community-piece version that was bumped without a lockfile sync (103 lines for four intended bumps). Both diffs are indistinguishable from real work in review, and both bury the change you actually made. Revert the file and hand-apply your own entries instead — then prove parity by running the generator into a scratch copy and diffing just your keys against it, so you keep byte-identical output without the churn. Provider setup markdown in `features/agents/ai-providers.ts` is extracted as translation keys in **source order**, so new entries go beside their neighbours in `SUPPORTED_AI_PROVIDERS`, not at the end.
- **`.env.dev` is TRACKED, so the `.env*` line in `.gitignore` does not protect it — secrets put there get committed.** `.gitignore` line 82 is `.env*`, which reads as blanket protection for every env file, but gitignore has no effect on a path already in the index, and both `.env.dev` and `.env.example` are committed on `main`. `git check-ignore .env.dev` returns nothing, which is the tell. So an SMTP password or API key dropped into `.env.dev` shows up in `git status` as a normal modification and rides the next `git add -A`. Put local secrets under `dev/` instead — that whole directory is genuinely ignored (line 27) — and reach for `git check-ignore -v <path>` before writing a credential anywhere, rather than trusting the pattern.
- **A bare `*` in CODEOWNERS matches every file at every depth, so the catch-all owner is dragged into PRs that have nothing to do with them.** Unlike `docs/*` (direct children only), `*` is fully recursive, and last-match-wins means only an explicit later rule can release a path. A lockfile-only PR requested `core` ([#14629](https://github.com/activepieces/activepieces/pull/14629)), and so did a single-page docs PR ([#14422](https://github.com/activepieces/activepieces/pull/14422), one file under `brain/`). The release valve is a **path listed with no owner after the `*` line**, which GitHub reads as owned-by-nobody; CODEOWNERS has no `!negation` syntax and no brace expansion — `packages/**/{A,B}.md` parses clean and matches a file literally named `{A,B}.md`. Verify any edit with `gh api repos/activepieces/activepieces/codeowners/errors` — an invalid line is silently *skipped*, which quietly restores the catch-all owner instead of failing loudly.
- **A spurious `core` request on a pieces PR is not always the lockfile — check for a second root file.** [#14558](https://github.com/activepieces/activepieces/pull/14558) looked like the lockfile case but its non-pieces files were `bun.lock` *and* `tsconfig.base.json`; the `core` request landed 6s after the commit that touched the tsconfig, not after the pieces push. Per-piece `paths` mappings generated into root `tsconfig.base.json` mean a pieces change can still reach a core-owned file, and no CODEOWNERS pattern can fix that — the file holds real compiler options and CODEOWNERS has no sub-file granularity.
- **Greptile's Confidence Score prose is cumulative — a low score is not evidence of a live problem.** It edits one summary comment in place, and its "Files Needing Attention" list keeps naming findings that are already resolved and outdated: #14825 sat at 2/5 citing three files, two of which were a closed P1 and a duplicate view of the third. Read the *unresolved* review threads (`reviewThreads(first:60) { isResolved isOutdated }` over GraphQL — the REST comments endpoint carries no resolution state) and judge from those; re-trigger the review to refresh the score. It also re-raises the same class of finding each round with a new comment id, so a fix on one thread does not silence its sibling.
Expand Down
Loading
Loading