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
60 changes: 55 additions & 5 deletions packages/api-client/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,7 +1539,15 @@ export interface paths {
delete?: never;
options?: never;
head?: never;
patch?: never;
/**
* Rename run
* @description Changes only the run display name. Supply expectedName for optimistic concurrency; omission supports last-writer-wins clients.
*
* Requires the runs:write scope.
*
* Accepts a scoped bearer token or interactive owner session.
*/
patch: operations["patch_api_v1_runs_runId"];
trace?: never;
};
"/api/v1/runs/{runId}/trace": {
Expand Down Expand Up @@ -2304,7 +2312,7 @@ export interface operations {
/** @constant */
role: "owner";
};
capabilities: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[];
capabilities: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "runs:write" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[];
/** Format: date-time */
expiresAt: string;
};
Expand Down Expand Up @@ -3606,7 +3614,7 @@ export interface operations {
"application/json": {
id: string;
name: string;
scopes: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[];
scopes: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "runs:write" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[];
created_at: string;
expires_at: string;
last_used_at?: string | null;
Expand Down Expand Up @@ -3635,7 +3643,7 @@ export interface operations {
content: {
"application/json": {
name: string;
scopes: (("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write") | ("flows:read" | "flows:write" | "runs:write"))[];
scopes: (("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "runs:write" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write") | ("flows:read" | "flows:write"))[];
expiryDays: 7 | 30 | 90;
};
};
Expand All @@ -3650,7 +3658,7 @@ export interface operations {
"application/json": {
id: string;
name: string;
scopes: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[];
scopes: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "runs:write" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[];
created_at: string;
expires_at: string;
last_used_at?: string | null;
Expand Down Expand Up @@ -6296,6 +6304,48 @@ export interface operations {
503: components["responses"]["Error503"];
};
};
patch_api_v1_runs_runId: {
parameters: {
query?: never;
header?: never;
path: {
runId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
name: string;
expectedName?: string;
};
};
};
responses: {
/** @description Saved run display name */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
run_name: string;
};
};
};
400: components["responses"]["Error400"];
401: components["responses"]["Error401"];
403: components["responses"]["Error403"];
/** @description Run not found within the tenant */
404: components["responses"]["Error404"];
/** @description stale_run_name: reload before retrying */
409: components["responses"]["Error409"];
415: components["responses"]["Error415"];
429: components["responses"]["Error429"];
500: components["responses"]["Error500"];
503: components["responses"]["Error503"];
};
};
get_api_v1_runs_runId_trace: {
parameters: {
query?: {
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/access-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ import type { Env, OAuthProps } from "./types";
import { databaseFor } from "./postgres/database";
import { AccessTokenRepository } from "./postgres/access-token-repository";

export const ACCESS_SCOPES = ["projects:read", "projects:write", "jobs:read", "jobs:write", "runs:read", "runs:invoke", "runs:cancel", "integrations:read", "integrations:write", "skills:read", "skills:write"] as const;
export const ACCESS_SCOPES = ["projects:read", "projects:write", "jobs:read", "jobs:write", "runs:read", "runs:invoke", "runs:cancel", "runs:write", "integrations:read", "integrations:write", "skills:read", "skills:write"] as const;
export type AccessScope = typeof ACCESS_SCOPES[number];
export type LegacyScope = "flows:read" | "flows:write" | "runs:write";
export type LegacyScope = "flows:read" | "flows:write";

/** Legacy grants are accepted only at authentication time. New issuance never emits them. */
export function normalizeScopes(scopes: readonly string[]): string[] {
const result = new Set<string>();
for (const scope of scopes) {
if (scope === "flows:read") ["projects:read", "jobs:read", "integrations:read"].forEach(value => result.add(value));
else if (scope === "flows:write") ["projects:write", "jobs:write", "integrations:write"].forEach(value => result.add(value));
else if (scope === "runs:write") ["runs:read", "runs:invoke", "runs:cancel"].forEach(value => result.add(value));
else if (scope === "runs:write") ["runs:read", "runs:invoke", "runs:cancel", "runs:write"].forEach(value => result.add(value));
else if (ACCESS_SCOPES.includes(scope as AccessScope)) result.add(scope);
}
return [...result];
Expand Down
9 changes: 8 additions & 1 deletion packages/api/src/api-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { ApiService } from "./flow-service";
import {
jobInputSchema,
manualInvocationSchema,
renameRunSchema,
jobConditionsTestSchema,
listRunsSchema,
scopeSchema,
Expand Down Expand Up @@ -108,7 +109,7 @@ const accessTokenBody = z.object({
name: z.string().trim().min(1).max(100),
// Legacy names remain accepted at this boundary for migration; the service
// immediately stores their canonical resource-scope expansion.
scopes: z.array(z.union([scopeSchema, z.enum(["flows:read", "flows:write", "runs:write"])] )).min(1),
scopes: z.array(z.union([scopeSchema, z.enum(["flows:read", "flows:write"])] )).min(1),
expiryDays: z.union([z.literal(7), z.literal(30), z.literal(90)]),
});

Expand Down Expand Up @@ -154,6 +155,7 @@ function route(
// Legacy route labels are translated by resource and effect; they are never
// accepted as newly issued scopes.
function scopeForRoute(path: string, legacy: string): string | string[] {
if (legacy === "runs:write" && path === "/api/v1/runs/{runId}" ) return "runs:write";
if (legacy === "runs:read" && path === "/api/v1/search") return "jobs:read+runs:read";
if (legacy === "flows:read") {
if (path.startsWith("/api/v1/projects") || path.includes("/jobs/") && path.endsWith("/project")) return "projects:read";
Expand Down Expand Up @@ -887,6 +889,11 @@ export const API_ROUTES: RouteContract[] = [
},
(service, { params }) => service.getRun(params.runId),
),
route("PATCH", "/api/v1/runs/{runId}", "runs:write", {
summary: "Rename run",
description: "Changes only the run display name. Supply expectedName for optimistic concurrency; omission supports last-writer-wins clients.",
responses: { "200": { description: "Saved run display name", content: { "application/json": { schema: { type: "object", required: ["run_name"], properties: { run_name: { type: "string" } }, additionalProperties: false } } } }, "404": { description: "Run not found within the tenant" }, "409": { description: "stale_run_name: reload before retrying" } },
}, (service, { params, body }) => service.renameRun(params.runId, body), { body: renameRunSchema, parameters: z.object({ runId: z.uuid() }) }),
route(
"GET",
"/api/v1/runs/{runId}/trace",
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/consent-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export async function consentDecision(env: Env, session: Session, input: z.infer
const client = await env.OAUTH_PROVIDER.lookupClient(parsed.clientId);
if (!client || !client.redirectUris.includes(parsed.redirectUri)) return null;
const allowed = new Set(normalizeScopes(parsed.scope.filter(scope => supported.includes(scope))));
const legacyRequest = parsed.scope.some(scope => scope === "flows:read" || scope === "flows:write" || scope === "runs:write");
const legacyRequest = parsed.scope.some(scope => scope === "flows:read" || scope === "flows:write");
const selected = normalizeScopes(input.scopes);
const issuedScopes = legacyRequest ? input.scopes : selected;
if (input.scopes.some(scope => !allowed.has(scope) && !allowed.has(normalizeScopes([scope])[0] ?? "") ) || selected.some(scope => !ACCESS_SCOPES.includes(scope as typeof ACCESS_SCOPES[number]))) return null;
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/flow-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";
import { conditionsSchema, preparedWebhookSchema } from "./webhook-conditions";

export const scopeSchema = z.enum(["projects:read", "projects:write", "jobs:read", "jobs:write", "runs:read", "runs:invoke", "runs:cancel", "integrations:read", "integrations:write", "skills:read", "skills:write"]);
export const scopeSchema = z.enum(["projects:read", "projects:write", "jobs:read", "jobs:write", "runs:read", "runs:invoke", "runs:cancel", "runs:write", "integrations:read", "integrations:write", "skills:read", "skills:write"]);
export const runStateSchema = z.enum(["queued", "reserved", "starting", "running", "done", "blocked", "failed", "ignored", "succeeded", "stopping", "stopped"]);
export const matchRuleSchema = z.object({
type: z.enum(["owner", "creator", "status", "label", "assignee"]),
Expand All @@ -15,6 +15,7 @@ export const listRunsSchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(50), cursor: z.string().min(1).optional(),
}).refine(value => !value.direction || value.sort, { message: "direction requires sort" });
export const runIdSchema = z.object({ runId: z.string().min(1) });
export const renameRunSchema = z.object({ name: z.string().trim().min(1).max(120), expectedName: z.string().max(120).optional() }).strict();

const webhookCommonSchema = z.object({ conditions: conditionsSchema.optional() });
export const webhookTriggerConfigSchema = z.discriminatedUnion("provider", [
Expand Down
14 changes: 11 additions & 3 deletions packages/api/src/flow-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { z } from "zod";
import { runPageResponse, fullRunResponse } from "./run-read-contracts";
import { triggerContextCatalog } from "./trigger-context";
import type { Env, OAuthProps } from "./types";
import { canonicalizeVmImage, vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas";
import { canonicalizeVmImage, renameRunSchema, vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas";
import { nextOccurrence, validateScheduleConfig } from "./schedule";
import { databaseFor } from "./postgres/database";
import { IdentityRepository } from "./postgres/identity-repository";
Expand Down Expand Up @@ -51,7 +51,7 @@ export function publicValue<T extends z.ZodType>(schema: T, value: unknown): z.o
return result.data;
}

type Scope = "projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write";
type Scope = "projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "runs:write" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write";

export class ApiService {
authorizationDuration = 0;
Expand Down Expand Up @@ -157,6 +157,14 @@ export class ApiService {
async search(query: string) { await this.authorize("jobs:read"); await this.authorize("runs:read"); return publicValue(searchResponse, await new OperationsRepository(databaseFor(this.env), this.auth.tenantId).search(query)); }
async triggerContextMetadata() { await this.authorize("jobs:read"); return triggerContextCatalog; }
async getRunStatus(runId: string) { await this.authorize("runs:read"); const status = await new RunQueryRepository(databaseFor(this.env), this.auth.tenantId).status(runId); if (!status) throw new ServiceError(404, "not_found", "Run not found"); return status; }
async renameRun(runId: string, input: unknown) {
await this.authorize("runs:write");
const parsed = renameRunSchema.parse(input);
const result = await new RunQueryRepository(databaseFor(this.env), this.auth.tenantId).rename(runId, parsed.name, parsed.expectedName);
if (result === "missing") throw new ServiceError(404, "not_found", "Run not found");
if (result === "stale") throw new ServiceError(409, "stale_run_name", "This run was renamed. Reload it before saving.");
return result;
}
async getRevisionTrace(runId: string, after: number, limit: number, revision?: string, before?: number, recent = false) { await this.authorize("runs:read"); const page = await new TraceRepository(databaseFor(this.env), this.auth.tenantId).revisionPage(runId, after, limit, revision, before, recent); if (!page) throw new ServiceError(404, "not_found", "Run not found"); return page; }
async getRun(runId: string) {
await this.authorize("runs:read");
Expand Down Expand Up @@ -349,6 +357,6 @@ export class ApiService {
}
async revokeAuthorizedClient(clientId: string) { await this.authorizeOwnerSession("jobs:write"); if (!this.env.OAUTH_PROVIDER) throw new ServiceError(503, "operation_failed", "OAuth management unavailable"); const grants: any[] = []; let cursor: string | undefined; do { const page = await this.env.OAUTH_PROVIDER.listUserGrants(this.auth.userId, { limit: 100, cursor }); grants.push(...page.items.filter(grant => grant.clientId === clientId)); cursor = page.cursor; } while (cursor); await Promise.all(grants.map(grant => this.env.OAUTH_PROVIDER!.revokeGrant(grant.id, this.auth.userId))); return { revoked: grants.length }; }
async listAccessTokens() { await this.authorizeOwnerSession("jobs:read"); return publicValue(z.array(accessToken), (await new AccessTokenRepository(databaseFor(this.env), this.auth.tenantId).list()).map(token => ({ ...token, created_at: new Date(token.created_at).toISOString(), expires_at: new Date(token.expires_at).toISOString(), last_used_at: token.last_used_at ? new Date(token.last_used_at).toISOString() : null, revoked_at: token.revoked_at ? new Date(token.revoked_at).toISOString() : null, scopes: normalizeScopes(token.scopes) }))); }
async createAccessToken(input: any) { await this.authorizeOwnerSession("jobs:write"); const name = typeof input?.name === "string" ? input.name.trim() : "", requested: string[] = Array.isArray(input?.scopes) ? [...new Set<string>(input.scopes.filter((scope: unknown): scope is string => typeof scope === "string"))] : [], legacy = new Set(["flows:read", "flows:write", "runs:write"]), scopes = normalizeScopes(requested), expiryDays = Number(input?.expiryDays); if (!name || name.length > 100 || !requested.length || requested.some(scope => !ACCESS_SCOPES.includes(scope as any) && !legacy.has(scope)) || !scopes.length || ![7, 30, 90].includes(expiryDays)) throw new ServiceError(400, "invalid_request", "A name, supported scopes, and expiryDays of 7, 30, or 90 are required."); const token = issueAccessToken(this.auth.tenantId), expiresAt = new Date(Date.now() + expiryDays * 86_400_000).toISOString(); const metadata = await new AccessTokenRepository(databaseFor(this.env), this.auth.tenantId).create({ name, scopes, expiresAt, digest: await accessTokenDigest(token), userId: this.auth.userId, sessionVersion: this.auth.sessionVersion }); if (!metadata) throw new ServiceError(401, "invalid_token", "The owner session is no longer active."); return publicValue(createdAccessToken, JSON.parse(JSON.stringify({ ...metadata, token }))); }
async createAccessToken(input: any) { await this.authorizeOwnerSession("jobs:write"); const name = typeof input?.name === "string" ? input.name.trim() : "", requested: string[] = Array.isArray(input?.scopes) ? [...new Set<string>(input.scopes.filter((scope: unknown): scope is string => typeof scope === "string"))] : [], legacy = new Set(["flows:read", "flows:write"]), scopes = normalizeScopes(requested), expiryDays = Number(input?.expiryDays); if (!name || name.length > 100 || !requested.length || requested.some(scope => !ACCESS_SCOPES.includes(scope as any) && !legacy.has(scope)) || !scopes.length || ![7, 30, 90].includes(expiryDays)) throw new ServiceError(400, "invalid_request", "A name, supported scopes, and expiryDays of 7, 30, or 90 are required."); const token = issueAccessToken(this.auth.tenantId), expiresAt = new Date(Date.now() + expiryDays * 86_400_000).toISOString(); const metadata = await new AccessTokenRepository(databaseFor(this.env), this.auth.tenantId).create({ name, scopes, expiresAt, digest: await accessTokenDigest(token), userId: this.auth.userId, sessionVersion: this.auth.sessionVersion }); if (!metadata) throw new ServiceError(401, "invalid_token", "The owner session is no longer active."); return publicValue(createdAccessToken, JSON.parse(JSON.stringify({ ...metadata, token }))); }
async revokeAccessToken(id: string) { await this.authorizeOwnerSession("jobs:write"); if (!await new AccessTokenRepository(databaseFor(this.env), this.auth.tenantId).revoke(id)) throw new ServiceError(404, "not_found", "Access token not found"); return { revoked: true }; }
}
14 changes: 14 additions & 0 deletions packages/api/src/postgres/run-query-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ export class RunQueryRepository {
if (!row) return null;
return { ...row, created_at: row.created_at.toISOString(), updated_at: row.updated_at.toISOString(), started_at: row.started_at?.toISOString() ?? null };
}
async rename(runId: string, name: string, expectedName?: string): Promise<"missing" | "stale" | { run_name: string }> {
return this.database.transaction(async client => {
// Lifecycle writers lock job_runs before runs, so use the same order to
// avoid deadlocks while renames race state transitions.
const jobRun = (await client.query("SELECT id FROM app.job_runs WHERE tenant_id=$1 AND id=$2 FOR UPDATE", [this.tenantId, runId])).rows[0];
if (!jobRun) return "missing";
const current = (await client.query<{ run_name: string }>("SELECT run_name FROM app.runs WHERE tenant_id=$1 AND id=$2 FOR UPDATE", [this.tenantId, runId])).rows[0];
if (!current) return "missing";
if (expectedName !== undefined && current.run_name !== expectedName) return "stale";
await client.query("UPDATE app.runs SET run_name=$3 WHERE tenant_id=$1 AND id=$2", [this.tenantId, runId, name]);
await client.query("UPDATE app.job_runs SET run_name=$3 WHERE tenant_id=$1 AND id=$2", [this.tenantId, runId, name]);
return { run_name: name };
});
}
async activity(runId: string) { return (await this.database.pool.query("SELECT action,detail,created_at FROM app.run_activity WHERE tenant_id=$1 AND run_id=$2 ORDER BY created_at,id", [this.tenantId, runId])).rows; }
async stop(runId: string) {
return this.database.transaction(async client => {
Expand Down
Loading
Loading