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: 9 additions & 0 deletions .changeset/org-subject-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@taskless/cli": minor
---

Send the acting organization's identity on every write request. The CLI now resolves which Taskless org owns the current repository by matching the repo's git remotes (`origin` → `upstream` → rest) against the canonical owner URLs returned by `whoami`, and sends that org's Taskless UUID as the subject on rule generation, iterate, and reconcile calls. This fixes multi-org users being routed to whichever org their token happened to pin; the server authorizes the chosen org per request. When no remote matches a known org, the CLI falls back to the token's numeric `orgId` claim, so single-org behavior is unchanged.
Comment thread
theCodeDrift marked this conversation as resolved.

The client-side owner-URL canonicalizer is a verbatim port of the server's shared implementation, so both sides compare by exact string equality across SSH, `ssh://`/`git://`, port, and `www.` remote forms.

`rule create`/`rule improve` now handle two additional generation states: `classifying` (a transient pre-build phase) and `unsupported`, a terminal state emitted when the request needs a capability the organization's plan doesn't include (for example, runtime rules) — surfaced with a clear message and the new `RULE_UNSUPPORTED` error code. When the server can't act on a repository for the selected org (its GitHub App installation doesn't cover the repo, or membership changed), the CLI now explains the coverage cause rather than only suggesting re-authentication.
14 changes: 10 additions & 4 deletions packages/cli/src/api/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { CLI_VERSION, CLI_VERSION_HEADER } from "../version";

/**
* Server-owned rule reconciliation (TSKL-270). The CLI reports the rule files
* it holds and the server returns the exact subset that may run. This endpoint
* is not in the generated schema (it may not be deployed everywhere yet), so it
* is called with a hand-typed request over plain `fetch`; migrate it onto the
* typed client once it lands in `GET /cli/api/__schema`.
* it holds and the server returns the exact subset that may run. The endpoint
* is now in the generated schema, but this stays on hand-typed plain `fetch`
* for its degradation contract (`ReconcileOutcome` never throws for expected
* network/auth/deployment conditions, so `check` falls back to a local scan);
* migrating it onto the typed client would mean re-expressing that handling.
*/

/** A rule file reported for reconciliation. */
Expand All @@ -16,6 +17,11 @@ export interface ReportedFile {
}

export interface ReconcileRequest {
/**
* Org subject: Taskless UUID (preferred) or numeric GitHub org id. Optional —
* the server falls back to the deprecated token claim when it is absent.
*/
orgId?: string | number;
repositoryUrl: string;
files: ReportedFile[];
}
Expand Down
32 changes: 24 additions & 8 deletions packages/cli/src/api/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,32 @@ function parseErrorBody(rawError: unknown): Record<string, unknown> {
return {};
}

/**
* The server returns the same 404 `organization_not_found` whether the org
* isn't yours or its GitHub App installation doesn't cover this repository (it
* deliberately doesn't distinguish, to avoid leaking org existence), so the
* message names both causes — coverage first, since a resolved org subject
* makes membership the less likely one.
*/
function orgNotFoundMessage(): string {
return [
"Taskless could not act on this repository for your organization.",
"",
"Most often the organization's Taskless GitHub App installation does not cover this repository. It can also mean your login no longer has access to the organization.",
"",
"- Confirm the Taskless app is installed on this repository's owner and includes this repository.",
`- If access recently changed, re-authenticate with \`${getCliPrefix()} auth login\`.`,
].join("\n");
}

// --- API functions ---

