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
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,10 @@ current") with nextActions `auth workspace use` and login.
TokenStorage view; the exchange itself runs OUTSIDE the file lock
(§8) — only the resulting write takes it:
- `setTokens` (the rotation write): updates IN PLACE only `token`,
`refreshToken`, `expiresAt` (re-derived from claims; the SDK's
pair carries no expiry) of its workspace's record. NEVER creates
`refreshToken`, `expiresAt` (the proactive token-endpoint adapter
supplies the explicit OAuth lifetime; an SDK-driven rotation falls
back to the access token's claim) of its workspace's record. NEVER
creates
a record, NEVER moves the marker, NEVER touches the name. If the
freshly-read state has no record for that workspace (ended by
another process), refuse and throw — no resurrection. If the new
Expand Down Expand Up @@ -758,9 +760,12 @@ endAllSessions(): Promise<void>;
* has returned non-null; the engine resolves that first. */
activeCredentialStorage(): Promise<TokenStorage>;
/** ENGINE-FACING (S3). The active credential's ACCESS token, read
* fresh on every call, for handing to a child process. Never the
* refresh token. Single consumer: ctx.spawn's credential injection. */
activeAccessToken(): Promise<string | null>;
* fresh on every call, for handing to a child process. With options,
* refreshes or refuses a token that lacks the required remaining
* lifetime. Never the refresh token. */
activeAccessToken(
options?: ActiveAccessTokenOptions,
): Promise<string | null>;
```

All three mutations are workspace-id-keyed, symmetric with
Expand All @@ -774,10 +779,10 @@ S3 amendment (2026-08-11, re-ruled after the PR-136 architect review):
the engine forwards the storage `activeCredentialStorage()` returns
into SDK client config and never calls its methods itself — no
exceptions. What the spawn path needs is a manager OPERATION, not a
carve-out: the interface gains `activeAccessToken()`, whose single
consumer is `ctx.spawn`'s credential injection in the engine's spawn
module (`packages/cli-engine/src/execution/spawn.ts`, `spawnToken`).
It is read at spawn time and handed to the child as
carve-out: the interface gains `activeAccessToken()`, consumed by the
delegated-credential preflight and by `ctx.spawn`'s credential injection
in the engine's spawn module (`packages/cli-engine/src/execution/spawn.ts`,
`spawnToken`). It is read at spawn time and handed to the child as
`PRISMA_SERVICE_TOKEN` (+ `PRISMA_WORKSPACE_ID` when the credential
names a workspace; when it names none, an inherited
`PRISMA_WORKSPACE_ID` is DELETED from the child environment — the two
Expand All @@ -791,6 +796,17 @@ that same `ctx.api` through its `deps.client` seam, not by composing
another client from env). For an environment-only manager the
operation is a pass-through of the env token — no storage involved.

S3 amendment (2026-08-14): the child still receives only an access-token
snapshot, but a stored OAuth session is no longer rejected merely because
that snapshot is inside the five-minute window. Before the handler runs, the
engine asks `activeAccessToken(options)` to refresh the pair under the
manager's storage lock, persist the rotation, and return the new access token.
The shipped manager receives a host-side token-endpoint adapter at construction;
the manager remains the sole owner of storage reads and writes, and the
refresh token is never added to child env. The spawn-time call is another
validated fresh read, so rotation by another process between preflight and
spawn is still observed without handing the child an unchecked replacement.

### 11.6 whoami

`whoami` asks for the active credential's identity and renders it. It
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@
* separate credentialsForSpawn declaration is gone, and the entailment
* (child credentials imply the credentials need) is structural. The
* manager gains the named engine-facing operation activeAccessToken()
* for the spawn path's read, so the "engine never calls storage
* methods" rule is absolute — no sanctioned exception.
* for delegated preflight and the spawn-time read, so the "engine
* never calls storage methods" rule is absolute — no sanctioned
* exception.
* exitWithChildStatus(opts?) takes { nextActions? }, rendered
* to stderr before the exit (R-S3-4's reproduce hint).
* Amended 2026-08-11 (operator ruling) — SIGNAL SETTLEMENT IS THE
Expand Down Expand Up @@ -585,6 +586,12 @@ export interface Session {
readonly current: boolean
}

export interface ActiveAccessTokenOptions {
readonly minimumValidityMs: number
readonly now: Date
readonly signal: AbortSignal
}

/** Manages sessions: six user-facing operations plus one
* engine-facing accessor. Custody, not user interaction: never opens
* a browser, never prompts. Env is a construction input. The manager
Expand Down Expand Up @@ -628,10 +635,11 @@ export interface CredentialManager {
* credential's ACCESS token, read fresh, for handing to a child
* process that authenticates as this process does. Never the
* refresh token — the child gets a snapshot it cannot refresh.
* Single consumer: ctx.spawn's credential injection. The read
* builds no second API client, so the one-client-per-process
* invariant holds (credential-manager-design.md §11.5). */
activeAccessToken(): Promise<string | null>
* With options, refreshes a near-expiry stored OAuth pair before
* returning its access token. Preflight and ctx.spawn both use the
* options form so the spawn-time fresh read is also validated. The
* refresh token never reaches the child. */
activeAccessToken(options?: ActiveAccessTokenOptions): Promise<string | null>
}

/** The SDK's typed client and token-storage contract, re-exported by
Expand All @@ -643,7 +651,12 @@ import type {
} from '@prisma/management-api-sdk'

export type ManagementApiClient = SdkClient
export type TokenStorage = SdkTokenStorage
type SdkTokens = NonNullable<Awaited<ReturnType<SdkTokenStorage['getTokens']>>>
type StoredTokens = SdkTokens & { readonly expiresAt?: Date }
export type TokenStorage = Omit<SdkTokenStorage, 'getTokens' | 'setTokens'> & {
getTokens(): Promise<StoredTokens | null>
setTokens(tokens: SdkTokens, expiresAt?: Date): Promise<void>
}

/** SDK client construction config, injected by the bin beside the
* manager (§10). All four fields: the SDK's refreshing fetch
Expand Down
18 changes: 8 additions & 10 deletions .drive/projects/prisma-cli-v8/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,17 +147,15 @@ CLI does not do, and each restarts as engine work if wanted:
consumer (`packages/cli-engine/src/execution/spawn.ts`) moves with
it. Recorded in `assets/engine/credential-manager-design.md`.
- **Nothing bounds a child run to the token it was given.** A
`credentials: "child"` command hands the child a snapshot of the
`credentials: "child"` command still hands the child a snapshot of the
access token and never the refresh token
(`packages/cli-engine/src/execution/spawn.ts`), and the only check is
the near-expiry refusal in `execution/needs.ts`:
`CREDENTIAL_NEAR_EXPIRY_MS` is 5 minutes, so the guarantee at spawn is
"more than five minutes left", not "enough for this run". A converge
that outlives the snapshot fails on an expired token, after the child
has already created resources. Two ways out, both unbuilt: hand the
child something that can refresh, or bound the child's run and refuse
when the remaining lifetime cannot cover it. Recorded as a release
limitation in `plan.md`'s coverage ledger.
(`packages/cli-engine/src/execution/spawn.ts`). The parent now refreshes a
stored OAuth pair before the handler when its access token is inside
`CREDENTIAL_NEAR_EXPIRY_MS`, so a refreshable session receives a fresh
snapshot instead of an unnecessary sign-in error. That does not bound the
child's total runtime: a converge that outlives even the refreshed snapshot
can still fail after creating resources. The remaining ways out are to hand
the child something that can refresh or to bound the child's run.
- **A validated number flag**, if `--tail`'s old constraint is wanted
back. `flag.number` accepts negatives and fractions, so "non-negative
integer" is enforced nowhere. D4 took the other branch this item
Expand Down
2 changes: 1 addition & 1 deletion .drive/projects/prisma-cli-v8/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ Recorded so they are not lost between slices.
| Prompts (defaults, consent, wizard) | S2 (init) |
| Poll + status events; output streams | S2 (domain wait; `build logs` — `service logs` moved to S8) |
| Auth via context | S2, S3 (deploy, destroy) |
| Refresh under long runs | **Still unproven** (corrected at S3 closure). S3 proves the STATIC-token handoff instead: the child is given a snapshot that never refreshes, and the refresh token is never injected, so a long converge runs on a token that can expire mid-run. The contract accepts that and refuses up front when the session is near expiry. **The bound that refusal buys is five minutes** (`CREDENTIAL_NEAR_EXPIRY_MS` in `execution/needs.ts`): a run starts only when more than five minutes remain, and nothing limits how long the child then runs, so a converge outliving the snapshot fails on an expired token after it has created resources. That is a release limitation, recorded in `deferred.md`, not a solved problem. The in-process leg uses the engine's refreshing client, but nothing in S3 runs long enough to make it refresh. |
| Refresh under long runs | **Still unproven.** The child receives an access-token snapshot and never the refresh token. As of the 2026-08-14 amendment, the parent proactively rotates a refreshable stored OAuth pair before the handler when the access token is inside `CREDENTIAL_NEAR_EXPIRY_MS`; this avoids rejecting a healthy login and gives the child a fresh snapshot. Nothing bounds the child runtime, so a converge can still outlive that refreshed snapshot and fail after it has created resources. That remaining limitation is recorded in `deferred.md`. |
| Config sections, command families, validator absence | Two levels, and they are proven in different places. The section machinery — a total validator including absence, a validator's warning diagnostic, and the engine's unknown-section check — is proven by the engine's own suite (`packages/cli-engine/tests/config.test.ts`) against toy sections. What S3 adds is ONE real section end to end: composer's, a single optional string field, declared by one family, read from disk by the bin's real loader, accepted by composer's own validator, and arriving at composer's handler as the path it acts on (`v8-bin.test.ts`, "hands the composer section of prisma.config.ts to the composer family"). Only the accepting path is covered there: nothing in the bin shows composer's validator refusing a section, running on an absent one, or warning on an unknown key, because `log` against that one fixture is the only run a shipped composer command makes to config without credentials. The platform family declares no section, so two families contributing to one config file is unproven, and so is any section with required or structured fields. Both wait for S5. |
| Session commands, signal lifetime | S3 (dev, log) |
| Cross-repo/published consumption, pins, tandem releases | S3 |
Expand Down
3 changes: 2 additions & 1 deletion .drive/projects/prisma-cli-v8/specs/s3-composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,8 @@ Acceptance verified against source and merged PRs: prisma-cli #136,
are `packages/cli-engine/tests/spawn-real-child.test.ts` and
`spawn.test.ts` (plus `environment-credential-manager.test.ts`); the
SPI amendment is `credential-manager-design.md` §11.5
(`activeAccessToken()`, single consumer `execution/spawn.ts`); the
(`activeAccessToken(options)`, consumed by delegated preflight and
`execution/spawn.ts`); the
static-graph check is composer's `check:family-static-graph` and the
sole-listener detector is composer's
`cli/src/family/__tests__/signal-listeners.test.ts`; the tarball
Expand Down
87 changes: 87 additions & 0 deletions packages/cli-engine/src/active-access-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
authServiceError,
credentialsRequiredError,
} from "./credential-errors";
import type { ActiveAccessTokenOptions } from "./credential-manager";
import type { CredentialRefresher, TokenStorage } from "./management-api";
import { CliStructuredError } from "./protocol";
import { claimedExpiresAt } from "./token-claims";

type Tokens = NonNullable<Awaited<ReturnType<TokenStorage["getTokens"]>>>;

/** Shared implementation used by every CredentialManager. */
export async function readActiveAccessToken(
storage: TokenStorage,
refreshCredential: CredentialRefresher | undefined,
options?: ActiveAccessTokenOptions,
): Promise<string | null> {
if (options === undefined) {
return (await storage.getTokens())?.accessToken ?? null;
}
const runLocked = storage.withRefreshLock ?? (async (fn) => fn());
try {
return await runLocked(async () => {
// A waiter always re-reads inside the lock so a rotation that won the
// race is used without exchanging the old refresh token again.
const current = await storage.getTokens();
if (current === null) return null;
if (!expiresSoon(current.accessToken, options, current.expiresAt)) {
return current.accessToken;
}
if (!current.refreshToken) {
throw credentialsRequiredError("expiring-soon");
}
if (refreshCredential === undefined) {
throw new Error(
"@prisma/cli-engine: delegated OAuth refresh requires Runtime.refreshCredential",
);
}
const refreshed = await refreshCredential({
refreshToken: current.refreshToken,
signal: options.signal,
});
if (refreshed.kind === "invalid") {
await clearCurrentTokens(storage, current);
throw credentialsRequiredError("expired");
}
if (expiresSoon(refreshed.accessToken, options, refreshed.expiresAt)) {
throw new Error("the OAuth endpoint returned a short-lived token");
}
await storage.setTokens(
{
workspaceId: current.workspaceId,
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
},
refreshed.expiresAt,
);
return refreshed.accessToken;
});
} catch (cause) {
if (CliStructuredError.is(cause) || options.signal.aborted) throw cause;
throw authServiceError();
}
}

function expiresSoon(
token: string,
options: ActiveAccessTokenOptions,
fallbackExpiresAt?: Date,
): boolean {
const expiresAt = claimedExpiresAt(token) ?? fallbackExpiresAt;
return (
expiresAt !== undefined &&
expiresAt.getTime() - options.now.getTime() <= options.minimumValidityMs
);
}

async function clearCurrentTokens(
storage: TokenStorage,
current: Tokens,
): Promise<void> {
if (storage.clearTokensIfCurrent !== undefined) {
await storage.clearTokensIfCurrent(current);
return;
}
await storage.clearTokens();
}
7 changes: 4 additions & 3 deletions packages/cli-engine/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ export interface NeedsSpec<TConfig> {
* Fail early with the sign-in error when unauthenticated. The
* `"child"` form (S3) additionally makes the engine compose the
* active credential into every child environment
* (PRISMA_SERVICE_TOKEN, PRISMA_WORKSPACE_ID) and refuse the run
* before the handler when that credential expires too soon to hand
* out — a child cannot refresh the snapshot it is given. It
* (PRISMA_SERVICE_TOKEN, PRISMA_WORKSPACE_ID). Before the handler it
* refreshes a stored OAuth session that expires too soon, or refuses
* an unrefreshable credential — a child cannot refresh the snapshot
* it is given. It
* requires `maySpawn` (construction error otherwise) and entails
* the plain credentials need.
*/
Expand Down
18 changes: 12 additions & 6 deletions packages/cli-engine/src/credential-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ export interface ActiveCredential {
readonly origin: CredentialOrigin;
}

export interface ActiveAccessTokenOptions {
/** Refuse or refresh a token with no more than this lifetime left. */
readonly minimumValidityMs: number;
readonly now: Date;
readonly signal: AbortSignal;
}

/**
* Manages the credentials this machine holds: the stored per-workspace
* sessions, which one is selected, and the credential this process
Expand Down Expand Up @@ -142,11 +149,10 @@ export interface CredentialManager {
/**
* ENGINE-FACING. The active credential's ACCESS token, read fresh on
* every call, for handing to a child process that authenticates as
* this process does. Never the refresh token: the child gets a
* snapshot it cannot refresh. Null when the material is gone (the
* session ended). Single consumer: ctx.spawn's credential injection
* (credential-manager-design.md §11.5) — the read builds no second
* API client, so the one-client-per-process invariant holds.
* this process does. With options, a near-expiry OAuth pair is refreshed
* under the storage lock before its access token is returned. Never the
* refresh token: the child gets a snapshot it cannot refresh. Null when
* the material is gone (the session ended).
*/
activeAccessToken(): Promise<string | null>;
activeAccessToken(options?: ActiveAccessTokenOptions): Promise<string | null>;
}
24 changes: 19 additions & 5 deletions packages/cli-engine/src/environment-credential-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
* refuses with a structured error. Env is a construction input; nothing
* here reads process.env.
*/
import { readActiveAccessToken } from "./active-access-token";
import { emptyServiceTokenError } from "./credential-errors";
import {
type ActiveAccessTokenOptions,
type ActiveCredential,
type Credential,
type CredentialManager,
Expand Down Expand Up @@ -98,10 +100,18 @@ export class EnvironmentCredentialManager implements CredentialManager {
return this.#activeStorage;
}

/** The spawn path's read: the env token passes through directly. It
/** The delegated path's read: the env token passes through directly. It
* is already a snapshot with no refresh token behind it. */
async activeAccessToken(): Promise<string | null> {
return this.#token() ?? null;
async activeAccessToken(
options?: ActiveAccessTokenOptions,
): Promise<string | null> {
const credential = await this.activeCredential();
if (credential === null) return null;
return readActiveAccessToken(
await this.activeCredentialStorage(),
undefined,
options,
);
}

#buildActiveStorage(): TokenStorage {
Expand All @@ -117,6 +127,7 @@ export class EnvironmentCredentialManager implements CredentialManager {
workspaceId: this.#workspaceId(token) ?? NO_WORKSPACE_NAMED,
accessToken: token,
refreshToken: undefined,
expiresAt: claimedExpiresAt(token),
};
const singleFlight = <T>(fn: () => Promise<T>): Promise<T> => {
const queued = this.#refreshLock.then(fn, fn);
Expand All @@ -128,8 +139,11 @@ export class EnvironmentCredentialManager implements CredentialManager {
};
return {
getTokens: async () => tokens,
setTokens: async (rotated) => {
tokens = rotated;
setTokens: async (rotated, expiresAt) => {
tokens = {
...rotated,
expiresAt: claimedExpiresAt(rotated.accessToken) ?? expiresAt,
};
},
clearTokens: async () => {
tokens = null;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-engine/src/execution/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ function observedTokenStorage(
};
return {
getTokens: () => storage.getTokens(),
setTokens: (tokens) => storage.setTokens(tokens),
setTokens: (tokens, expiresAt) => storage.setTokens(tokens, expiresAt),
clearTokens: () => storage.clearTokens(),
...(storage.clearTokensIfCurrent === undefined
? {}
Expand Down
Loading
Loading