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
28 changes: 15 additions & 13 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
# Optional: T3 Connect source builds
# Leave these unset to disable optional T3 Connect features in local source builds.
# Optional: Pylon Connect source builds
# Leave these unset to disable optional Pylon Connect features in local source builds.
# Release builds inject their public values at build time. Do not add server-side
# secrets to this file.

# Get these from the Clerk Dashboard under API keys, JWT templates, and OAuth applications.
# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_...
# T3CODE_CLERK_JWT_TEMPLATE=t3-relay
# T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_...
# These are Pylon's own public identifiers, not secrets: they ship inside every
# released web, desktop, and mobile artifact. Copy this file to .env to enable
# Pylon Connect in a source build. Mobile builds read the same values from the
# EAS environment; CI keeps that mirror in sync from the GitHub `production`
# environment, which is the source of truth.
# T3CODE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsucHlsb24tY29kZS5jb20k
# T3CODE_CLERK_JWT_TEMPLATE=pylon-relay
# T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=LL3XOy2zs9YyBSHm
# T3CODE_RELAY_URL=https://relay.pylon-code.com

# Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API
# hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk
# returns a different RP ID or when multiple domains must be entitled.
# T3CODE_APPLE_TEAM_ID=ABC1234567
# T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile
# T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/pylon.provisionprofile
# T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com

# Get this from your relay deployment. `infra/relay` deploys update it automatically.
# T3CODE_RELAY_URL=https://relay.example.com

# Optional: hosted app origin used by the CLI's out-of-band OAuth flow.
# Defaults to https://app.t3.codes; override to test against a staging deployment.
# T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes
# Defaults to https://app.pylon-code.com; override to test against a staging deployment.
# T3CODE_HOSTED_APP_URL=https://nightly.pylon-code.com

# Public, ingest-only mobile OpenTelemetry configuration.
# T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces
# T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev
# T3CODE_MOBILE_OTLP_TRACES_DATASET=pylon-mobile-traces-dev
# T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-...
13 changes: 13 additions & 0 deletions .github/workflows/mobile-eas-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ jobs:
APP_VARIANT: preview
NODE_OPTIONS: --max-old-space-size=8192
MOBILE_VERSION_POLICY: fingerprint
# app.config.ts reads these to build `extra.eas.projectId`, and eas-cli
# resolves the project from it. They otherwise live only in a gitignored
# .env.local, so without them here every eas command fails with "EAS
# project not configured".
PYLON_EAS_PROJECT_ID: ${{ vars.PYLON_EAS_PROJECT_ID }}
PYLON_EAS_OWNER: ${{ vars.PYLON_EAS_OWNER }}
steps:
- id: expo-token
name: Check for EXPO_TOKEN
Expand Down Expand Up @@ -88,6 +94,13 @@ jobs:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: eas env:pull preview --non-interactive

# The preview environment is a mirror; the production workflow refreshes it
# from the GitHub `production` environment. Fail here rather than hand a
# reviewer a build whose Connect surfaces are silently missing.
- name: Verify Connect config reaches the app manifest
if: steps.expo-token.outputs.present == 'true'
run: node scripts/verify-mobile-connect-config.ts

- name: Deploy with fingerprint check
if: steps.expo-token.outputs.present == 'true'
# Pinned to a release rather than @main: this is the only action reference
Expand Down
62 changes: 62 additions & 0 deletions .github/workflows/mobile-eas-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,24 @@ jobs:
runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
contents: read
# Pylon Connect's public config lives in this environment, the same source
# `relay_public_config` in release.yml feeds to desktop, CLI, and hosted web.
# Mobile reads it from here too, so the four surfaces cannot drift apart.
environment: production
env:
APP_VARIANT: production
NODE_OPTIONS: --max-old-space-size=8192
CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }}
CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }}
RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }}
RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }}
# app.config.ts reads these to build `extra.eas.projectId`, and eas-cli
# resolves the project from it. They otherwise live only in a gitignored
# .env.local, so without them here every eas command fails with "EAS
# project not configured".
PYLON_EAS_PROJECT_ID: ${{ vars.PYLON_EAS_PROJECT_ID }}
PYLON_EAS_OWNER: ${{ vars.PYLON_EAS_OWNER }}
steps:
- id: expo-token
name: Check for EXPO_TOKEN
Expand Down Expand Up @@ -91,13 +106,60 @@ jobs:
# ignored build script (no allowBuilds config outside the repo).
packager: npm

