Skip to content
Open
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
20 changes: 4 additions & 16 deletions .agents/skills/test-styal-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,14 @@ for a fresh one. Keep using the same tab.

## Sign in to styal Link

Link sign-in only appears when the dev server has the development Clerk identifiers and relay URL. If the root `.env` lacks them, export `.env.development.example` for the dev server: `set -a; . ./.env.development.example; set +a` before `vp run dev`. Never use the production values locally; production Clerk rejects sign-ins from `localhost`.
Link sign-in only appears when the dev server has a relay URL. If the root `.env` lacks one, export `.env.development.example` for the dev server: `set -a; . ./.env.development.example; set +a` before `vp run dev`. It points at the shared development relay, `relay-dev.styal.build`.

Use the shared agent account, `agent+clerk_test@styal.build`. Do not create other accounts.

- **Local dev (development instance):** open Settings and choose **Sign in to styal Link**. A fresh database shows the welcome wizard first; click through it. Enter the email and the verification code `424242`. Clerk test mode sends no email. Typing the code as soon as the code field appears can fail with "You need to send a verification code before attempting to verify"; wait a few seconds after the field appears, or choose **Resend** and enter the code again. `.env.development.example` points at the matching development relay, `relay-dev.styal.build`.
- **`app.styal.build` or builds using production Clerk:** test mode is off, so the code is not accepted. Mint a one-time token with the maintainer's Clerk CLI login:
- **Local dev (development relay):** open Settings and choose **Sign in to styal Link**. A fresh database shows the welcome wizard first; click through it. Enter the email, choose **Send code**, then enter the development relay's fixed test code: the value of the `RELAY_DEV_TEST_SIGN_IN_CODE` variable, which the maintainer sets. It only works while the dev relay uses log delivery (`RELAY_DEV_EMAIL_DELIVERY=log`); if sign-in says email sign-in is not available, those variables are not set, so ask the maintainer.
- **`app.styal.build` or other builds using the production relay:** there is no test code. Ask the maintainer for the code from the sign-in email, and keep it out of screenshots and logs. Sign out when done; signing out ends the session on the relay.

```sh
clerk api /sign_in_tokens --app app_3IPih12l7JcyeHP2MlqFOESKdGN --instance prod \
-d '{"user_id":"user_3Js3kNcKhTiIAGCvFcG70AqB4ZY","expires_in_seconds":300}'
```

Pass its `token` to the loaded app page, not its `url`:

```js
const signIn = await Clerk.client.signIn.create({ strategy: "ticket", ticket: TOKEN });
await Clerk.setActive({ session: signIn.createdSessionId });
```

The token is a credential; keep it out of screenshots and logs. When done, list the active production sessions with `clerk api "/sessions?user_id=<user>&status=active"` and revoke each with `clerk api /sessions/<id>/revoke -X POST`, passing the same `--app` and `--instance prod` flags.
The sign-in dialog's selectors: scope dialog queries by name (`getByRole("dialog", { name: "Sign in to styal Link" })`), since the provider "Updates Available" toast is also a dialog.

## Verify and retain

Expand Down
4 changes: 0 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r
# automatically.
T3CODE_RELAY_URL=https://relay.t3.codes

# 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