/** Submit a new rule generation request */
export async function submitRule(
token: string,
request: {
orgId: number;
/** Org subject: Taskless UUID (preferred) or numeric GitHub org id. */
orgId: string | number;
repositoryUrl: string;
prompt: string;
successCases?: string[];
Expand Down Expand Up @@ -65,9 +84,7 @@ export async function submitRule(
response.status === 404 &&
errorData.error === "organization_not_found"
) {
throw new Error(
`Organization not found. Try running \`${getCliPrefix()} auth login\` to re-authenticate.`
);
throw new Error(orgNotFoundMessage());
}
throw new Error(
`Request submission failed (HTTP ${String(response.status)})`
Expand Down Expand Up @@ -103,7 +120,8 @@ export async function iterateRule(
token: string,
ruleId: string,
request: {
orgId: number;
/** Org subject: Taskless UUID (preferred) or numeric GitHub org id. */
orgId: string | number;
guidance: string;
references?: Array<{ filename: string; content: string }>;
}
Expand Down Expand Up @@ -133,9 +151,7 @@ export async function iterateRule(
response.status === 404 &&
errorData.error === "organization_not_found"
) {
throw new Error(
`Organization not found. Try running \`${getCliPrefix()} auth login\` to re-authenticate.`
);
throw new Error(orgNotFoundMessage());
}
throw new Error(`Iterate request failed (HTTP ${String(response.status)})`);
}
Expand Down
26 changes: 16 additions & 10 deletions packages/cli/src/auth/identity.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { getToken } from "./token";
import { decodeOrgId } from "./jwt";
import { resolveOrgSubject } from "./org";
import { resolveRepositoryUrl } from "../util/git-remote";
import { getCliPrefix } from "../util/package-manager";

export interface Identity {
token: string;
orgId: number;
/**
* Org subject to send on write calls: the current org's Taskless UUID
* (preferred) or the deprecated numeric `orgId` claim. See `resolveOrgSubject`.
*/
orgSubject: string | number;
repositoryUrl: string;
}

/**
* Resolve the current user's identity from JWT claims and git remote.
* - orgId: extracted from the JWT's orgId claim
* Resolve the current user's identity for a write call.
* - orgSubject: the current org's Taskless UUID matched from `whoami` + the
* repo's remotes, falling back to the token's deprecated numeric `orgId` claim
* - repositoryUrl: inferred from `git remote get-url origin`
*
* Throws if auth is missing, the JWT lacks orgId, or the git remote is unavailable.
* Throws if auth is missing, no org subject can be determined, or the git
* remote is unavailable.
*/
export async function resolveIdentity(cwd: string): Promise<Identity> {
const token = await getToken(cwd);
Expand All @@ -24,14 +30,14 @@ export async function resolveIdentity(cwd: string): Promise<Identity> {
);
}

const orgId = decodeOrgId(token);
if (orgId === undefined) {
const repositoryUrl = await resolveRepositoryUrl(cwd);

const orgSubject = await resolveOrgSubject(cwd, token);
if (orgSubject === undefined) {
throw new Error(
`Your auth token is missing organization info. Run \`${getCliPrefix()} auth login\` to re-authenticate.`
);
}

const repositoryUrl = await resolveRepositoryUrl(cwd);

return { token, orgId, repositoryUrl };
return { token, orgSubject, repositoryUrl };
}
71 changes: 71 additions & 0 deletions packages/cli/src/auth/org.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { paths } from "../generated/api";
import { decodeOrgId } from "./jwt";
import { fetchWhoami } from "./whoami";
import { listRemoteOwnerUrls } from "../util/git-remote";

type WhoamiData =
paths["/cli/api/whoami"]["get"]["responses"]["200"]["content"]["application/json"];

/**
* One organization from `GET /cli/api/whoami`, derived from the generated
* OpenAPI schema. `orgId` is the numeric GitHub org id; `id` is the Taskless
* org UUID — the subject the CLI acts as on write calls; `url` is the canonical
* OWNER url (e.g. `https://github.com/acme`) matched against the repo's remotes.
*/
export type WhoamiOrg = WhoamiData["orgs"][number];

/**
* Pick the acting org from a whoami org list given the repo's owner urls (in
* `origin` → `upstream` → rest precedence). Exact `url` equality against
* GitHub-sourced orgs; the first remote that matches an org wins. Returns
* `undefined` when nothing matches (no current-org context).
*/
export function selectOrgForOwners(
ownerUrls: string[],
orgs: WhoamiOrg[]
): WhoamiOrg | undefined {
const byUrl = new Map(
orgs.filter((org) => org.source === "github").map((org) => [org.url, org])
);
for (const ownerUrl of ownerUrls) {
const org = byUrl.get(ownerUrl);
if (org) return org;
}
return undefined;
}

/**
* Resolve which org the CLI acts as for the repo at `cwd`: match the repo's
* local git remotes against `orgs[].url`. Returns `undefined` when there is no
* GitHub remote or no org owns it — the caller then has no current-org context
* (and any write it attempts is authorized, and may be denied, server-side).
*/
export async function resolveCurrentOrg(
cwd: string,
orgs: WhoamiOrg[]
): Promise<WhoamiOrg | undefined> {
const ownerUrls = await listRemoteOwnerUrls(cwd);
return selectOrgForOwners(ownerUrls, orgs);
}

/**
* The org subject to send on write calls. Prefers the current org's Taskless
* UUID (`id`), resolved by matching the repo's remotes against `whoami`; falls
* back to the deprecated numeric `orgId` claim in the token when whoami is
* unavailable or no org owns the repo. A new client thus routes multi-org users
* correctly, while older single-org behaviour is preserved via the claim.
*
* Returns `undefined` only when there is neither a matched org nor a claim
* (a broken or pre-org token) — the caller has no subject to send.
*/
export async function resolveOrgSubject(
cwd: string,
token: string
): Promise<string | number | undefined> {
const whoami = await fetchWhoami(token);
if (whoami && whoami.orgs.length > 0) {
const org = await resolveCurrentOrg(cwd, whoami.orgs);
if (org) return org.id;
}
return decodeOrgId(token);
}
3 changes: 3 additions & 0 deletions packages/cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getTelemetry } from "../telemetry";
import { outputSchema as checkOutputSchema } from "../schemas/check";
import { makeErrorEnvelope } from "../types/errors";
import { getToken } from "../auth/token";
import { resolveOrgSubject } from "../auth/org";
import { resolveRepositoryUrl } from "../util/git-remote";
import { getCliPrefix } from "../util/package-manager";
import { reconcile } from "../api/reconcile";
Expand Down Expand Up @@ -184,7 +185,9 @@ async function planRuntime(
reason: "its check.ts is missing or unreadable",
}));

