Skip to content

Commit f3bf618

Browse files
authored
feat(auth): carry the acting org's restoreRules entitlement from whoami
1 parent ed40a96 commit f3bf618

5 files changed

Lines changed: 136 additions & 30 deletions

File tree

‎packages/cli/src/auth/identity.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getToken } from "./token";
2-
import { resolveOrgSubject } from "./org";
2+
import { resolveActingOrg } from "./org";
33
import { CLIError } from "../util/cli-error";
44
import { resolveRepositoryUrl } from "../util/git-remote";
55
import { getCliPrefix } from "../util/package-manager";
@@ -9,10 +9,15 @@ export interface Identity {
99
token: string;
1010
/**
1111
* Org subject to send on write calls: the current org's Taskless UUID
12-
* (preferred) or the deprecated numeric `orgId` claim. See `resolveOrgSubject`.
12+
* (preferred) or the deprecated numeric `orgId` claim. See `resolveActingOrg`.
1313
*/
1414
orgSubject: string | number;
1515
repositoryUrl: string;
16+
/**
17+
* Whether the acting org's plan includes rule recovery; `undefined` when
18+
* unknown. Chooses which recovery to suggest, never whether to try one.
19+
*/
20+
restoreRules?: boolean;
1621
}
1722

1823
/**
@@ -21,6 +26,8 @@ export interface Identity {
2126
* repo's remotes, falling back to the token's canonical id claim, and finally
2227
* to the nil-UUID `NIL_ORG_ID` so a subject is always present
2328
* - repositoryUrl: inferred from `git remote get-url origin`
29+
* - restoreRules: the matched org's plan entitlement, from the same `whoami`
30+
* call; absent when unknown
2431
*
2532
* Throws a `CLIError` carrying a stable `CLIErrorCode` if auth is missing
2633
* (`AUTH_REQUIRED`) or the repository URL cannot be resolved, in which case
@@ -39,9 +46,16 @@ export async function resolveIdentity(cwd: string): Promise<Identity> {
3946
}
4047

4148
const repositoryUrl = await resolveRepositoryUrl(cwd);
42-
const orgSubject = await resolveOrgSubject(cwd, token);
49+
const org = await resolveActingOrg(cwd, token);
4350

44-
return { token, orgSubject, repositoryUrl };
51+
return {
52+
token,
53+
orgSubject: org.subject,
54+
repositoryUrl,
55+
...(org.restoreRules === undefined
56+
? {}
57+
: { restoreRules: org.restoreRules }),
58+
};
4559
}
4660

4761
/**
@@ -52,7 +66,7 @@ export async function resolveIdentity(cwd: string): Promise<Identity> {
5266
* prose. It is shared by every caller so the mapping cannot drift between
5367
* them.
5468
*
55-
* `resolveOrgSubject` is deliberately absent from the list of expected
69+
* `resolveActingOrg` is deliberately absent from the list of expected
5670
* failures: it cannot throw. `fetchWhoami` swallows every network and HTTP
5771
* error and returns `undefined`, and `decodeOrgId` falls back to the nil-UUID
5872
* `NIL_ORG_ID`, so a repo with no matching org resolves a subject rather than

‎packages/cli/src/auth/org.ts‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,26 +65,50 @@ export async function resolveCurrentOrg(
6565
return selectOrgForOwners(ownerUrls, orgs);
6666
}
6767

68+
/** The organization the CLI acts as, and what its plan is known to grant. */
69+
export interface ActingOrg {
70+
/** The org subject to send on write calls. See `resolveActingOrg`. */
71+
subject: string | number;
72+
/**
73+
* The matched organization's `entitlements.restoreRules`, or `undefined`
74+
* when it is unknown: whoami failed, the organization carried no boolean
75+
* for it, or no organization matched and the subject came from the token.
76+
* A hint for which recovery to suggest, never a gate. The service refuses
77+
* a recovery the plan lacks on its own.
78+
*/
79+
restoreRules?: boolean;
80+
}
81+
6882
/**
69-
* The org subject to send on write calls. Prefers the current org's Taskless
70-
* UUID (`id`), resolved by matching the repo's remotes against `whoami`; falls
83+
* The org to act as. `subject` prefers the current org's Taskless UUID
84+
* (`id`), resolved by matching the repo's remotes against `whoami`; falls
7185
* back to the token's canonical id (or its deprecated numeric `orgId` claim)
7286
* when whoami is unavailable or no org owns the repo. A new client thus routes
7387
* multi-org users correctly, while older single-org behaviour is preserved via
7488
* the claim.
7589
*
76-
* Never `undefined`: when neither a matched org nor a token claim resolves,
77-
* returns the nil-UUID `NIL_ORG_ID` so the canonical subject is always known
78-
* (unattributed usage is sent under one stable, known org id).
90+
* `subject` is never `undefined`: when neither a matched org nor a token claim
91+
* resolves, it is the nil-UUID `NIL_ORG_ID` so the canonical subject is always
92+
* known (unattributed usage is sent under one stable, known org id).
93+
*
94+
* `restoreRules` rides along from the same whoami call, so knowing the plan
95+
* costs no request of its own.
7996
*/
80-
export async function resolveOrgSubject(
97+
export async function resolveActingOrg(
8198
cwd: string,
8299
token: string
83-
): Promise<string | number> {
100+
): Promise<ActingOrg> {
84101
const whoami = await fetchWhoami(token);
85102
if (whoami && whoami.orgs.length > 0) {
86103
const org = await resolveCurrentOrg(cwd, whoami.orgs);
87-
if (org) return org.id;
104+
if (org) {
105+
// Read defensively: the schema says an absent `entitlements` means
106+
// unknown, and an older service may not send it at all.
107+
const restoreRules: unknown = org.entitlements?.restoreRules;
108+
return typeof restoreRules === "boolean"
109+
? { subject: org.id, restoreRules }
110+
: { subject: org.id };
111+
}
88112
}
89-
return decodeOrgId(token) ?? NIL_ORG_ID;
113+
return { subject: decodeOrgId(token) ?? NIL_ORG_ID };
90114
}

‎packages/cli/src/rules/plan-check.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getToken } from "../auth/token";
2-
import { resolveOrgSubject } from "../auth/org";
2+
import { resolveActingOrg } from "../auth/org";
33
import { reconcileRules } from "../api/v2";
44
import { resolveRepositoryUrl } from "../util/git-remote";
55
import { getCliPrefix } from "../util/package-manager";
@@ -191,9 +191,9 @@ export async function planCheck(
191191
};
192192
}
193193

