diff --git a/.changeset/org-identity-forward-compat.md b/.changeset/org-identity-forward-compat.md new file mode 100644 index 0000000..e8b0e50 --- /dev/null +++ b/.changeset/org-identity-forward-compat.md @@ -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. diff --git a/packages/cli/src/auth/identity.ts b/packages/cli/src/auth/identity.ts index 95fdebd..9a76717 100644 --- a/packages/cli/src/auth/identity.ts +++ b/packages/cli/src/auth/identity.ts @@ -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 { const token = await getToken(cwd); @@ -31,13 +31,7 @@ export async function resolveIdentity(cwd: string): Promise { } 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 }; } diff --git a/packages/cli/src/auth/jwt.ts b/packages/cli/src/auth/jwt.ts index c0be8d7..b769819 100644 --- a/packages/cli/src/auth/jwt.ts +++ b/packages/cli/src/auth/jwt.ts @@ -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; } diff --git a/packages/cli/src/auth/org.ts b/packages/cli/src/auth/org.ts index 789e5f1..8902c5d 100644 --- a/packages/cli/src/auth/org.ts +++ b/packages/cli/src/auth/org.ts @@ -1,5 +1,5 @@ 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"; @@ -7,12 +7,30 @@ 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 @@ -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 { +): Promise { 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; } diff --git a/packages/cli/src/schemas/rules-meta.ts b/packages/cli/src/schemas/rules-meta.ts index 2d2f5f5..a5c220f 100644 --- a/packages/cli/src/schemas/rules-meta.ts +++ b/packages/cli/src/schemas/rules-meta.ts @@ -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"), }); diff --git a/packages/cli/src/telemetry.ts b/packages/cli/src/telemetry.ts index d2e03fb..6f1a7b0 100644 --- a/packages/cli/src/telemetry.ts +++ b/packages/cli/src/telemetry.ts @@ -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"; @@ -145,7 +145,11 @@ export async function getTelemetry(cwd?: string): Promise { // 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) { @@ -154,7 +158,7 @@ export async function getTelemetry(cwd?: string): Promise { distinctId = sub; anonymous = false; } - orgId = decodeOrgId(token); + orgSubject = decodeOrgId(token) ?? NIL_ORG_ID; } const scaffoldVersion = await resolveScaffoldVersion(cwd); @@ -176,10 +180,10 @@ export async function getTelemetry(cwd?: string): Promise { }); // 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), }); } @@ -196,8 +200,8 @@ export async function getTelemetry(cwd?: string): Promise { cliVersion: CLI_VERSION, scaffoldVersion, }, - ...(!anonymous && orgId !== undefined - ? { groups: { organization: String(orgId) } } + ...(!anonymous && orgSubject !== undefined + ? { groups: { organization: String(orgSubject) } } : {}), }); } catch { diff --git a/packages/cli/test/jwt.test.ts b/packages/cli/test/jwt.test.ts index 0f6b755..64d5d50 100644 --- a/packages/cli/test/jwt.test.ts +++ b/packages/cli/test/jwt.test.ts @@ -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"; @@ -10,18 +10,41 @@ function makeJwt(payload: Record): 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", () => { + 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(); }); @@ -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"); + }); +}); diff --git a/packages/cli/test/org.test.ts b/packages/cli/test/org.test.ts index 2efda1a..2fea810 100644 --- a/packages/cli/test/org.test.ts +++ b/packages/cli/test/org.test.ts @@ -32,10 +32,9 @@ const WHOAMI_UNAVAILABLE = undefined as Awaited>; // "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; @@ -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" + ); }); }); diff --git a/packages/cli/test/telemetry.test.ts b/packages/cli/test/telemetry.test.ts index 76fe446..01aabb6 100644 --- a/packages/cli/test/telemetry.test.ts +++ b/packages/cli/test/telemetry.test.ts @@ -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");