# Optional: styal-owned Expo project. Without both values, mobile OTA updates are
# disabled so a source build can never join T3 Code's update stream.
# EXPO_OWNER=<styal Expo account>
Expand Down
15 changes: 13 additions & 2 deletions .github/fork-features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3961,8 +3961,10 @@ features:
- Accounts imported from Clerk keep their Clerk user id. During transition, email sign-in rechecks imported email ownership and resolves unmapped verified addresses through Clerk, while exchange refreshes the email mapping. Clerk outages fail as upstream_unavailable instead of splitting accounts or rejecting credentials. A relay without a Clerk secret keeps stored email mappings as they are and makes no Clerk lookups, so removing Clerk later does not split imported accounts.
- The account endpoints accept either a Clerk bearer token or a relay DPoP access token with the account scope. DPoP authentication does not call Clerk. Deleting an imported account tears down its environments, then deletes its Clerk identity when Clerk is configured (404 counts as success; outages are retryable upstream_unavailable), then deletes relay data, so a failure at any step leaves the account and its sessions able to retry.
- Email sign-in stays off until a sender is configured, and production refuses log delivery and fixed test codes.
- Web, desktop, and mobile sign in with the emailed code; web and desktop keep the session next to the browser's DPoP key.
- A client still signed in with Clerk moves to a relay session once without asking, then signs Clerk out so a later styal Link sign-out stays signed out.
- Web, desktop, mobile, and the CLI sign in with the emailed code; web and desktop keep the session next to the browser's DPoP key, and the CLI asks for the code in the terminal, including over SSH.
- Web, desktop, and mobile exchange a stored Clerk sign-in once for a relay session, then sign Clerk out locally so a later styal Link sign-out stays signed out.
- CLI adoption and credential updates use a cross-process lease. Startup skips absent credentials and retries only transient failures, including a busy lease; it forgets a rejected Clerk token only after a definitive grant rejection. Successful adoption or email verification retains cloud-cli-oauth-token for older processes until logout and writes cloud-cli-clerk-adopted so it is never adopted again.
- CLI login offers email sign-in after permanent credential recovery failures, keeps prompting after temporary verify or resend failures, and treats a stored session for another relay as signed out. The CLI uses packages/shared/src/dpopProof.ts to sign proofs and sends authorization only to the session's relay origin. Email sign-in replaces upstream's CLI OAuth flow (apps/server/src/cloud/CliTokenManager.ts and cliAuthHtml.ts, deleted here), so intake must not restore those files.
- A session the relay reports as ended signs the client out; a network failure does not.
- Account deletion retries teardown for active links, revoked links, and outstanding allocations, clearing held-hook inboxes before removing allocation records. Teardown failure preserves cleanup state and account sessions for retry. Deletion queues Live Activity ends before removing registrations and removes activity content only for environment/key pairs with no other active account owner; signed removal ends remain deliverable after SQL removal without recreating account records. Deletion takes session row locks before device and Live Activity rows, the same order as registration and session revocation.
- Exported traces never carry database driver detail (bound parameters, failing rows, driver messages), so auth persistence failures do not export email addresses; spans keep the query text, SQL state and constraint name.
Expand All @@ -3971,6 +3973,7 @@ features:
- apps/mobile/src/features/cloud/relayKeychain.ts
- apps/mobile/src/features/cloud/relaySession.ts
- apps/mobile/src/features/cloud/relaySessionStore.ts
- apps/server/src/cloud/CliSession.ts
- apps/web/src/cloud/legacyClerkSession.tsx
- apps/web/src/cloud/relaySession.ts
- apps/web/src/cloud/relaySessionStore.ts
Expand All @@ -3988,7 +3991,13 @@ features:
- infra/relay/src/auth/EmailSignInCodes.ts
- infra/relay/src/auth/clerkImport.ts
- infra/relay/src/pruneExpiredState.ts
- packages/shared/src/dpopProof.ts
upstream_paths:
- apps/server/src/auth/ServerSecretStore.ts
- apps/server/src/cli/connect.ts
- apps/server/src/cloud/CloudLink.ts
- apps/server/src/cloud/publicConfig.ts
- apps/server/src/server.ts
- apps/web/src/cloud/managedAuth.tsx
- infra/relay/src/Config.ts
- infra/relay/src/auth/RelayTokens.ts
Expand All @@ -4001,6 +4010,8 @@ features:
- packages/contracts/src/relay.ts
tests:
- apps/mobile/src/features/cloud/relaySessionStore.test.ts
- apps/server/src/cli/connect.test.ts
- apps/server/src/cloud/CliSession.test.ts
- apps/web/src/components/relay-auth/RelayEmailSignInForm.logic.test.ts
- infra/relay/src/Config.test.ts
- infra/relay/src/Runtime.test.ts
Expand Down
155 changes: 155 additions & 0 deletions apps/server/src/auth/SecretStoreLock.errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as TestClock from "effect/testing/TestClock";
import * as HttpClient from "effect/http/HttpClient";
import { lock } from "proper-lockfile";
import { vi } from "vite-plus/test";

import * as CliSession from "../cloud/CliSession.ts";
import { withChatGptSessionLock } from "../provider/CodexChatGptSessionLock.ts";
import { SecretStoreLockError, withSecretStoreLock } from "./SecretStoreLock.ts";
import * as ServerSecretStore from "./ServerSecretStore.ts";