194-
const orgSubject = await resolveOrgSubject(cwd, token);
194+
const org = await resolveActingOrg(cwd, token);
195195
const outcome = await reconcileRules(token, {
196-
orgId: orgSubject,
196+
orgId: org.subject,
197197
repositoryUrl,
198198
rules: report.rules.map(({ ruleId, files }) => ({ ruleId, files })),
199199
});

‎packages/cli/test/error-envelope.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { join, resolve } from "node:path";
66
import { promisify } from "node:util";
77
import { afterEach, beforeEach, describe, expect, it } from "vitest";
88

9-
import { resolveOrgSubject } from "../src/auth/org";
9+
import { resolveActingOrg } from "../src/auth/org";
1010
import { cliRejectionToResult } from "./support/spawn-cli";
1111

1212
const execFileAsync = promisify(execFile);
@@ -290,7 +290,7 @@ describe("standardized error envelope (--json)", () => {
290290
* rewording any of these strings fails a test instead of silently telling a
291291
* machine consumer to log in when the real problem is the git remote.
292292
*
293-
* Origin 4, a `resolveOrgSubject` failure, is absent on purpose and is
293+
* Origin 4, a `resolveActingOrg` failure, is absent on purpose and is
294294
* covered by its own test below: it cannot throw.
295295
*/
296296
describe("resolveIdentity failure codes (--json)", () => {
@@ -501,14 +501,14 @@ describe("resolveIdentity failure codes (--json)", () => {
501501
});
502502

503503
/**
504-
* Origin 4 of #181: a `resolveOrgSubject` failure. There is no such failure
504+
* Origin 4 of #181: a `resolveActingOrg` failure. There is no such failure
505505
* to give a code to. `fetchWhoami` returns `undefined` on any network or HTTP
506506
* error, and `decodeOrgId` falls back to the nil-UUID `NIL_ORG_ID`, so the
507507
* step resolves a subject rather than throwing. This test holds that shape in
508-
* place: if `resolveOrgSubject` ever grows a throw, it needs a code of its
508+
* place: if `resolveActingOrg` ever grows a throw, it needs a code of its
509509
* own and this test says so by failing.
510510
*/
511-
describe("resolveOrgSubject", () => {
511+
describe("resolveActingOrg", () => {
512512
let cwd: string;
513513

514514
beforeEach(async () => {
@@ -525,7 +525,7 @@ describe("resolveOrgSubject", () => {
525525
process.env.TASKLESS_API_URL = "http://127.0.0.1:1/cli";
526526
try {
527527
await expect(
528-
resolveOrgSubject(cwd, "not-a-real-token")
528+
resolveActingOrg(cwd, "not-a-real-token")
529529
).resolves.toBeDefined();
530530
} finally {
531531
if (previous === undefined) {

‎packages/cli/test/org.test.ts‎

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { promisify } from "node:util";
66
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
77
import { fetchWhoami } from "../src/auth/whoami";
88
import {
9-
resolveOrgSubject,
9+
resolveActingOrg,
1010
selectOrgForOwners,
1111
type WhoamiOrg,
1212
} from "../src/auth/org";
@@ -39,6 +39,15 @@ const org = (id: string, url: string, source = "github"): WhoamiOrg =>
3939
url,
4040
}) as WhoamiOrg;
4141

42+
const entitled = (restoreRules: boolean): WhoamiOrg => ({
43+
...org("uuid-acme", "https://github.com/acme"),
44+
entitlements: {
45+
remoteGeneration: true,
46+
runtimeSignatures: true,
47+
restoreRules,
48+
},
49+
});
50+
4251
describe("selectOrgForOwners", () => {
4352
const orgs = [
4453
org("uuid-acme", "https://github.com/acme"),
@@ -78,7 +87,7 @@ describe("selectOrgForOwners", () => {
7887
});
7988
});
8089

81-
describe("resolveOrgSubject", () => {
90+
describe("resolveActingOrg", () => {
8291
let directory: string;
8392
const token = makeJwt({ orgId: 4242 });
8493

@@ -100,25 +109,84 @@ describe("resolveOrgSubject", () => {
100109
mockedFetchWhoami.mockResolvedValue(
101110
whoamiWith([org("uuid-acme", "https://github.com/acme")])
102111
);
103-
expect(await resolveOrgSubject(directory, token)).toBe("uuid-acme");
112+
await expect(resolveActingOrg(directory, token)).resolves.toMatchObject({
113+
subject: "uuid-acme",
114+
});
104115
});
105116

106117
it("falls back to the numeric claim when no whoami org matches the repo", async () => {
107118
mockedFetchWhoami.mockResolvedValue(
108119
whoamiWith([org("uuid-other", "https://github.com/other")])
109120
);
110-
expect(await resolveOrgSubject(directory, token)).toBe(4242);
121+
await expect(resolveActingOrg(directory, token)).resolves.toMatchObject({
122+
subject: 4242,
123+
});
111124
});
112125

113126
it("falls back to the numeric claim when whoami is unavailable", async () => {
114127
mockedFetchWhoami.mockResolvedValue(WHOAMI_UNAVAILABLE);
115-
expect(await resolveOrgSubject(directory, token)).toBe(4242);
128+
await expect(resolveActingOrg(directory, token)).resolves.toMatchObject({
129+
subject: 4242,
130+
});
116131
});
117132

118133
it("falls back to the nil-UUID when whoami is unavailable and the token has no org claim", async () => {
119134
mockedFetchWhoami.mockResolvedValue(WHOAMI_UNAVAILABLE);
120-
expect(await resolveOrgSubject(directory, makeJwt({ sub: "u" }))).toBe(
121-
"00000000-0000-0000-0000-000000000000"
135+
await expect(
136+
resolveActingOrg(directory, makeJwt({ sub: "u" }))
137+
).resolves.toMatchObject({
138+
subject: "00000000-0000-0000-0000-000000000000",
139+
});
140+
});
141+
142+
it.each([true, false])(
143+
"carries the matched org's restoreRules (%s) from the same whoami call",
144+
async (restoreRules) => {
145+
mockedFetchWhoami.mockResolvedValue(whoamiWith([entitled(restoreRules)]));
146+
expect(await resolveActingOrg(directory, token)).toEqual({
147+
subject: "uuid-acme",
148+
restoreRules,
149+
});
150+
expect(mockedFetchWhoami).toHaveBeenCalledTimes(1);
151+
}
152+
);
153+
154+
it("leaves restoreRules unknown when the matched org has no entitlements", async () => {
155+
mockedFetchWhoami.mockResolvedValue(
156+
whoamiWith([org("uuid-acme", "https://github.com/acme")])
122157
);
158+
expect(await resolveActingOrg(directory, token)).toEqual({
159+
subject: "uuid-acme",
160+
});
161+
});
162+
163+
it("leaves restoreRules unknown when entitlements carries no boolean for it", async () => {
164+
mockedFetchWhoami.mockResolvedValue(
165+
whoamiWith([
166+
{
167+
...org("uuid-acme", "https://github.com/acme"),
168+
entitlements: { remoteGeneration: true } as WhoamiOrg["entitlements"],
169+
},
170+
])
171+
);
172+
expect(await resolveActingOrg(directory, token)).toEqual({
173+
subject: "uuid-acme",
174+
});
175+
});
176+
177+
it("leaves restoreRules unknown when no org matches, even if another org has it", async () => {
178+
mockedFetchWhoami.mockResolvedValue(
179+
whoamiWith([
180+
{ ...entitled(false), url: "https://github.com/other" } as WhoamiOrg,
181+
])
182+
);
183+
expect(await resolveActingOrg(directory, token)).toEqual({
184+
subject: 4242,
185+
});
186+
});
187+
188+
it("leaves restoreRules unknown when whoami is unavailable", async () => {
189+
mockedFetchWhoami.mockResolvedValue(WHOAMI_UNAVAILABLE);
190+
expect(await resolveActingOrg(directory, token)).toEqual({ subject: 4242 });
123191
});
124192
});

0 commit comments

Comments
 (0)