# EAS build servers read their own environment store, not this checkout —
# a repo-root .env is gitignored and never reaches them. So the GitHub
# values are mirrored into EAS here, keeping one source of truth instead
# of a hand-maintained expo.dev copy that silently drifts.
- name: Sync Connect config to the EAS environment
if: steps.expo-token.outputs.present == 'true'
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
set -euo pipefail

relay_domain="${RELAY_DOMAIN:-}"
if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then
relay_domain="relay.$RELAY_API_ZONE_NAME"
fi
# Partial configuration is treated as none, matching `relay_public_config`
# in release.yml: half-configured Connect fails at runtime in the user's
# app rather than here, where the cause is still visible.
missing=()
for name in relay_domain CLERK_PUBLISHABLE_KEY CLERK_JWT_TEMPLATE CLERK_CLI_OAUTH_CLIENT_ID; do
if [[ -z "${!name:-}" ]]; then
missing+=("$name")
fi
done
if (( ${#missing[@]} > 0 )); then
printf 'Connect is not configured (missing: %s); leaving the EAS environment untouched.\n' "${missing[*]}" >&2
exit 0
fi

sync() {
echo "Syncing $1 to the EAS production, preview, and development environments."
eas env:create \
--name "$1" --value "$2" \
--environment production --environment preview --environment development \
--visibility plaintext --type string --scope project \
--force --non-interactive
}
sync T3CODE_CLERK_PUBLISHABLE_KEY "$CLERK_PUBLISHABLE_KEY"
sync T3CODE_CLERK_JWT_TEMPLATE "$CLERK_JWT_TEMPLATE"
sync T3CODE_CLERK_CLI_OAUTH_CLIENT_ID "$CLERK_CLI_OAUTH_CLIENT_ID"
sync T3CODE_RELAY_URL "https://$relay_domain"

- name: Pull production environment variables
if: steps.expo-token.outputs.present == 'true'
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: eas env:pull production --non-interactive

- name: Verify Connect config reaches the app manifest
if: steps.expo-token.outputs.present == 'true'
run: node scripts/verify-mobile-connect-config.ts

- name: Build and submit
if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build'
working-directory: apps/mobile
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function ConfiguredConnectOnboardingRouteScreen() {
) : (
<View collapsable={false} className="rounded-[24px] bg-card p-5">
<Text className="text-sm leading-normal text-foreground-muted">
Sign in to your T3 account to set up Pylon Connect.
Sign in to your Pylon account to set up Pylon Connect.
</Text>
</View>
)}
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ function ConfiguredSettingsRouteScreen() {
<SettingsSection title="Account">
<SettingsRow
icon="person.crop.circle"
label="T3 Account"
label="Pylon Account"
value={accountLabel}
onPress={openAccount}
/>
Expand Down
50 changes: 45 additions & 5 deletions docs/internals/t3-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@ identifiers, not secrets.
Web, desktop, mobile, and bundled server builds statically inject the values they consume during
their build step. A built artifact does not need an environment file at runtime. CI release builds
should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`,
`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building. EAS preview and
production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS
environment.
`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building.

Mobile is the exception, because EAS build servers read their own environment store rather than the
checkout: a repository-root `.env` is gitignored and never reaches them. The GitHub `production`
environment stays the single source of truth, and `mobile-eas-production.yml` mirrors those values
into the EAS `production`, `preview`, and `development` environments before it builds. Both mobile
workflows then run `scripts/verify-mobile-connect-config.ts`, which resolves the public app manifest
and fails the job when `clerk.publishableKey`, `clerk.jwtTemplate`, or `relay.url` is absent. That
check exists because the failure is otherwise invisible: `hasCloudPublicConfig()` omits every Connect
surface with no error and no empty state, so a misconfigured build looks like an app that simply
never had the feature.

When any client-facing public value is absent, cloud UI is omitted. The `t3 connect` command group is
always registered: when the CLI public values are absent, `makeCli` in `apps/server/src/bin.ts`
Expand Down Expand Up @@ -236,8 +244,40 @@ codesign --verify --deep --strict "/Applications/Pylon (Alpha).app"
codesign -d --entitlements :- "/Applications/Pylon (Alpha).app"
```

The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth
flow uses a custom redirect URI, add that exact URI to the same allowlist.
## Mobile Native Redirect Allowlist

Mobile does **not** use `allowed_origins`. That field covers browser-like stacks — Electron and
browser extensions — which is why the desktop entries above live there. Clerk's native
authentication view (`AuthView` from `@clerk/expo/native`) is validated against a separate
**Redirect URLs** resource, reachable in the Dashboard under **Native applications > Allowlist for
mobile SSO redirect**. Patching `allowed_origins` does not affect it.

The view derives its redirect from the **iOS bundle identifier**, not from the app's URL scheme, so
each variant needs its own entry:

```text
com.pylon.code://callback
com.pylon.code.preview://callback
com.pylon.code.dev://callback
```

The Backend API is additive, so adding one entry cannot disturb the others:

```sh
curl -X POST https://api.clerk.com/v1/redirect_urls \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"com.pylon.code.preview://callback"}'
```

`GET /v1/redirect_urls` lists the current entries and `DELETE /v1/redirect_urls/<id>` removes one.

A missing entry fails at the end of the sign-in flow, not at launch: Clerk renders "The current
redirect url passed in the sign in or sign up request does not match an authorized redirect URI for
this instance" and names the rejected URI. Read that URI off the error rather than deriving it — it
is the exact string the allowlist needs. Note that these are bundle identifiers
(`com.pylon.code.preview`), while the desktop entries above are URL schemes (`pylon-code`); the two
namespaces are easy to confuse.

## Sign-in Surfaces

Expand Down
63 changes: 63 additions & 0 deletions scripts/verify-mobile-connect-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// @effect-diagnostics nodeBuiltinImport:off - Build bootstrap runs before an Effect runtime exists.
import * as NodeChildProcess from "node:child_process";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";

// Pylon Connect is gated on `hasCloudPublicConfig()` in
// apps/mobile/src/features/cloud/publicConfig.ts, which omits every Connect
// surface when any of these three is absent. That failure is invisible in the
// app: no error, no empty state, the section simply does not render. Asserting
// against the resolved manifest here turns a silently Connect-dark build into a
// failed job, where the cause is still visible.
const REQUIRED = [
["clerk.publishableKey", (extra: Extra) => extra?.clerk?.publishableKey],
["clerk.jwtTemplate", (extra: Extra) => extra?.clerk?.jwtTemplate],
["relay.url", (extra: Extra) => extra?.relay?.url],
] as const;

interface Extra {
readonly clerk?: { readonly publishableKey?: unknown; readonly jwtTemplate?: unknown };
readonly relay?: { readonly url?: unknown };
}

const REPO_ROOT = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)));
const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps", "mobile");