// Simulate the library's final acquisition failure without spending its 40-second retry budget.
vi.mock("proper-lockfile", () => ({ lock: vi.fn() }));

const instanceId = ProviderInstanceId.make("lock-error-test");

it.effect.each(["ELOCKED", "EACCES"])("classifies final acquisition failure %s", (code) =>
Effect.gen(function* () {
vi.mocked(lock).mockRejectedValue(
Object.assign(new Error("Lease acquisition failed"), { code }),
);
const error = yield* Effect.flip(withSecretStoreLock("secrets", "session", Effect.void));
assert.instanceOf(error, SecretStoreLockError);
assert.equal(error.reason, code === "ELOCKED" ? "busy" : "unavailable");
const codexError = yield* Effect.flip(
withChatGptSessionLock("secrets", "session", instanceId, Effect.void),
);
assert.instanceOf(codexError, ProviderSetupError);
assert.equal(codexError.instanceId, instanceId);
assert.equal(codexError.operation, "credential-lock");
assert.equal(
codexError.detail,
"Could not lock the ChatGPT connection for an update. Try again.",
);
}),
);

it.effect("interrupts a protected task when the shared lease is compromised", () =>
Effect.scoped(
Effect.gen(function* () {
const entered = yield* Deferred.make<void>();
const interrupted = yield* Deferred.make<void>();
const release = vi.fn(async () => {});
vi.mocked(lock).mockResolvedValue(release);
const task = yield* withSecretStoreLock(
"secrets",
"session",
Deferred.succeed(entered, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
).pipe(Effect.result, Effect.forkScoped);
yield* Deferred.await(entered);
const options = vi.mocked(lock).mock.lastCall?.[1];
options?.onCompromised?.(Object.assign(new Error("Lease lost"), { code: "ECOMPROMISED" }));
const result = yield* Fiber.join(task);
assert.equal(result._tag, "Failure");
yield* Deferred.await(interrupted);
assert.equal(release.mock.calls.length, 1);
}),
),
);

it.effect.each(["ELOCKED", "EACCES"])(
"reports %s clearly and retries startup adoption only for a busy lock",
(code) =>
Effect.scoped(
Effect.gen(function* () {
vi.mocked(lock).mockRejectedValue(
Object.assign(new Error("Lease acquisition failed"), { code }),
);
const session = yield* CliSession.make;
for (const task of [
session.currentIdentity.pipe(Effect.asVoid),
session
.verifyEmailSignIn({ email: "person@example.test", code: "12345678" })
.pipe(Effect.asVoid),
session.signOut.pipe(Effect.asVoid),
]) {
const error = yield* Effect.flip(task);
assert.instanceOf(error, CliSession.CloudCliSessionStorageError);
assert.equal(
error.message,
code === "ELOCKED"
? "Another styal process is updating the styal Link sign-in; try again."
: "Could not read or update the stored styal Link sign-in.",
);
}
const failed = yield* Deferred.make<void>();
const retried = yield* Deferred.make<void>();
const warnings: Array<unknown> = [];
let attempts = 0;
yield* Layer.build(
CliSession.layerStartupAdoption.pipe(
Layer.provide(
Layer.succeed(CliSession.CloudCliSession, {
...session,
currentIdentity: Effect.suspend(() => {
attempts += 1;
return session.currentIdentity.pipe(
Effect.tapError(() =>
Deferred.succeed(attempts === 1 ? failed : retried, undefined),
),
);
}),
}),
),
Layer.provide(
Logger.layer([Logger.make(({ message }) => warnings.push(message))], {
mergeWithExisting: false,
}),
),
),
);
yield* Deferred.await(failed);
if (code === "ELOCKED") {
yield* TestClock.adjust("1 second");
yield* Deferred.await(retried);
assert.equal(attempts, 2);
assert.include(String(warnings[0]), "will retry");
} else {
yield* TestClock.adjust("10 minutes");
assert.equal(attempts, 1);
assert.lengthOf(warnings, 1);
assert.include(String(warnings[0]), "adoption stopped");
}
}),
).pipe(
Effect.provide(NodeServices.layer),
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make(() =>
Effect.die("No HTTP request should run without the credential lease"),
),
),
Effect.provideService(ServerSecretStore.ServerSecretStore, {
directory: "synthetic-secrets",
get: (name) =>
name === CliSession.LEGACY_CLERK_CLI_TOKEN_SECRET
? Effect.succeedSome(new TextEncoder().encode("stored-legacy-credential"))
: Effect.succeedNone,
set: () => Effect.die("No credential mutation should run without the lease"),
create: () => Effect.die("No credential mutation should run without the lease"),
remove: () => Effect.die("No credential mutation should run without the lease"),
getOrCreateRandom: () => Effect.die("unused"),
}),
),
);
59 changes: 59 additions & 0 deletions apps/server/src/auth/SecretStoreLock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// @effect-diagnostics nodeBuiltinImport:off - A child process verifies filesystem exclusion.
import * as NodeChildProcess from "node:child_process";
import * as NodeModule from "node:module";
import * as NodePath from "node:path";
import * as NodeUtil from "node:util";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import { withSecretStoreLock } from "./SecretStoreLock.ts";