const orgSubject = await resolveOrgSubject(cwd, token);
const outcome = await reconcile(token, {
orgId: orgSubject,
repositoryUrl,
files: reportRuntimeChecks(cwd, signed),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const infoCommand = defineCommand({
args.anonymous ? Promise.resolve() : getToken(cwd),
]);

let auth: { user: string; email: string; orgs: string[] } | undefined;
let auth: { user: string; email?: string; orgs: string[] } | undefined;
if (!args.anonymous && token) {
const whoami = await fetchWhoami(token);
if (whoami) {
Expand Down
34 changes: 32 additions & 2 deletions packages/cli/src/commands/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ function getTimestamp(): string {

const POLL_INTERVAL_MS = 15_000;

/**
* `unsupported` is a terminal status: the request asked for a rule generation
* the account can't access — e.g. runtime rules that aren't enabled on the
* current plan. It is not a transient failure to retry; the plan or entitlement
* has to change first.
*/
function unsupportedMessage(): string {
return [
"This rule generation isn't available on your current Taskless plan.",
"",
"It may need a capability that isn't enabled for your organization yet (for example, runtime rules). Ask your Taskless administrator or upgrade your plan to enable it.",
].join("\n");
}

const createCommand = defineCommand({
meta: {
name: "create",
Expand Down Expand Up @@ -163,7 +177,7 @@ const createCommand = defineCommand({
let ruleId: string;
try {
const response = await submitRule(identity.token, {
orgId: identity.orgId,
orgId: identity.orgSubject,
repositoryUrl: identity.repositoryUrl,
prompt: request.prompt,
successCases: request.successCases,
Expand Down Expand Up @@ -198,10 +212,18 @@ const createCommand = defineCommand({
console.error("Status: accepted — waiting for processing...");
break;
}
case "classifying": {
console.error("Status: classifying — analyzing your request...");
break;
}
case "building": {
console.error("Status: building — generating rules...");
break;
}
case "unsupported": {
fail(unsupportedMessage(), "RULE_UNSUPPORTED");
break;
}
case "failed": {
fail(
`Rule generation failed: ${status.error}`,
Expand Down Expand Up @@ -396,7 +418,7 @@ const improveCommand = defineCommand({
let requestId: string;
try {
const response = await iterateRule(identity.token, request.ruleId, {
orgId: identity.orgId,
orgId: identity.orgSubject,
guidance: request.guidance,
references: request.references,
});
Expand Down Expand Up @@ -431,10 +453,18 @@ const improveCommand = defineCommand({
console.error("Status: accepted — waiting for processing...");
break;
}
case "classifying": {
console.error("Status: classifying — analyzing your request...");
break;
}
case "building": {
console.error("Status: building — generating rules...");
break;
}
case "unsupported": {
fail(unsupportedMessage(), "RULE_UNSUPPORTED");
break;
}
case "failed": {
fail(
`Rule iteration failed: ${status.error}`,
Expand Down
Loading
Loading