-
Notifications
You must be signed in to change notification settings - Fork 195
feat(fe): hand apps a session to re-issue their own delegations from #4273
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
sea-snake
merged 129 commits into
fe/app-session-store
from
fe/ii-session-delegation-handler
Sep 10, 2026
Merged
Changes from 126 commits
Commits
Show all changes
129 commits
Select commit
Hold shift + click to select a range
e0fdc2c
feat(fe): hand apps a session to re-issue their own delegations from
sea-snake b045cca
feat(fe): let an app cap how long its session lasts
sea-snake 6e424ca
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake f41338c
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 299f507
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake dd089bd
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake b63b6b1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake b17d6c4
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 3f8f2f6
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake e6dd29d
feat(sessions): record the account mapping alongside the session
sea-snake 257e7d1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 37f16ad
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 79a0711
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 1f4a744
refactor(fe): send current_device_key by the name the canister now uses
sea-snake 079fe46
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 7559a30
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake ed9228c
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake cd912eb
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 9172669
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 7ac1a65
feat(fe): carry the app's idle bound to the canister
sea-snake f018dd1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake c3e04d1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 05f7e40
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 4e17ea0
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 0920e97
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake aa3ddfb
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 889fc2b
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 7a7e622
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 2b7a48d
fix(fe): name the session get_account_session is fetching
sea-snake 24ffb57
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 6074e87
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 3e98ee8
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 8aaaeeb
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 26f787c
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake f10937a
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake ce2afee
fix(fe): name the session get_account_session is fetching by its id
sea-snake 571c246
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 0c5ff96
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 0bc3e59
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 7b70f47
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 3998ed1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 8cbf7d0
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 53c2485
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 659cc9b
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 69579e4
feat(fe): retry a sign-in the canister refused as a stale key
sea-snake 6a81cd8
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake a643211
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake bab2812
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake daea5c2
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 5046aaf
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 20fa2bb
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 90670b7
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 5614385
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake a0fa407
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 4070355
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 084d56a
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake c729076
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake b10bf1e
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake dd02507
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 9dabb91
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake ec57fa2
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 858cf96
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 87ed6c3
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake b94e9c5
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 32a2837
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 997991d
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 6d1da21
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 9e86af6
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake a119727
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake cc602f9
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake c378843
refactor(be): a browser, not a session device
sea-snake 6de75c1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake c446a88
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 92174df
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 21f33a4
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 0274624
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake e3f9175
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 9f3be29
refactor(fe): the sign-in handler names a browser
sea-snake 144e2a8
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 146d849
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 52cd973
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 2073d02
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 6f57b43
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 5493377
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake a527777
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 79080db
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake ad7621f
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 4fa6831
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake efb3f3a
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 5eec2ff
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake fde140e
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 11af3a7
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 64f3dde
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 5255223
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 7f81737
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake e50a887
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake bc83d9a
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 2e60a01
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 13256b2
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 19eafb3
chore: merge fe/app-session-store
sea-snake 7775758
chore: merge fe/app-session-store
sea-snake cf62055
chore: merge fe/app-session-store
sea-snake e244425
chore: merge fe/app-session-store
sea-snake c910b4b
chore: merge fe/app-session-store
sea-snake 4c788d4
chore: merge fe/app-session-store
sea-snake bbbf460
chore: merge fe/app-session-store
sea-snake d15a2ab
feat(frontend): send what the browser is, not a label for it
sea-snake 20aec53
chore: merge fe/app-session-store
sea-snake 2a2e5d3
chore: merge fe/app-session-store
sea-snake 6955c79
chore: merge fe/app-session-store
sea-snake 667fa9d
chore: merge fe/app-session-store
sea-snake 89319bb
chore: merge fe/app-session-store
sea-snake e4d2605
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake b402663
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 9a398c9
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 3d5847c
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake beb78b9
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 617a50a
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake f58f71c
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake b2cec72
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake a6003c5
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake a60b970
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake bb25bf4
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 1d1860d
fix(session-delegation): a duration it cannot read is an error, not s…
sea-snake 17a9ae4
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake 7dfa92c
fix(session-delegation): keep the targets the canister signed
sea-snake 0803107
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake e371f37
fix(transport): accept only decimal digits as a nat64
sea-snake e0044a5
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
265 changes: 265 additions & 0 deletions
265
src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,265 @@ | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import "fake-indexeddb/auto"; | ||
|
|
||
| const ORIGIN = "https://app.example.com"; | ||
|
|
||
| vi.mock("$lib/globals", async () => { | ||
| const { Principal } = await import("@icp-sdk/core/principal"); | ||
| return { | ||
| canisterId: Principal.fromText("rwlgt-iiaaa-aaaaa-aaaaa-cai"), | ||
| backendCanisterConfig: { openid_configs: [] }, | ||
| frontendCanisterConfig: { related_origins: [], dev_csp: [] }, | ||
| }; | ||
| }); | ||
| vi.mock("$lib/utils/validateDerivationOrigin", () => ({ | ||
| validateDerivationOrigin: vi.fn(() => Promise.resolve({ result: "valid" })), | ||
| })); | ||
|
|
||
| const setRequestContext = vi.fn(); | ||
|
|
||
| const IDENTITY = BigInt(10_000); | ||
| const prepareAccountSession = vi.fn(); | ||
| const getAccountSession = vi.fn(); | ||
|
|
||
| vi.mock("$lib/stores/authorization.store", () => ({ | ||
| authorizationStore: { | ||
| setRequestContext: (...args: unknown[]) => setRequestContext(...args), | ||
| }, | ||
| // A store that already holds its value, which is what `waitForStore` waits for. | ||
| // Inlined rather than shared, because `vi.mock` is hoisted above anything declared | ||
| // here. | ||
| authorizedStore: { | ||
| subscribe: (run: (value: unknown) => void) => { | ||
| run({ | ||
| accessLevel: "full-access", | ||
| maxTimeToLive: undefined, | ||
| accountNumberPromise: Promise.resolve(undefined), | ||
| }); | ||
| return () => {}; | ||
| }, | ||
| }, | ||
| })); | ||
| vi.mock("$lib/stores/authentication.store", () => ({ | ||
| authenticationStore: { | ||
| subscribe: (run: (value: unknown) => void) => { | ||
| run({ | ||
| identityNumber: BigInt(10_000), | ||
| authMethod: { passkey: {} }, | ||
| actor: { | ||
| prepare_account_session: (...args: unknown[]) => | ||
| prepareAccountSession(...args), | ||
| get_account_session: (...args: unknown[]) => | ||
| getAccountSession(...args), | ||
| }, | ||
| }); | ||
| return () => {}; | ||
| }, | ||
| }, | ||
| })); | ||
|
|
||
| import { | ||
| asBrowserKeyError, | ||
| handleSessionDelegationRequest, | ||
| } from "./sessionDelegation"; | ||
| import { StaleBrowserKeyError } from "$lib/stores/browser-key.store"; | ||
| import { CanisterError } from "$lib/utils/utils"; | ||
| import { | ||
| appSessionsForOrigin, | ||
| purgeAppSessions, | ||
| } from "$lib/stores/app-session.store"; | ||
| import { ECDSAKeyIdentity } from "@icp-sdk/core/identity"; | ||
| import { Principal } from "@icp-sdk/core/principal"; | ||
| import { Base64ToBytesCodec } from "$lib/utils/transport/utils"; | ||
|
|
||
| const channelWith = () => { | ||
| const sent: unknown[] = []; | ||
| return { | ||
| channel: { | ||
| origin: ORIGIN, | ||
| closed: false, | ||
| resumeToken: "token", | ||
| addEventListener: () => () => {}, | ||
| send: (response: unknown) => { | ||
| sent.push(response); | ||
| return Promise.resolve(); | ||
| }, | ||
| close: async () => {}, | ||
| }, | ||
| sent, | ||
| }; | ||
| }; | ||
|
|
||
| describe("ii_session_delegation", () => { | ||
| beforeEach(async () => { | ||
| setRequestContext.mockClear(); | ||
| await purgeAppSessions(BigInt(10_000)); | ||
| await purgeAppSessions(BigInt(10_001)); | ||
| }); | ||
|
|
||
| it("ignores a request for another method", async () => { | ||
| const { channel, sent } = channelWith(); | ||
| const onError = vi.fn(); | ||
|
|
||
| await handleSessionDelegationRequest( | ||
| channel, | ||
| onError, | ||
| )({ | ||
| jsonrpc: "2.0", | ||
| id: 1, | ||
| method: "icrc34_delegation", | ||
| }); | ||
|
|
||
| expect(sent).toEqual([]); | ||
| expect(onError).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("rejects params that carry no session key", async () => { | ||
| const { channel, sent } = channelWith(); | ||
| const onError = vi.fn(); | ||
|
|
||
| await handleSessionDelegationRequest( | ||
| channel, | ||
| onError, | ||
| )({ | ||
| jsonrpc: "2.0", | ||
| id: 1, | ||
| method: "ii_session_delegation", | ||
| params: {}, | ||
| }); | ||
|
|
||
| expect(sent).toHaveLength(1); | ||
| expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); | ||
| expect(onError).toHaveBeenCalledWith("invalid-request"); | ||
| }); | ||
|
|
||
| /// A duration `BigInt` cannot read used to throw out of `safeParse`, which sits above | ||
| /// the handler's `try`, so the app was told nothing at all. | ||
| it("rejects a duration that is not a number", async () => { | ||
| const { channel, sent } = channelWith(); | ||
| const onError = vi.fn(); | ||
|
|
||
| await handleSessionDelegationRequest( | ||
| channel, | ||
| onError, | ||
| )({ | ||
| jsonrpc: "2.0", | ||
| id: 1, | ||
| method: "ii_session_delegation", | ||
| params: { | ||
| sessionPublicKey: btoa("an app key"), | ||
| maxTimeToLive: "not a number", | ||
| }, | ||
| }); | ||
|
|
||
| expect(sent).toHaveLength(1); | ||
| expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); | ||
| expect(onError).toHaveBeenCalledWith("invalid-request"); | ||
| }); | ||
|
|
||
| /// The whole ceremony, which nothing else here reaches: what the canister is asked | ||
| /// for, what is kept, and what the app is handed back. | ||
| it("mints a session and answers with a chain the app can use", async () => { | ||
| const { channel, sent } = channelWith(); | ||
| const appKey = await ECDSAKeyIdentity.generate({ extractable: false }); | ||
| const appPublicKey = new Uint8Array(appKey.getPublicKey().toDer()); | ||
| const expiration = BigInt(Date.now() + 60 * 60 * 1000) * BigInt(1_000_000); | ||
|
|
||
| prepareAccountSession.mockImplementation(({ session_key }) => | ||
| Promise.resolve({ | ||
| Ok: { | ||
| user_key: session_key, | ||
| expiration, | ||
| session_id: BigInt(77), | ||
| browser_id: 3, | ||
| account_principal: Principal.anonymous(), | ||
| }, | ||
| }), | ||
| ); | ||
| getAccountSession.mockImplementation(({ session_key }) => | ||
| Promise.resolve({ | ||
| Ok: { | ||
| signed_delegation: { | ||
| // As the canister answers since the session credential was scoped: the | ||
| // targets are part of what it signed, so a chain rebuilt without them is | ||
| // refused by the replica. | ||
| delegation: { | ||
| pubkey: session_key, | ||
| expiration, | ||
| targets: [[Principal.fromText("rwlgt-iiaaa-aaaaa-aaaaa-cai")]], | ||
| }, | ||
| // At least 32 bytes: the chain's own parser refuses anything shorter. | ||
| signature: new Uint8Array(64).fill(7), | ||
| }, | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| await handleSessionDelegationRequest( | ||
| channel, | ||
| vi.fn(), | ||
| )({ | ||
| jsonrpc: "2.0", | ||
| id: 1, | ||
| method: "ii_session_delegation", | ||
| params: { sessionPublicKey: Base64ToBytesCodec.encode(appPublicKey) }, | ||
| }); | ||
|
|
||
| // Asked for what the request and the consent said, at this origin. | ||
| expect(prepareAccountSession).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| identity_number: IDENTITY, | ||
| origin: ORIGIN, | ||
| account_number: [], | ||
| }), | ||
| ); | ||
|
|
||
| // Kept, so a later silent re-auth resumes rather than signing in again — and kept | ||
| // against II's own key, never the app's. | ||
| const [stored] = await appSessionsForOrigin(ORIGIN); | ||
| expect(stored.record.sessionId).toBe(BigInt(77)); | ||
| expect(stored.identityNumber).toBe(IDENTITY); | ||
|
|
||
| // Answered, and the chain ends at the app's key rather than at what the canister | ||
| // signed: the hop only II can make is what makes the on-chain half unusable alone. | ||
| expect(sent).toHaveLength(1); | ||
| expect(sent[0]).toMatchObject({ id: 1 }); | ||
| const result = ( | ||
| sent[0] as { | ||
| result: { | ||
| publicKey: string; | ||
| signerDelegation: { delegation: { targets?: string[] } }[]; | ||
| }; | ||
| } | ||
| ).result; | ||
| expect(result.publicKey).toEqual(expect.any(String)); | ||
|
|
||
| // The hop the canister signed keeps its targets. Dropping them leaves a delegation | ||
| // that hashes to nothing in the signature tree, and every call the app makes with | ||
| // this chain comes back "Invalid canister signature". | ||
| expect(result.signerDelegation[0].delegation.targets).toEqual([ | ||
| "rwlgt-iiaaa-aaaaa-aaaaa-cai", | ||
| ]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("asBrowserKeyError", () => { | ||
| it("names a retired browser key so the key store can promote its successor", () => { | ||
| const stale = asBrowserKeyError( | ||
| new CanisterError({ StaleBrowserKey: null }), | ||
| ); | ||
|
|
||
| expect(stale).toBeInstanceOf(StaleBrowserKeyError); | ||
| }); | ||
|
|
||
| it("leaves every other canister error alone", () => { | ||
| const other = new CanisterError({ NoSuchAccount: null }); | ||
|
|
||
| expect(asBrowserKeyError(other)).toBe(other); | ||
| }); | ||
|
|
||
| it("leaves a transport failure alone", () => { | ||
| const network = new Error("network"); | ||
|
|
||
| expect(asBrowserKeyError(network)).toBe(network); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.