const execFile = NodeUtil.promisify(NodeChildProcess.execFile);
const lockModule = NodeModule.createRequire(import.meta.url).resolve("proper-lockfile");
const probe = (directory: string) =>
Effect.promise(async () => {
const { stdout } = await execFile(process.execPath, [
"-e",
`
const { lock } = require(process.argv[1]);
lock(process.argv[2], { realpath: false }).then(async release => {
await release(); process.stdout.write('acquired');
}, error => {
if (error.code === 'ELOCKED') process.stdout.write('blocked');
else { console.error(error); process.exitCode = 1; }
});
`,
lockModule,
NodePath.join(directory, "cloud-cli-session.bin"),
]);
return stdout;
});

it.live("excludes another process and releases the credential lease after interruption", () =>
Effect.scoped(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const directory = yield* fs.makeTempDirectoryScoped({
prefix: "clerk-cli-lock-",
});
assert.equal(
yield* withSecretStoreLock(directory, "cloud-cli-session", probe(directory)),
"blocked",
);
assert.equal(yield* probe(directory), "acquired");
const entered = yield* Deferred.make<void>();
const operation = yield* withSecretStoreLock(
directory,
"cloud-cli-session",
Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)),
).pipe(Effect.forkScoped);
yield* Deferred.await(entered);
assert.equal(yield* probe(directory), "blocked");
yield* Fiber.interrupt(operation);
assert.equal(yield* probe(directory), "acquired");
}),
).pipe(Effect.provide(NodeServices.layer)),
);
54 changes: 54 additions & 0 deletions apps/server/src/auth/SecretStoreLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @effect-diagnostics nodeBuiltinImport:off - Credential leases coordinate Node server processes.
import * as NodePath from "node:path";
import { lock } from "proper-lockfile";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Predicate from "effect/Predicate";
import * as Schema from "effect/Schema";

export class SecretStoreLockError extends Schema.TaggedError<SecretStoreLockError>()(
"SecretStoreLockError",
{ resource: Schema.String, reason: Schema.Literals(["busy", "unavailable"]) },
) {
override get message(): string {
return `Could not lock ${this.resource} for an update. Try again.`;
}
}

/** Serialize a credential transition across processes sharing a file-backed store. */
export const withSecretStoreLock = <A, E, R>(
directory: string | undefined,
key: string,
task: Effect.Effect<A, E, R>,
): Effect.Effect<A, E | SecretStoreLockError, R> => {
// In-memory test stores have no filesystem shared with other processes.
if (!directory) return task;
const failure = (cause?: unknown) =>
new SecretStoreLockError({
resource: key,
reason:
Predicate.hasProperty(cause, "code") && cause.code === "ELOCKED" ? "busy" : "unavailable",
});
return Effect.scoped(
Effect.gen(function* () {
const compromised = yield* Deferred.make<never, SecretStoreLockError>();
const services = yield* Effect.context<never>();
yield* Effect.acquireRelease(
Effect.tryPromise({
try: () =>
lock(NodePath.join(directory, `${key}.bin`), {
realpath: false,
stale: 120_000,
update: 10_000,
retries: { retries: 80, factor: 1, minTimeout: 500, maxTimeout: 500 },
onCompromised: () =>
Effect.runSyncWith(services)(Deferred.fail(compromised, failure())),
}),
catch: failure,
}),
(release) => Effect.promise(() => release()).pipe(Effect.ignore),
);
return yield* Effect.raceFirst(task, Deferred.await(compromised));
}),
);
};
Loading
Loading