// The workspace-local binary, not `pnpm exec`: `pnpm exec` reinstalls the
// workspace and re-runs the CLI under its own Node, which can be older than the
// version whose type stripping app.config.ts needs.
const EXPO_BIN = NodePath.join(MOBILE_ROOT, "node_modules", ".bin", "expo");

function readPublicManifest(): { readonly extra?: Extra } {
const stdout = NodeChildProcess.execFileSync(EXPO_BIN, ["config", "--type", "public", "--json"], {
cwd: MOBILE_ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
});
// `expo config` can emit progress lines before the document.
const start = stdout.indexOf("{");
if (start === -1) {
throw new Error(`expo config produced no JSON document:\n${stdout}`);
}
return JSON.parse(stdout.slice(start));
}

const extra = readPublicManifest().extra;
const missing = REQUIRED.filter(([, read]) => {
const value = read(extra ?? {});
return typeof value !== "string" || value.trim() === "";
}).map(([name]) => name);

if (missing.length > 0) {
process.stderr.write(
`Pylon Connect config is missing from the app manifest: ${missing.join(", ")}.\n` +
"Mobile reads these from the EAS environment. Confirm the GitHub production " +
"environment defines CLERK_PUBLISHABLE_KEY, CLERK_JWT_TEMPLATE, and " +
"RELAY_API_ZONE_NAME (or RELAY_DOMAIN), and that the sync step ran before this one.\n" +
"Building now would ship an app with every Connect surface silently omitted.\n",
);
process.exit(1);
}

const variant = process.env.APP_VARIANT?.trim() || "production";
process.stdout.write(`Pylon Connect config present in the ${variant} app manifest.\n`);
Loading