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
32 changes: 32 additions & 0 deletions .changeset/org-identity-forward-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@taskless/cli": patch
---

Treat the Taskless organization UUID as the one canonical identity, and become
forward-compatible with the server's coming identity cleanup.

- **Stop consuming `installationId`.** It is dropped from the `WhoamiOrg` type
and from the `taskless rule meta --json` output (it was already optional and
absent for public repos). The CLI never used it and it should not round-trip
through us.
- **Namespace the GitHub org id.** `WhoamiOrg.orgId` is now optional and a new
`githubOrgId?: number` is added, so a consumer reads `githubOrgId ?? orgId`
and keeps working across the server's rename. It is a convenience id, never an
identity.
- **Identify on the canonical id.** `decodeOrgId` validates the token's `id`
claim (a UUID string) and the legacy `orgId` claim (numeric, or a numeric
string) independently: a valid `id` wins, an invalid `id` still lets a valid
`orgId` through, and a non-numeric `orgId` is rejected rather than smuggled in
as an identity. PostHog then groups organizations on that canonical id. Tokens
that don't yet carry an `id` claim fall back to the numeric claim, so grouping
is unchanged until the server starts sending it.
- **Always have a known org id.** When neither a matched org nor a token claim
resolves, the canonical id falls back to the nil UUID
(`00000000-0000-0000-0000-000000000000`) instead of being absent — so the org
subject and telemetry group are always a stable, known value and unattributed
usage lands in one bucket. As a result, a write from a token missing org info
now sends the nil-UUID subject rather than failing with a re-authenticate
error.
- **Tolerate `number | string` on the legacy path.** The canonical `id` stays a
UUID `string`, but `decodeOrgId` accepts either type since we can't promise
what a legacy claim carries.
12 changes: 3 additions & 9 deletions packages/cli/src/auth/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ export interface Identity {
/**
* 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
* repo's remotes, falling back to the token's canonical id claim, and finally
* to the nil-UUID `NIL_ORG_ID` so a subject is always present
* - repositoryUrl: inferred from `git remote get-url origin`
*
* Throws if auth is missing, no org subject can be determined, or the git
* remote is unavailable.
* Throws if auth is missing or the git remote is unavailable.
*/
export async function resolveIdentity(cwd: string): Promise<Identity> {
const token = await getToken(cwd);
Expand All @@ -31,13 +31,7 @@ export async function resolveIdentity(cwd: string): Promise<Identity> {
}

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.`
);
}

return { token, orgSubject, repositoryUrl };
}
46 changes: 36 additions & 10 deletions packages/cli/src/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,45 @@
import { decodeJwt } from "jose";

/**
* Decode the `orgId` claim from a JWT.
* Returns `undefined` if the token is not a valid JWT or lacks the claim.
* No signature verification is performed — the server validates on API calls.
* Known fallback for the canonical org id — the nil UUID. Used when a token
* carries no resolvable org so the canonical id is never missing; downstream
* (the API org subject, PostHog grouping) always has a stable, known value
* rather than `undefined`, and "unattributed" usage lands in one known bucket.
*/
export function decodeOrgId(token: string): number | undefined {
export const NIL_ORG_ID = "00000000-0000-0000-0000-000000000000";

/**
* Decode the canonical organization id from a JWT.
*
* `id` is the canonical, stable Taskless org id — a UUID string. The legacy
* numeric `orgId` claim is the fallback for tokens minted before the server
* namespaced its claims. The two are validated INDEPENDENTLY:
* - a valid `id` (a non-empty string) wins;
* - otherwise a valid `orgId` (a finite number, or a numeric string, which is
* coerced) is used — a non-numeric `orgId` is rejected, never smuggled in as
* an identity, and an invalid `id` (e.g. `""`) does not block this fallback.
*
* Returns `undefined` when neither claim is usable (or the token is not a JWT);
* callers apply `?? NIL_ORG_ID` for the known fallback. No signature
* verification is performed — the server validates on API calls.
*/
export function decodeOrgId(token: string): string | number | undefined {
let claims: { id?: unknown; orgId?: unknown };
try {
const claims = decodeJwt(token);
const orgId = claims.orgId;
if (typeof orgId === "number" && Number.isFinite(orgId)) {
return orgId;
}
return undefined;
claims = decodeJwt(token) as { id?: unknown; orgId?: unknown };
} catch {
return undefined;
}
// Canonical id — a non-empty UUID string.
if (typeof claims.id === "string" && claims.id.length > 0) {
return claims.id;
}
// Legacy GitHub org id — numeric only (a numeric string is coerced).
if (typeof claims.orgId === "number" && Number.isFinite(claims.orgId)) {
return claims.orgId;
}
if (typeof claims.orgId === "string" && /^\d+$/.test(claims.orgId)) {
return Number(claims.orgId);
}
return undefined;
}
46 changes: 33 additions & 13 deletions packages/cli/src/auth/org.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
import type { paths } from "../generated/api";
import { decodeOrgId } from "./jwt";
import { decodeOrgId, NIL_ORG_ID } 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.
* One organization from `GET /cli/api/whoami`. Adapts the generated OpenAPI
* shape to what the CLI actually acts on, staying forward-compatible with the
* server's coming identity cleanup:
*
* - `id` — the canonical, stable, platform-agnostic Taskless org id and the
* only subject the CLI acts on. A UUID string, so kept as the generated
* `string` (the `string | number` tolerance lives on the legacy JWT-claim
* path in `decodeOrgId`, where a numeric id can still appear).
* - `githubOrgId` — GitHub's numeric org id, a convenience only. The server is
* renaming the legacy `orgId` to this; read `githubOrgId ?? orgId` so either
* spelling resolves during the transition.
* - `installationId` is intentionally dropped — the CLI never uses it and it
* should not be sent to us.
*
* `url` is the canonical OWNER url (e.g. `https://github.com/acme`) matched
* against the repo's remotes.
*/
export type WhoamiOrg = WhoamiData["orgs"][number];
export type WhoamiOrg = Omit<
WhoamiData["orgs"][number],
"orgId" | "installationId"
> & {
orgId?: number;
githubOrgId?: number;
};

/**
* Pick the acting org from a whoami org list given the repo's owner urls (in
Expand Down Expand Up @@ -51,21 +69,23 @@ export async function resolveCurrentOrg(
/**
* 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.
* back to the token's canonical id (or its deprecated numeric `orgId` claim)
* 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.
* Never `undefined`: when neither a matched org nor a token claim resolves,
* returns the nil-UUID `NIL_ORG_ID` so the canonical subject is always known
* (unattributed usage is sent under one stable, known org id).
*/
export async function resolveOrgSubject(
cwd: string,
token: string
): Promise<string | number | undefined> {
): Promise<string | number> {
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);
return decodeOrgId(token) ?? NIL_ORG_ID;
}
4 changes: 0 additions & 4 deletions packages/cli/src/schemas/rules-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ import { z } from "zod";
export const outputSchema = z.object({
id: z.string().describe("Rule ID"),
ticketId: z.string().describe("Ticket ID that produced this rule"),
installationId: z
.string()
.optional()
.describe("GitHub App installation ID (absent for public repos)"),
generatedAt: z.string().describe("ISO 8601 generation timestamp"),
schemaVersion: z.string().describe("Sidecar schema version"),
});
Expand Down
18 changes: 11 additions & 7 deletions packages/cli/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { join } from "node:path";
import { PostHog } from "posthog-node";
import { decodeJwt } from "jose";

import { decodeOrgId } from "./auth/jwt";
import { decodeOrgId, NIL_ORG_ID } from "./auth/jwt";
import { getConfigDirectory, getToken } from "./auth/token";
import { CLI_VERSION } from "./version";

Expand Down Expand Up @@ -145,7 +145,11 @@ export async function getTelemetry(cwd?: string): Promise<TelemetryClient> {
// Resolve identity: prefer JWT subject, fall back to anonymous ID
let distinctId = anonymousId;
let anonymous = true;
let orgId: number | undefined;
// The org's canonical id — the field we always identify on. May be a string
// (UUID) or a number, so it is stringified only at the group boundary. When
// logged in but no org resolves, it falls back to the known nil-UUID so
// authenticated events always carry a stable org group.
let orgSubject: string | number | undefined;

const token = await getToken(cwd, { silent: true });
if (token) {
Expand All @@ -154,7 +158,7 @@ export async function getTelemetry(cwd?: string): Promise<TelemetryClient> {
distinctId = sub;
anonymous = false;
}
orgId = decodeOrgId(token);
orgSubject = decodeOrgId(token) ?? NIL_ORG_ID;
}

const scaffoldVersion = await resolveScaffoldVersion(cwd);
Expand All @@ -176,10 +180,10 @@ export async function getTelemetry(cwd?: string): Promise<TelemetryClient> {
});

// Group identify for authenticated users with an org
if (!anonymous && orgId !== undefined) {
if (!anonymous && orgSubject !== undefined) {
posthog.groupIdentify({
groupType: "organization",
groupKey: String(orgId),
groupKey: String(orgSubject),
});
}

Expand All @@ -196,8 +200,8 @@ export async function getTelemetry(cwd?: string): Promise<TelemetryClient> {
cliVersion: CLI_VERSION,
scaffoldVersion,
},
...(!anonymous && orgId !== undefined
? { groups: { organization: String(orgId) } }
...(!anonymous && orgSubject !== undefined
? { groups: { organization: String(orgSubject) } }
: {}),
});
} catch {
Expand Down
39 changes: 34 additions & 5 deletions packages/cli/test/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { decodeOrgId } from "../src/auth/jwt";
import { decodeOrgId, NIL_ORG_ID } from "../src/auth/jwt";

// Minimal unsigned JWTs for testing (header: {"alg":"none","typ":"JWT"})
const HEADER = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
Expand All @@ -10,18 +10,41 @@ function makeJwt(payload: Record<string, unknown>): string {
}

describe("decodeOrgId", () => {
it("returns numeric orgId from a valid JWT", () => {
it("returns the legacy numeric orgId claim", () => {
expect(decodeOrgId(makeJwt({ orgId: 123 }))).toBe(123);
});

it("returns undefined for a JWT missing orgId", () => {
expect(decodeOrgId(makeJwt({ sub: "user-123" }))).toBeUndefined();
it("coerces a numeric-string orgId claim", () => {
expect(decodeOrgId(makeJwt({ orgId: "123" }))).toBe(123);
});

it("returns undefined for a JWT with non-numeric orgId", () => {
it("rejects a non-numeric orgId (never smuggled in as an identity)", () => {
expect(decodeOrgId(makeJwt({ orgId: "not-a-number" }))).toBeUndefined();
});

it("prefers the canonical `id` claim over the legacy orgId", () => {
Comment thread
thecodedrift marked this conversation as resolved.
expect(decodeOrgId(makeJwt({ id: "org-uuid", orgId: 123 }))).toBe(
"org-uuid"
);
});

it("accepts a string id (a UUID)", () => {
expect(decodeOrgId(makeJwt({ id: "org-uuid" }))).toBe("org-uuid");
});

it("falls back to a valid orgId when the id claim is invalid (empty)", () => {
// `??` would keep "" — independent validation lets the legacy orgId through.
expect(decodeOrgId(makeJwt({ id: "", orgId: 123 }))).toBe(123);
});

it("ignores a non-string id claim and falls back to orgId", () => {
expect(decodeOrgId(makeJwt({ id: 4242, orgId: 123 }))).toBe(123);
});

it("returns undefined for a JWT carrying neither id nor orgId", () => {
expect(decodeOrgId(makeJwt({ sub: "user-123" }))).toBeUndefined();
});

it("returns undefined for an invalid token string", () => {
expect(decodeOrgId("not-a-jwt")).toBeUndefined();
});
Expand All @@ -30,3 +53,9 @@ describe("decodeOrgId", () => {
expect(decodeOrgId("")).toBeUndefined();
});
});

describe("NIL_ORG_ID", () => {
it("is the nil UUID", () => {
expect(NIL_ORG_ID).toBe("00000000-0000-0000-0000-000000000000");
});
});
11 changes: 5 additions & 6 deletions packages/cli/test/org.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,9 @@ const WHOAMI_UNAVAILABLE = undefined as Awaited<ReturnType<typeof fetchWhoami>>;
// "github"; the source-filter test deliberately injects another provider.
const org = (id: string, url: string, source = "github"): WhoamiOrg =>
({
orgId: 1,
githubOrgId: 1,
id,
name: url.split("/").pop() ?? "",
installationId: 1,
source,
url,
}) as WhoamiOrg;
Expand Down Expand Up @@ -116,10 +115,10 @@ describe("resolveOrgSubject", () => {
expect(await resolveOrgSubject(directory, token)).toBe(4242);
});

it("returns undefined when whoami is unavailable and the token has no claim", async () => {
it("falls back to the nil-UUID when whoami is unavailable and the token has no org claim", async () => {
mockedFetchWhoami.mockResolvedValue(WHOAMI_UNAVAILABLE);
expect(
await resolveOrgSubject(directory, makeJwt({ sub: "u" }))
).toBeUndefined();
expect(await resolveOrgSubject(directory, makeJwt({ sub: "u" }))).toBe(
"00000000-0000-0000-0000-000000000000"
);
});
});
34 changes: 34 additions & 0 deletions packages/cli/test/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,40 @@ describe("authenticated identity", () => {
}
});

it("groups on the canonical `id` claim when present, not the legacy orgId", async () => {
const cwd = await mkdtemp(join(tmpdir(), "taskless-auth-test-"));
try {
const jwt = makeJwt({ sub: "user-789", id: "org-uuid", orgId: 99 });
await writeTokenFile(cwd, jwt);

await getTelemetry(cwd);

expect(mockGroupIdentify).toHaveBeenCalledWith({
groupType: "organization",
groupKey: "org-uuid",
});
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

it("groups a logged-in user with no org on the nil-UUID", async () => {
const cwd = await mkdtemp(join(tmpdir(), "taskless-auth-test-"));
try {
const jwt = makeJwt({ sub: "user-000" });
await writeTokenFile(cwd, jwt);

await getTelemetry(cwd);

expect(mockGroupIdentify).toHaveBeenCalledWith({
groupType: "organization",
groupKey: "00000000-0000-0000-0000-000000000000",
});
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

it("falls back to anonymous UUID when no JWT is available", async () => {
const telemetry = await getTelemetry();
telemetry.capture("cli_run");
Expand Down
Loading