@cartanova/qgrid-cli 2.7.9 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/bundle/dist/application/qgrid/expired-token-reminder.js +9 -8
- package/bundle/dist/application/qgrid/oauth.js +5 -2
- package/bundle/dist/application/qgrid/qgrid.frame.js +8 -14
- package/bundle/dist/application/qgrid/qgrid.types.js +1 -1
- package/bundle/dist/application/qgrid/token-death.js +12 -31
- package/bundle/dist/application/qgrid/token-trigger-setup.js +2 -1
- package/bundle/dist/application/sonamu.generated.js +3 -1
- package/bundle/dist/application/sonamu.generated.sso.js +2 -1
- package/bundle/dist/application/token/token.model.js +66 -47
- package/bundle/dist/application/token/token.types.js +2 -1
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/migrations/20260901163614_alter_tokens_add1_alter1.js +15 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +13 -3
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +12 -7
- package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +8 -5
- package/bundle/dist/utils/providers/common/model-cost.js +9 -11
- package/bundle/dist/utils/providers/openai/openai-refresh.js +6 -4
- package/bundle/src/application/qgrid/expired-token-reminder.test.ts +8 -6
- package/bundle/src/application/qgrid/expired-token-reminder.ts +9 -8
- package/bundle/src/application/qgrid/oauth.test.ts +28 -0
- package/bundle/src/application/qgrid/oauth.ts +5 -0
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +22 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +8 -15
- package/bundle/src/application/qgrid/qgrid.types.ts +1 -1
- package/bundle/src/application/qgrid/token-death.test.ts +62 -58
- package/bundle/src/application/qgrid/token-death.ts +15 -39
- package/bundle/src/application/qgrid/token-trigger-setup.test.ts +6 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +1 -0
- package/bundle/src/application/sonamu.generated.http +0 -27
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +3 -0
- package/bundle/src/application/token/token.entity.json +7 -0
- package/bundle/src/application/token/token.model.test.ts +112 -37
- package/bundle/src/application/token/token.model.ts +76 -48
- package/bundle/src/application/token/token.types.ts +1 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/migrations/20260901163614_alter_tokens_add1_alter1.ts +15 -0
- package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +25 -1
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +23 -4
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +36 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +20 -7
- package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +28 -0
- package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +18 -2
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +14 -10
- package/bundle/src/utils/providers/common/model-cost.test.ts +19 -19
- package/bundle/src/utils/providers/common/model-cost.ts +19 -16
- package/bundle/src/utils/providers/openai/openai-refresh.test.ts +16 -7
- package/bundle/src/utils/providers/openai/openai-refresh.ts +2 -2
- package/bundle/web-dist/client/assets/{index-D59Q_9Z4.js → index-DDSQ_6GL.js} +4 -4
- package/bundle/web-dist/client/assets/{logs-BHVfI8ut.js → logs-D2OMhBlx.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-EkLxH1oN.js → monit-CYLYHrc-.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-Kbvuclip.js → routes-DlG0sJHR.js} +2 -2
- package/bundle/web-dist/client/assets/{services.generated-Da1Eq5_R.js → services.generated-C6jfEwfK.js} +1 -1
- package/bundle/web-dist/client/assets/{settings-B4uTFP3J.js → settings-BeCBndh_.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BjIdSPTd.js → show-BdmzN1As.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-BbCSxbFu.js → tokens-KCfMOeTH.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-4OltfVAd.js → use-oauth-login-flow-2dSjIvBk.js} +1 -1
- package/bundle/web-dist/client/assets/{use-update-token-mutation-CZ57EKA1.js → use-update-token-mutation-BIvO70-M.js} +1 -1
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{lazyRouteComponent-V1S8gqMS.js → lazyRouteComponent-C_k4R5n6.js} +2 -2
- package/bundle/web-dist/server/assets/{logs-2Tkqcncq.js → logs-lBOU5eUP.js} +2 -2
- package/bundle/web-dist/server/assets/{monit-DrJ15xJL.js → monit-BW2X1D23.js} +2 -2
- package/bundle/web-dist/server/assets/{plus-DCai02Na.js → plus-DilmHifT.js} +1 -1
- package/bundle/web-dist/server/assets/{react-dom-BFiG5Daf.js → react-dom-CHRRsKwb.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-Ct3I9263.js → routes-CxMHXfdH.js} +5 -5
- package/bundle/web-dist/server/assets/{services.generated-y7xVOTCo.js → services.generated-buX3cJzR.js} +1 -9
- package/bundle/web-dist/server/assets/{settings-JznSjwGQ.js → settings-CFoXQaAg.js} +3 -3
- package/bundle/web-dist/server/assets/{show-D8kuAFbc.js → show-CcZJO6WF.js} +2 -2
- package/bundle/web-dist/server/assets/{tokens-PR42jF1J.js → tokens-C32lycTe.js} +6 -5
- package/bundle/web-dist/server/assets/{use-oauth-login-flow-C-33Z5HA.js → use-oauth-login-flow-DCjxIa80.js} +1 -1
- package/bundle/web-dist/server/assets/{use-update-token-mutation-BNaRPVaX.js → use-update-token-mutation-CqK8i8Fa.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +46 -23
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +8 -3
- package/skills/qgrid/references/ai-sdk-provider-contract.md +8 -3
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +17 -9
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/decision-rationale.md +5 -2
- package/skills/qgrid/references/docs-workflow.md +3 -3
- package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
- package/skills/qgrid/references/sonamu-api-web-flow.md +6 -2
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +20 -13
- package/skills/qgrid/references/verification-and-debugging.md +19 -16
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
findOneMock,
|
|
11
11
|
findManyMock,
|
|
12
12
|
findActiveByProviderAndNameMock,
|
|
13
|
+
toggleActiveMock,
|
|
13
14
|
saveMock,
|
|
14
15
|
updateFieldsMock,
|
|
15
16
|
requestLogSaveMock,
|
|
@@ -33,6 +34,7 @@ const {
|
|
|
33
34
|
findOneMock: vi.fn(),
|
|
34
35
|
findManyMock: vi.fn(),
|
|
35
36
|
findActiveByProviderAndNameMock: vi.fn(),
|
|
37
|
+
toggleActiveMock: vi.fn(),
|
|
36
38
|
saveMock: vi.fn(),
|
|
37
39
|
updateFieldsMock: vi.fn(),
|
|
38
40
|
requestLogSaveMock: vi.fn(),
|
|
@@ -65,6 +67,7 @@ vi.mock("../token/token.model", () => ({
|
|
|
65
67
|
findOne: findOneMock,
|
|
66
68
|
findMany: findManyMock,
|
|
67
69
|
findActiveByProviderAndName: findActiveByProviderAndNameMock,
|
|
70
|
+
toggleActive: toggleActiveMock,
|
|
68
71
|
save: saveMock,
|
|
69
72
|
updateFields: updateFieldsMock,
|
|
70
73
|
},
|
|
@@ -111,12 +114,31 @@ const tokenEntry = {
|
|
|
111
114
|
},
|
|
112
115
|
name: "tok-A",
|
|
113
116
|
active: true,
|
|
117
|
+
reauth_required: false,
|
|
114
118
|
ord: 0,
|
|
115
119
|
quota_threshold: null,
|
|
116
120
|
weight: 1,
|
|
117
121
|
keepalive_enabled: false,
|
|
118
122
|
};
|
|
119
123
|
|
|
124
|
+
describe("QgridFrame.toggleToken", () => {
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
toggleActiveMock.mockReset();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("does not reactivate a token whose login has expired", async () => {
|
|
130
|
+
toggleActiveMock.mockResolvedValue({ active: false, reauthRequired: true });
|
|
131
|
+
|
|
132
|
+
await expect(QgridFrame.toggleToken(1)).rejects.toThrow("Re-login required");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("reactivates a manually disabled token without requiring login", async () => {
|
|
136
|
+
toggleActiveMock.mockResolvedValue({ active: true, reauthRequired: false });
|
|
137
|
+
|
|
138
|
+
await expect(QgridFrame.toggleToken(1)).resolves.toEqual({ active: true });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
120
142
|
function deeplyNestedInputSchema(depth: number): unknown {
|
|
121
143
|
return JSON.parse(`${"[".repeat(depth)}0${"]".repeat(depth)}`) as unknown;
|
|
122
144
|
}
|
|
@@ -575,20 +575,12 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
575
575
|
*/
|
|
576
576
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
577
577
|
async toggleToken(id: number): Promise<{ active: boolean }> {
|
|
578
|
-
const
|
|
579
|
-
if (!
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
id,
|
|
585
|
-
provider: entry.provider,
|
|
586
|
-
credentials: entry.credentials,
|
|
587
|
-
active: newActive,
|
|
588
|
-
name: entry.name,
|
|
589
|
-
},
|
|
590
|
-
]);
|
|
591
|
-
return { active: newActive };
|
|
578
|
+
const result = await TokenModel.toggleActive(id);
|
|
579
|
+
if (!result) return { active: false };
|
|
580
|
+
if (result.reauthRequired) {
|
|
581
|
+
throw new BadRequestException("Re-login required" as LocalizedString);
|
|
582
|
+
}
|
|
583
|
+
return { active: result.active };
|
|
592
584
|
}
|
|
593
585
|
|
|
594
586
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
@@ -812,7 +804,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
812
804
|
refreshed = await refreshAccessToken(rt);
|
|
813
805
|
} catch (e) {
|
|
814
806
|
if (e instanceof RefreshFailedError && e.isAuthDead) {
|
|
815
|
-
await deactivateAuthDeadToken(token,
|
|
807
|
+
await deactivateAuthDeadToken(token, `anthropic:${e.status}`);
|
|
816
808
|
}
|
|
817
809
|
throw e;
|
|
818
810
|
}
|
|
@@ -829,6 +821,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
829
821
|
provider: token.provider,
|
|
830
822
|
credentials: updated,
|
|
831
823
|
name: token.name,
|
|
824
|
+
reauth_required: false,
|
|
832
825
|
},
|
|
833
826
|
]);
|
|
834
827
|
return refreshed.accessToken;
|
|
@@ -114,7 +114,7 @@ export const QueryInput = z.looseObject({
|
|
|
114
114
|
serviceTier: ServiceTier.optional(),
|
|
115
115
|
history: z.string().optional(),
|
|
116
116
|
projectName: z.string().optional(),
|
|
117
|
-
//
|
|
117
|
+
// raw/dashboard와 공개 AI SDK providerOptions.qgrid.tokenName이 공유하는 지정 주소.
|
|
118
118
|
tokenName: z.string().optional(),
|
|
119
119
|
// SDK-derived opaque affinity. It is intentionally distinct from the caller's sessionKey.
|
|
120
120
|
cacheAffinityKey: z
|
|
@@ -3,14 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import { SLACK_COLOR } from "../../utils/slack-notify";
|
|
4
4
|
import { deactivateAuthDeadToken, notifyTokenAdded } from "./token-death";
|
|
5
5
|
|
|
6
|
-
const {
|
|
7
|
-
findOneMock: vi.fn(),
|
|
6
|
+
const { deactivateMock, notifySlackMock } = vi.hoisted(() => ({
|
|
8
7
|
deactivateMock: vi.fn(),
|
|
9
8
|
notifySlackMock: vi.fn(),
|
|
10
9
|
}));
|
|
11
10
|
|
|
12
11
|
vi.mock("../token/token.model", () => ({
|
|
13
|
-
TokenModel: {
|
|
12
|
+
TokenModel: { markReauthRequired: deactivateMock },
|
|
14
13
|
}));
|
|
15
14
|
|
|
16
15
|
// SLACK_COLOR 는 상수라 가릴 이유가 없다 — 전송 함수만 스텁한다.
|
|
@@ -19,32 +18,34 @@ vi.mock("../../utils/slack-notify", async (importOriginal) => ({
|
|
|
19
18
|
notifySlack: notifySlackMock,
|
|
20
19
|
}));
|
|
21
20
|
|
|
22
|
-
const token = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
const token = {
|
|
22
|
+
id: 7,
|
|
23
|
+
name: "anthropic/test-token",
|
|
24
|
+
provider: "anthropic",
|
|
25
|
+
credentials: {
|
|
26
|
+
accessToken: "at",
|
|
27
|
+
refreshToken: "rt-current",
|
|
28
|
+
expiresAt: 0,
|
|
29
|
+
accountUuid: "acc-1",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
31
32
|
|
|
32
33
|
describe("deactivateAuthDeadToken", () => {
|
|
33
34
|
afterEach(() => {
|
|
34
|
-
findOneMock.mockReset();
|
|
35
35
|
deactivateMock.mockReset();
|
|
36
36
|
notifySlackMock.mockReset();
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
it("deactivates and notifies when the failed attempt used the current refresh token", async () => {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
deactivateMock.mockResolvedValue({
|
|
41
|
+
marked: true,
|
|
42
|
+
keptAsLastActive: false,
|
|
43
|
+
staleCredentials: false,
|
|
44
|
+
});
|
|
42
45
|
|
|
43
|
-
await expect(
|
|
44
|
-
deactivateAuthDeadToken(token, "rt-current", "anthropic:400"),
|
|
45
|
-
).resolves.toBe(true);
|
|
46
|
+
await expect(deactivateAuthDeadToken(token, "anthropic:400")).resolves.toBe(true);
|
|
46
47
|
|
|
47
|
-
expect(deactivateMock).toHaveBeenCalledWith(7);
|
|
48
|
+
expect(deactivateMock).toHaveBeenCalledWith(7, token.credentials);
|
|
48
49
|
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
49
50
|
const notification = notifySlackMock.mock.calls[0]![0] as {
|
|
50
51
|
subject: string;
|
|
@@ -56,34 +57,39 @@ describe("deactivateAuthDeadToken", () => {
|
|
|
56
57
|
expect(notification.color).toBe(SLACK_COLOR.bad);
|
|
57
58
|
});
|
|
58
59
|
|
|
59
|
-
it("skips deactivation when
|
|
60
|
-
|
|
60
|
+
it("skips deactivation when credentials rotated since the failed attempt", async () => {
|
|
61
|
+
deactivateMock.mockResolvedValue({
|
|
62
|
+
marked: false,
|
|
63
|
+
keptAsLastActive: false,
|
|
64
|
+
staleCredentials: true,
|
|
65
|
+
});
|
|
61
66
|
|
|
62
|
-
await expect(deactivateAuthDeadToken(token, "
|
|
67
|
+
await expect(deactivateAuthDeadToken(token, "anthropic:400")).resolves.toBe(false);
|
|
63
68
|
|
|
64
|
-
expect(deactivateMock).
|
|
69
|
+
expect(deactivateMock).toHaveBeenCalledWith(7, token.credentials);
|
|
65
70
|
expect(notifySlackMock).not.toHaveBeenCalled();
|
|
66
71
|
});
|
|
67
72
|
|
|
68
73
|
it("does not notify when another process won the deactivation race", async () => {
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
deactivateMock.mockResolvedValue({
|
|
75
|
+
marked: false,
|
|
76
|
+
keptAsLastActive: false,
|
|
77
|
+
staleCredentials: false,
|
|
78
|
+
});
|
|
71
79
|
|
|
72
|
-
await expect(deactivateAuthDeadToken(token, "
|
|
73
|
-
false,
|
|
74
|
-
);
|
|
80
|
+
await expect(deactivateAuthDeadToken(token, "anthropic:400")).resolves.toBe(false);
|
|
75
81
|
|
|
76
82
|
expect(notifySlackMock).not.toHaveBeenCalled();
|
|
77
83
|
});
|
|
78
84
|
|
|
79
85
|
it("alerts instead of deactivating when the dying token is the last active one", async () => {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
deactivateMock.mockResolvedValue({
|
|
87
|
+
marked: true,
|
|
88
|
+
keptAsLastActive: true,
|
|
89
|
+
staleCredentials: false,
|
|
90
|
+
});
|
|
85
91
|
|
|
86
|
-
await expect(
|
|
92
|
+
await expect(deactivateAuthDeadToken(token, "anthropic:400")).resolves.toBe(false);
|
|
87
93
|
|
|
88
94
|
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
89
95
|
const notification = notifySlackMock.mock.calls[0]![0] as { title: string; context: string };
|
|
@@ -91,36 +97,34 @@ describe("deactivateAuthDeadToken", () => {
|
|
|
91
97
|
expect(notification.context).toContain("anthropic:400");
|
|
92
98
|
});
|
|
93
99
|
|
|
94
|
-
it("
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
it("does not repeat a last-active alert after another process records auth death", async () => {
|
|
101
|
+
deactivateMock
|
|
102
|
+
.mockResolvedValueOnce({
|
|
103
|
+
marked: true,
|
|
104
|
+
keptAsLastActive: true,
|
|
105
|
+
staleCredentials: false,
|
|
106
|
+
})
|
|
107
|
+
.mockResolvedValue({
|
|
108
|
+
marked: false,
|
|
109
|
+
keptAsLastActive: false,
|
|
110
|
+
staleCredentials: false,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await deactivateAuthDeadToken(token, "anthropic:400");
|
|
114
|
+
await deactivateAuthDeadToken(token, "anthropic:400");
|
|
115
|
+
await deactivateAuthDeadToken(token, "anthropic:401");
|
|
105
116
|
|
|
106
117
|
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
107
118
|
});
|
|
108
119
|
|
|
109
|
-
it("does nothing when the token no longer exists", async () => {
|
|
110
|
-
findOneMock.mockResolvedValue(undefined);
|
|
111
|
-
|
|
112
|
-
await expect(deactivateAuthDeadToken(token, "rt-current", "anthropic:400")).resolves.toBe(
|
|
113
|
-
false,
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
expect(deactivateMock).not.toHaveBeenCalled();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
120
|
it("never puts the raw provider error body in the notification", async () => {
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
deactivateMock.mockResolvedValue({
|
|
122
|
+
marked: true,
|
|
123
|
+
keptAsLastActive: false,
|
|
124
|
+
staleCredentials: false,
|
|
125
|
+
});
|
|
122
126
|
|
|
123
|
-
await deactivateAuthDeadToken(token, "
|
|
127
|
+
await deactivateAuthDeadToken(token, "anthropic:400");
|
|
124
128
|
|
|
125
129
|
// 필드가 늘어도 새는 곳이 생기지 않도록 페이로드 전체를 훑는다.
|
|
126
130
|
const payload = JSON.stringify(notifySlackMock.mock.calls[0]![0]);
|
|
@@ -1,55 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 세션 만료(auth-dead) 토큰의 라우팅 제외 지점.
|
|
3
3
|
*
|
|
4
|
-
* refresh 가 재로그인 외 복구 불가한 실패를 반환한 순간에만 호출된다.
|
|
5
|
-
*
|
|
6
|
-
* 공유 DB 를 쓰는 인스턴스가 몇이든 사건당 알림은 1건이다.
|
|
4
|
+
* refresh 가 재로그인 외 복구 불가한 실패를 반환한 순간에만 호출된다. 시도에 사용한
|
|
5
|
+
* credentials 가 DB 와 같을 때만 reauth_required 를 기록하고, 그 기록을 이긴 프로세스만
|
|
6
|
+
* Slack 알림을 보낸다 — 공유 DB 를 쓰는 인스턴스가 몇이든 사건당 알림은 1건이다.
|
|
7
7
|
*/
|
|
8
8
|
import { getLogger } from "@logtape/logtape";
|
|
9
9
|
|
|
10
|
-
import { getRefreshToken } from "../../utils/providers/common/credentials";
|
|
11
10
|
import { notifySlack, SLACK_COLOR } from "../../utils/slack-notify";
|
|
11
|
+
import { type TokenSubsetA } from "../sonamu.generated";
|
|
12
12
|
import { TokenModel } from "../token/token.model";
|
|
13
13
|
import { getSlackUserMap, mentionFor } from "./slack-user-map";
|
|
14
14
|
|
|
15
15
|
const logger = getLogger(["qgrid", "token-death"]);
|
|
16
16
|
|
|
17
|
-
type DeadTokenRef =
|
|
18
|
-
id: number;
|
|
19
|
-
name: string;
|
|
20
|
-
provider: string;
|
|
21
|
-
};
|
|
17
|
+
type DeadTokenRef = Pick<TokenSubsetA, "id" | "name" | "provider" | "credentials">;
|
|
22
18
|
|
|
23
19
|
/**
|
|
24
|
-
* @param usedRefreshToken 실패한 요청이 실제로 사용한 refresh token.
|
|
25
|
-
* refresh token 은 회전 저장되므로, 이미 회전된 옛 토큰으로 늦게 도착한 재시도가
|
|
26
|
-
* 400 을 받는 것은 정상 레이스다. DB 의 현재 값과 다르면 사망으로 보지 않는다.
|
|
27
20
|
* @param reasonCode `provider:code` 형태의 내부 코드. provider 응답 본문에는 계정 이메일이나
|
|
28
21
|
* 토큰 조각이 섞일 수 있어 raw 메시지를 그대로 알림에 싣지 않는다.
|
|
29
22
|
*/
|
|
30
23
|
export async function deactivateAuthDeadToken(
|
|
31
24
|
token: DeadTokenRef,
|
|
32
|
-
usedRefreshToken: string,
|
|
33
25
|
reasonCode: string,
|
|
34
26
|
): Promise<boolean> {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
27
|
+
const { marked, keptAsLastActive, staleCredentials } = await TokenModel.markReauthRequired(
|
|
28
|
+
token.id,
|
|
29
|
+
token.credentials,
|
|
30
|
+
);
|
|
31
|
+
if (staleCredentials) {
|
|
40
32
|
logger.info(
|
|
41
|
-
`skipping auth-death for ${token.name}(id=${token.id}):
|
|
33
|
+
`skipping auth-death for ${token.name}(id=${token.id}): credentials rotated since this attempt`,
|
|
42
34
|
);
|
|
43
35
|
return false;
|
|
44
36
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (keptAsLastActive) notifyLastActiveTokenDying(token, reasonCode);
|
|
37
|
+
if (!marked) return false;
|
|
38
|
+
if (keptAsLastActive) {
|
|
39
|
+
notifyLastActiveTokenDying(token, reasonCode);
|
|
49
40
|
return false;
|
|
50
41
|
}
|
|
51
42
|
|
|
52
|
-
logger.warn(`token
|
|
43
|
+
logger.warn(`token requires re-login: ${token.name}(id=${token.id}) reason=${reasonCode}`);
|
|
53
44
|
// 만료 직후가 재로그인하기 가장 좋은 타이밍이다. 소유자를 멘션해 그 순간 당사자에게
|
|
54
45
|
// 꽂는다 — 다이제스트를 기다리면 다음 주기까지 토큰이 빠진 채로 남는다.
|
|
55
46
|
const mention = mentionFor(token.name, getSlackUserMap());
|
|
@@ -64,22 +55,7 @@ export async function deactivateAuthDeadToken(
|
|
|
64
55
|
return true;
|
|
65
56
|
}
|
|
66
57
|
|
|
67
|
-
/**
|
|
68
|
-
* 마지막 활성 토큰이 죽었을 때의 알림 쿨다운.
|
|
69
|
-
*
|
|
70
|
-
* 이 경우 토큰은 비활성화되지 않고 살아남으므로, 뒤따르는 요청마다 같은 실패를 반복해
|
|
71
|
-
* 알림이 폭주한다. 비활성화 성공 경로가 조건부 UPDATE 로 중복을 막는 것과 달리 여기엔
|
|
72
|
-
* 상태 변화가 없어 DB 로 억제할 수 없다 — provider 단위로 시간 창을 둔다.
|
|
73
|
-
*/
|
|
74
|
-
const LAST_ACTIVE_ALERT_COOLDOWN_MS = 30 * 60 * 1000;
|
|
75
|
-
const lastActiveAlertAt = new Map<string, number>();
|
|
76
|
-
|
|
77
58
|
function notifyLastActiveTokenDying(token: DeadTokenRef, reasonCode: string): void {
|
|
78
|
-
const now = Date.now();
|
|
79
|
-
const previous = lastActiveAlertAt.get(token.provider);
|
|
80
|
-
if (previous !== undefined && now - previous < LAST_ACTIVE_ALERT_COOLDOWN_MS) return;
|
|
81
|
-
lastActiveAlertAt.set(token.provider, now);
|
|
82
|
-
|
|
83
59
|
const mention = mentionFor(token.name, getSlackUserMap());
|
|
84
60
|
void notifySlack({
|
|
85
61
|
title: "마지막 토큰 사망",
|
|
@@ -12,4 +12,10 @@ describe("tokens trigger setup", () => {
|
|
|
12
12
|
"OLD.keepalive_enabled IS DISTINCT FROM NEW.keepalive_enabled",
|
|
13
13
|
);
|
|
14
14
|
});
|
|
15
|
+
|
|
16
|
+
it("notifies subscribers when a token starts requiring re-login", () => {
|
|
17
|
+
expect(TOKENS_TRIGGER_SETUP_SQL).toContain(
|
|
18
|
+
"OLD.reauth_required IS DISTINCT FROM NEW.reauth_required",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
15
21
|
});
|
|
@@ -37,6 +37,7 @@ export const TOKENS_TRIGGER_SETUP_SQL = `
|
|
|
37
37
|
FOR EACH ROW
|
|
38
38
|
WHEN (
|
|
39
39
|
OLD.active IS DISTINCT FROM NEW.active OR
|
|
40
|
+
OLD.reauth_required IS DISTINCT FROM NEW.reauth_required OR
|
|
40
41
|
OLD.credentials IS DISTINCT FROM NEW.credentials OR
|
|
41
42
|
OLD.provider IS DISTINCT FROM NEW.provider OR
|
|
42
43
|
OLD.name IS DISTINCT FROM NEW.name OR
|
|
@@ -22,33 +22,6 @@ Content-Type: application/json
|
|
|
22
22
|
|
|
23
23
|
###
|
|
24
24
|
|
|
25
|
-
POST {{baseUrl}}/api/token/save
|
|
26
|
-
Content-Type: application/json
|
|
27
|
-
|
|
28
|
-
{
|
|
29
|
-
"spa": [
|
|
30
|
-
{
|
|
31
|
-
"id": 0,
|
|
32
|
-
"created_at": "2000-01-01",
|
|
33
|
-
"provider": "PROVIDER",
|
|
34
|
-
"credentials": {
|
|
35
|
-
"accessToken": "ACCESSTOKEN",
|
|
36
|
-
"refreshToken": "REFRESHTOKEN",
|
|
37
|
-
"expiresAt": 0,
|
|
38
|
-
"accountUuid": "ACCOUNTUUID"
|
|
39
|
-
},
|
|
40
|
-
"name": "NAME",
|
|
41
|
-
"active": false,
|
|
42
|
-
"ord": 0,
|
|
43
|
-
"quota_threshold": null,
|
|
44
|
-
"weight": 1,
|
|
45
|
-
"keepalive_enabled": false
|
|
46
|
-
}
|
|
47
|
-
]
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
###
|
|
51
|
-
|
|
52
25
|
POST {{baseUrl}}/api/token/reorder
|
|
53
26
|
Content-Type: application/json
|
|
54
27
|
|
|
@@ -210,6 +210,7 @@ export const tokenSubsetQueries = {
|
|
|
210
210
|
credentials: "tokens.credentials",
|
|
211
211
|
name: "tokens.name",
|
|
212
212
|
active: "tokens.active",
|
|
213
|
+
reauth_required: "tokens.reauth_required",
|
|
213
214
|
ord: "tokens.ord",
|
|
214
215
|
quota_threshold: "tokens.quota_threshold",
|
|
215
216
|
weight: "tokens.weight",
|
|
@@ -296,6 +296,7 @@ export const TokenBaseSchema = z.object({
|
|
|
296
296
|
credentials: TokenCredentials,
|
|
297
297
|
name: z.string(),
|
|
298
298
|
active: z.boolean(),
|
|
299
|
+
reauth_required: z.boolean(),
|
|
299
300
|
ord: z.int(),
|
|
300
301
|
quota_threshold: z.int().nullable(),
|
|
301
302
|
weight: z.int(),
|
|
@@ -305,6 +306,7 @@ export type TokenBaseSchema = z.infer<typeof TokenBaseSchema> & {
|
|
|
305
306
|
readonly __hasDefault__: readonly [
|
|
306
307
|
"created_at",
|
|
307
308
|
"active",
|
|
309
|
+
"reauth_required",
|
|
308
310
|
"ord",
|
|
309
311
|
"quota_threshold",
|
|
310
312
|
"weight",
|
|
@@ -557,6 +559,7 @@ export const TokenSubsetA = z.object({
|
|
|
557
559
|
credentials: TokenCredentials,
|
|
558
560
|
name: z.string(),
|
|
559
561
|
active: z.boolean(),
|
|
562
|
+
reauth_required: z.boolean(),
|
|
560
563
|
ord: z.int(),
|
|
561
564
|
quota_threshold: z.int().nullable(),
|
|
562
565
|
weight: z.int(),
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
},
|
|
15
15
|
{ "name": "name", "desc": "토큰 이름", "type": "string" },
|
|
16
16
|
{ "name": "active", "type": "boolean", "desc": "활성 여부", "dbDefault": "true" },
|
|
17
|
+
{
|
|
18
|
+
"name": "reauth_required",
|
|
19
|
+
"type": "boolean",
|
|
20
|
+
"desc": "인증 만료로 재로그인이 필요한지 여부",
|
|
21
|
+
"dbDefault": "false"
|
|
22
|
+
},
|
|
17
23
|
{ "name": "ord", "type": "integer", "desc": "정렬 순서", "dbDefault": "0" },
|
|
18
24
|
{
|
|
19
25
|
"name": "quota_threshold",
|
|
@@ -50,6 +56,7 @@
|
|
|
50
56
|
"credentials",
|
|
51
57
|
"name",
|
|
52
58
|
"active",
|
|
59
|
+
"reauth_required",
|
|
53
60
|
"ord",
|
|
54
61
|
"quota_threshold",
|
|
55
62
|
"weight",
|