@hexclave/shared 1.0.9 → 1.0.11
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/dist/ai/unified-prompts/reminders.js +1 -0
- package/dist/ai/unified-prompts/reminders.js.map +1 -1
- package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.d.ts.map +1 -1
- package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js +10 -2
- package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js.map +1 -1
- package/dist/config/schema.d.ts +175 -175
- package/dist/config-rendering.d.ts.map +1 -1
- package/dist/config-rendering.js +7 -2
- package/dist/config-rendering.js.map +1 -1
- package/dist/esm/ai/unified-prompts/reminders.js +1 -0
- package/dist/esm/ai/unified-prompts/reminders.js.map +1 -1
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.d.ts.map +1 -1
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js +10 -2
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js.map +1 -1
- package/dist/esm/config/schema.d.ts +175 -175
- package/dist/esm/config-rendering.d.ts.map +1 -1
- package/dist/esm/config-rendering.js +7 -2
- package/dist/esm/config-rendering.js.map +1 -1
- package/dist/esm/hexclave-config-file.d.ts.map +1 -1
- package/dist/esm/hexclave-config-file.js +2 -1
- package/dist/esm/hexclave-config-file.js.map +1 -1
- package/dist/esm/interface/admin-interface.d.ts +9 -1
- package/dist/esm/interface/admin-interface.d.ts.map +1 -1
- package/dist/esm/interface/admin-interface.js +32 -2
- package/dist/esm/interface/admin-interface.js.map +1 -1
- package/dist/esm/interface/admin-metrics.d.ts +123 -7
- package/dist/esm/interface/admin-metrics.d.ts.map +1 -1
- package/dist/esm/interface/admin-metrics.js +21 -2
- package/dist/esm/interface/admin-metrics.js.map +1 -1
- package/dist/esm/interface/conversations.d.ts +27 -27
- package/dist/esm/interface/crud/current-user.d.ts +6 -6
- package/dist/esm/interface/crud/email-outbox.d.ts +228 -228
- package/dist/esm/interface/crud/invoices.d.ts +2 -2
- package/dist/esm/interface/crud/project-api-keys.d.ts +2 -2
- package/dist/esm/interface/crud/projects.d.ts +99 -99
- package/dist/esm/interface/crud/team-member-profiles.d.ts +16 -16
- package/dist/esm/interface/crud/users.d.ts +2 -2
- package/dist/esm/schema-fields.d.ts.map +1 -1
- package/dist/esm/schema-fields.js +1 -1
- package/dist/esm/schema-fields.js.map +1 -1
- package/dist/esm/sessions.d.ts +10 -0
- package/dist/esm/sessions.d.ts.map +1 -1
- package/dist/esm/sessions.js +20 -2
- package/dist/esm/sessions.js.map +1 -1
- package/dist/esm/sessions.test.d.ts +1 -0
- package/dist/esm/sessions.test.js +178 -0
- package/dist/esm/sessions.test.js.map +1 -0
- package/dist/hexclave-config-file.d.ts.map +1 -1
- package/dist/hexclave-config-file.js +2 -1
- package/dist/hexclave-config-file.js.map +1 -1
- package/dist/interface/admin-interface.d.ts +9 -1
- package/dist/interface/admin-interface.d.ts.map +1 -1
- package/dist/interface/admin-interface.js +32 -2
- package/dist/interface/admin-interface.js.map +1 -1
- package/dist/interface/admin-metrics.d.ts +123 -7
- package/dist/interface/admin-metrics.d.ts.map +1 -1
- package/dist/interface/admin-metrics.js +22 -1
- package/dist/interface/admin-metrics.js.map +1 -1
- package/dist/interface/conversations.d.ts +27 -27
- package/dist/interface/crud/current-user.d.ts +6 -6
- package/dist/interface/crud/email-outbox.d.ts +228 -228
- package/dist/interface/crud/invoices.d.ts +2 -2
- package/dist/interface/crud/project-api-keys.d.ts +2 -2
- package/dist/interface/crud/projects.d.ts +99 -99
- package/dist/interface/crud/team-member-profiles.d.ts +16 -16
- package/dist/interface/crud/users.d.ts +2 -2
- package/dist/schema-fields.d.ts.map +1 -1
- package/dist/schema-fields.js +1 -1
- package/dist/schema-fields.js.map +1 -1
- package/dist/sessions.d.ts +10 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +20 -2
- package/dist/sessions.js.map +1 -1
- package/dist/sessions.test.d.ts +1 -0
- package/dist/sessions.test.js +178 -0
- package/dist/sessions.test.js.map +1 -0
- package/package.json +1 -1
- package/src/ai/unified-prompts/reminders.ts +1 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.ts +10 -2
- package/src/config-rendering.ts +11 -2
- package/src/hexclave-config-file.ts +7 -1
- package/src/interface/admin-interface.ts +49 -2
- package/src/interface/admin-metrics.ts +33 -2
- package/src/schema-fields.ts +4 -1
- package/src/sessions.test.ts +147 -0
- package/src/sessions.ts +26 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
const require_chunk = require('./chunk-BE-pF4vm.js');
|
|
2
|
+
let vitest = require("vitest");
|
|
3
|
+
let __sessions_js = require("./sessions.js");
|
|
4
|
+
|
|
5
|
+
//#region src/sessions.test.ts
|
|
6
|
+
/**
|
|
7
|
+
* Builds a decodable (unsigned) access-token JWT with a valid payload. `refreshTokenId` controls the
|
|
8
|
+
* `refresh_token_id` claim (the session identifier); `iatOffsetSeconds` lets two tokens for the same session
|
|
9
|
+
* differ as strings while sharing a `refresh_token_id`.
|
|
10
|
+
*/
|
|
11
|
+
function createAccessTokenString(refreshTokenId, options) {
|
|
12
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
13
|
+
const nowSeconds = Math.floor(Date.now() / 1e3) + (options?.iatOffsetSeconds ?? 0);
|
|
14
|
+
return [
|
|
15
|
+
encode({
|
|
16
|
+
alg: "none",
|
|
17
|
+
typ: "JWT"
|
|
18
|
+
}),
|
|
19
|
+
encode({
|
|
20
|
+
sub: options?.sub ?? "user-id",
|
|
21
|
+
exp: nowSeconds + 60,
|
|
22
|
+
iat: nowSeconds,
|
|
23
|
+
iss: "https://api.example.test",
|
|
24
|
+
aud: "project-id",
|
|
25
|
+
project_id: "project-id",
|
|
26
|
+
branch_id: "main",
|
|
27
|
+
refresh_token_id: refreshTokenId,
|
|
28
|
+
role: "authenticated",
|
|
29
|
+
name: null,
|
|
30
|
+
email: null,
|
|
31
|
+
email_verified: false,
|
|
32
|
+
selected_team_id: null,
|
|
33
|
+
signed_up_at: nowSeconds,
|
|
34
|
+
is_anonymous: false,
|
|
35
|
+
is_restricted: false,
|
|
36
|
+
restricted_reason: null,
|
|
37
|
+
requires_totp_mfa: false
|
|
38
|
+
}),
|
|
39
|
+
""
|
|
40
|
+
].join(".");
|
|
41
|
+
}
|
|
42
|
+
function createAccessOnlySession(accessToken) {
|
|
43
|
+
return new __sessions_js.InternalSession({
|
|
44
|
+
refreshAccessTokenCallback: async () => null,
|
|
45
|
+
refreshToken: null,
|
|
46
|
+
accessToken
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const currentToken = (session) => session.getAccessTokenIfNotExpiredYet(2e4, null)?.token;
|
|
50
|
+
(0, vitest.describe)("InternalSession.calculateSessionKey", () => {
|
|
51
|
+
(0, vitest.it)("keys by the refresh token when one is present (ignoring any access token)", () => {
|
|
52
|
+
(0, vitest.expect)(__sessions_js.InternalSession.calculateSessionKey({ refreshToken: "rt-abc" })).toBe("refresh-rt-abc");
|
|
53
|
+
(0, vitest.expect)(__sessions_js.InternalSession.calculateSessionKey({
|
|
54
|
+
refreshToken: "rt-abc",
|
|
55
|
+
accessToken: createAccessTokenString("rtid-1")
|
|
56
|
+
})).toBe("refresh-rt-abc");
|
|
57
|
+
});
|
|
58
|
+
(0, vitest.it)("returns not-logged-in when neither token is present", () => {
|
|
59
|
+
(0, vitest.expect)(__sessions_js.InternalSession.calculateSessionKey({ refreshToken: null })).toBe("not-logged-in");
|
|
60
|
+
(0, vitest.expect)(__sessions_js.InternalSession.calculateSessionKey({
|
|
61
|
+
refreshToken: null,
|
|
62
|
+
accessToken: null
|
|
63
|
+
})).toBe("not-logged-in");
|
|
64
|
+
});
|
|
65
|
+
(0, vitest.it)("keys an access-only session by its refresh_token_id", () => {
|
|
66
|
+
(0, vitest.expect)(__sessions_js.InternalSession.calculateSessionKey({
|
|
67
|
+
refreshToken: null,
|
|
68
|
+
accessToken: createAccessTokenString("rtid-1")
|
|
69
|
+
})).toBe("access-session-rtid-1");
|
|
70
|
+
});
|
|
71
|
+
(0, vitest.it)("is stable across re-minted access tokens for the same session (the regression this fixes)", () => {
|
|
72
|
+
const first = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 });
|
|
73
|
+
const second = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 });
|
|
74
|
+
(0, vitest.expect)(second).not.toBe(first);
|
|
75
|
+
(0, vitest.expect)(__sessions_js.InternalSession.calculateSessionKey({
|
|
76
|
+
refreshToken: null,
|
|
77
|
+
accessToken: second
|
|
78
|
+
})).toBe(__sessions_js.InternalSession.calculateSessionKey({
|
|
79
|
+
refreshToken: null,
|
|
80
|
+
accessToken: first
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
(0, vitest.it)("distinguishes access-only sessions with different refresh_token_ids", () => {
|
|
84
|
+
(0, vitest.expect)(__sessions_js.InternalSession.calculateSessionKey({
|
|
85
|
+
refreshToken: null,
|
|
86
|
+
accessToken: createAccessTokenString("rtid-1")
|
|
87
|
+
})).not.toBe(__sessions_js.InternalSession.calculateSessionKey({
|
|
88
|
+
refreshToken: null,
|
|
89
|
+
accessToken: createAccessTokenString("rtid-2")
|
|
90
|
+
}));
|
|
91
|
+
});
|
|
92
|
+
(0, vitest.it)("falls back to the raw token when the access token can't be decoded", () => {
|
|
93
|
+
(0, vitest.expect)(__sessions_js.InternalSession.calculateSessionKey({
|
|
94
|
+
refreshToken: null,
|
|
95
|
+
accessToken: "not-a-jwt"
|
|
96
|
+
})).toBe("access-not-a-jwt");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
(0, vitest.describe)("InternalSession#updateAccessToken", () => {
|
|
100
|
+
(0, vitest.it)("installs a fresh token for the same access-only session in place", () => {
|
|
101
|
+
const initial = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 });
|
|
102
|
+
const refreshed = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 });
|
|
103
|
+
const session = createAccessOnlySession(initial);
|
|
104
|
+
session.updateAccessToken({
|
|
105
|
+
accessToken: refreshed,
|
|
106
|
+
refreshToken: null
|
|
107
|
+
});
|
|
108
|
+
(0, vitest.expect)(currentToken(session)).toBe(refreshed);
|
|
109
|
+
(0, vitest.expect)(session.sessionKey).toBe("access-session-rtid-1");
|
|
110
|
+
});
|
|
111
|
+
(0, vitest.it)("rejects a token pair belonging to a different access-only session", () => {
|
|
112
|
+
const initial = createAccessTokenString("rtid-1");
|
|
113
|
+
const foreign = createAccessTokenString("rtid-2", { sub: "other-user" });
|
|
114
|
+
const session = createAccessOnlySession(initial);
|
|
115
|
+
session.updateAccessToken({
|
|
116
|
+
accessToken: foreign,
|
|
117
|
+
refreshToken: null
|
|
118
|
+
});
|
|
119
|
+
(0, vitest.expect)(currentToken(session)).toBe(initial);
|
|
120
|
+
});
|
|
121
|
+
(0, vitest.it)("is a no-op for an unchanged, null, or undecodable token", () => {
|
|
122
|
+
const initial = createAccessTokenString("rtid-1");
|
|
123
|
+
const session = createAccessOnlySession(initial);
|
|
124
|
+
session.updateAccessToken({
|
|
125
|
+
accessToken: initial,
|
|
126
|
+
refreshToken: null
|
|
127
|
+
});
|
|
128
|
+
session.updateAccessToken({
|
|
129
|
+
accessToken: null,
|
|
130
|
+
refreshToken: null
|
|
131
|
+
});
|
|
132
|
+
session.updateAccessToken({
|
|
133
|
+
accessToken: "not-a-jwt",
|
|
134
|
+
refreshToken: null
|
|
135
|
+
});
|
|
136
|
+
(0, vitest.expect)(currentToken(session)).toBe(initial);
|
|
137
|
+
});
|
|
138
|
+
(0, vitest.it)("never revives an invalidated session", () => {
|
|
139
|
+
const session = createAccessOnlySession(createAccessTokenString("rtid-1"));
|
|
140
|
+
session.markInvalid();
|
|
141
|
+
session.updateAccessToken({
|
|
142
|
+
accessToken: createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 }),
|
|
143
|
+
refreshToken: null
|
|
144
|
+
});
|
|
145
|
+
(0, vitest.expect)(session.isKnownToBeInvalid()).toBe(true);
|
|
146
|
+
(0, vitest.expect)(currentToken(session)).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
(0, vitest.it)("updates a refresh-token-backed session's access token in place when the refresh token matches", () => {
|
|
149
|
+
const session = new __sessions_js.InternalSession({
|
|
150
|
+
refreshAccessTokenCallback: async () => null,
|
|
151
|
+
refreshToken: "rt-abc",
|
|
152
|
+
accessToken: createAccessTokenString("rtid-1")
|
|
153
|
+
});
|
|
154
|
+
const refreshed = createAccessTokenString("rtid-2", { iatOffsetSeconds: 1 });
|
|
155
|
+
session.updateAccessToken({
|
|
156
|
+
accessToken: refreshed,
|
|
157
|
+
refreshToken: "rt-abc"
|
|
158
|
+
});
|
|
159
|
+
(0, vitest.expect)(currentToken(session)).toBe(refreshed);
|
|
160
|
+
(0, vitest.expect)(session.sessionKey).toBe("refresh-rt-abc");
|
|
161
|
+
});
|
|
162
|
+
(0, vitest.it)("rejects a token pair carrying a different refresh token for a refresh-backed session", () => {
|
|
163
|
+
const initial = createAccessTokenString("rtid-1");
|
|
164
|
+
const session = new __sessions_js.InternalSession({
|
|
165
|
+
refreshAccessTokenCallback: async () => null,
|
|
166
|
+
refreshToken: "rt-abc",
|
|
167
|
+
accessToken: initial
|
|
168
|
+
});
|
|
169
|
+
session.updateAccessToken({
|
|
170
|
+
accessToken: createAccessTokenString("rtid-2"),
|
|
171
|
+
refreshToken: "rt-other"
|
|
172
|
+
});
|
|
173
|
+
(0, vitest.expect)(currentToken(session)).toBe(initial);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
//#endregion
|
|
178
|
+
//# sourceMappingURL=sessions.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sessions.test.js","names":["InternalSession"],"sources":["../src/sessions.test.ts"],"sourcesContent":["import { describe, expect, it } from \"vitest\";\nimport { InternalSession } from \"./sessions\";\n\n/**\n * Builds a decodable (unsigned) access-token JWT with a valid payload. `refreshTokenId` controls the\n * `refresh_token_id` claim (the session identifier); `iatOffsetSeconds` lets two tokens for the same session\n * differ as strings while sharing a `refresh_token_id`.\n */\nfunction createAccessTokenString(refreshTokenId: string, options?: { iatOffsetSeconds?: number, sub?: string }): string {\n const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString(\"base64url\");\n const nowSeconds = Math.floor(Date.now() / 1000) + (options?.iatOffsetSeconds ?? 0);\n return [\n encode({ alg: \"none\", typ: \"JWT\" }),\n encode({\n sub: options?.sub ?? \"user-id\",\n exp: nowSeconds + 60,\n iat: nowSeconds,\n iss: \"https://api.example.test\",\n aud: \"project-id\",\n project_id: \"project-id\",\n branch_id: \"main\",\n refresh_token_id: refreshTokenId,\n role: \"authenticated\",\n name: null,\n email: null,\n email_verified: false,\n selected_team_id: null,\n signed_up_at: nowSeconds,\n is_anonymous: false,\n is_restricted: false,\n restricted_reason: null,\n requires_totp_mfa: false,\n }),\n \"\",\n ].join(\".\");\n}\n\nfunction createAccessOnlySession(accessToken: string): InternalSession {\n return new InternalSession({\n refreshAccessTokenCallback: async () => null,\n refreshToken: null,\n accessToken,\n });\n}\n\nconst currentToken = (session: InternalSession) => session.getAccessTokenIfNotExpiredYet(20_000, null)?.token;\n\ndescribe(\"InternalSession.calculateSessionKey\", () => {\n it(\"keys by the refresh token when one is present (ignoring any access token)\", () => {\n expect(InternalSession.calculateSessionKey({ refreshToken: \"rt-abc\" })).toBe(\"refresh-rt-abc\");\n expect(InternalSession.calculateSessionKey({ refreshToken: \"rt-abc\", accessToken: createAccessTokenString(\"rtid-1\") }))\n .toBe(\"refresh-rt-abc\");\n });\n\n it(\"returns not-logged-in when neither token is present\", () => {\n expect(InternalSession.calculateSessionKey({ refreshToken: null })).toBe(\"not-logged-in\");\n expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: null })).toBe(\"not-logged-in\");\n });\n\n it(\"keys an access-only session by its refresh_token_id\", () => {\n expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString(\"rtid-1\") }))\n .toBe(\"access-session-rtid-1\");\n });\n\n it(\"is stable across re-minted access tokens for the same session (the regression this fixes)\", () => {\n const first = createAccessTokenString(\"rtid-1\", { iatOffsetSeconds: 0 });\n const second = createAccessTokenString(\"rtid-1\", { iatOffsetSeconds: 1 });\n expect(second).not.toBe(first);\n expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: second }))\n .toBe(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: first }));\n });\n\n it(\"distinguishes access-only sessions with different refresh_token_ids\", () => {\n expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString(\"rtid-1\") }))\n .not.toBe(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString(\"rtid-2\") }));\n });\n\n it(\"falls back to the raw token when the access token can't be decoded\", () => {\n expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: \"not-a-jwt\" })).toBe(\"access-not-a-jwt\");\n });\n});\n\ndescribe(\"InternalSession#updateAccessToken\", () => {\n it(\"installs a fresh token for the same access-only session in place\", () => {\n const initial = createAccessTokenString(\"rtid-1\", { iatOffsetSeconds: 0 });\n const refreshed = createAccessTokenString(\"rtid-1\", { iatOffsetSeconds: 1 });\n const session = createAccessOnlySession(initial);\n\n session.updateAccessToken({ accessToken: refreshed, refreshToken: null });\n expect(currentToken(session)).toBe(refreshed);\n // identity is unchanged — same session key, same object\n expect(session.sessionKey).toBe(\"access-session-rtid-1\");\n });\n\n it(\"rejects a token pair belonging to a different access-only session\", () => {\n const initial = createAccessTokenString(\"rtid-1\");\n const foreign = createAccessTokenString(\"rtid-2\", { sub: \"other-user\" });\n const session = createAccessOnlySession(initial);\n\n session.updateAccessToken({ accessToken: foreign, refreshToken: null });\n expect(currentToken(session)).toBe(initial);\n });\n\n it(\"is a no-op for an unchanged, null, or undecodable token\", () => {\n const initial = createAccessTokenString(\"rtid-1\");\n const session = createAccessOnlySession(initial);\n\n session.updateAccessToken({ accessToken: initial, refreshToken: null });\n session.updateAccessToken({ accessToken: null, refreshToken: null });\n session.updateAccessToken({ accessToken: \"not-a-jwt\", refreshToken: null });\n expect(currentToken(session)).toBe(initial);\n });\n\n it(\"never revives an invalidated session\", () => {\n const session = createAccessOnlySession(createAccessTokenString(\"rtid-1\"));\n session.markInvalid();\n\n session.updateAccessToken({ accessToken: createAccessTokenString(\"rtid-1\", { iatOffsetSeconds: 1 }), refreshToken: null });\n expect(session.isKnownToBeInvalid()).toBe(true);\n expect(currentToken(session)).toBeUndefined();\n });\n\n it(\"updates a refresh-token-backed session's access token in place when the refresh token matches\", () => {\n const session = new InternalSession({\n refreshAccessTokenCallback: async () => null,\n refreshToken: \"rt-abc\",\n accessToken: createAccessTokenString(\"rtid-1\"),\n });\n const refreshed = createAccessTokenString(\"rtid-2\", { iatOffsetSeconds: 1 });\n\n session.updateAccessToken({ accessToken: refreshed, refreshToken: \"rt-abc\" });\n expect(currentToken(session)).toBe(refreshed);\n expect(session.sessionKey).toBe(\"refresh-rt-abc\");\n });\n\n it(\"rejects a token pair carrying a different refresh token for a refresh-backed session\", () => {\n const initial = createAccessTokenString(\"rtid-1\");\n const session = new InternalSession({\n refreshAccessTokenCallback: async () => null,\n refreshToken: \"rt-abc\",\n accessToken: initial,\n });\n\n session.updateAccessToken({ accessToken: createAccessTokenString(\"rtid-2\"), refreshToken: \"rt-other\" });\n expect(currentToken(session)).toBe(initial);\n });\n});\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ export const remindersPrompt = deindent`
|
|
|
18
18
|
- Language, framework, and library-specific details:
|
|
19
19
|
- JavaScript & TypeScript:
|
|
20
20
|
- Hexclave has different SDK packages for different frameworks and languages. As of the time of writing these reminders, they are: @hexclave/js (JavaScript/TypeScript), @hexclave/next (Next.js), @hexclave/react (React), @hexclave/tanstack-start (TanStack Start). You can find all of these on npm. They are all versioned together, meaning that vX.Y.Z of one SDK was released at the same time as vX.Y.Z of another SDK. For the most part, they are the same, although each has platform-specific features and differences.
|
|
21
|
+
- The Hexclave/Stack Auth SDK constructor accepts a \`urls\` option that tells the SDK where auth pages and post-auth redirects live. When you add a custom auth page such as a \`sign-in\`, \`sign-up\`, \`forgot-password\`, \`account-settings\`, etc., update the corresponding \`urls\` key to point to that route; also set redirect targets such as \`afterSignIn\`, \`afterSignUp\`, \`afterSignOut\`, and \`home\` when those destinations are customized. The \`urls\` option is the source of truth for redirect helpers such as \`redirectToSignIn()\`, hosted or handler-page flows, and post-auth navigation; if it is left pointing at the default pages after custom pages are added, users can hit extra redirects, land on the wrong auth page, or return to an unexpected page after signing in or out.
|
|
21
22
|
- The \`Result<T, E>\` type is \`{ status: "ok", data: T } | { status: "error", error: E }\`.
|
|
22
23
|
- \`KnownErrors[KNOWN_ERROR_CODE]\` refers to a specific known error type. Each KnownError may have its own properties, but they all inherit from \`Error & { statusCode: number, humanReadableMessage: string, details?: Json }\`.
|
|
23
24
|
- React & Next.js:
|
|
@@ -442,11 +442,13 @@ function getRestBackendSetupPrompt(kind: "python" | "rest-api") {
|
|
|
442
442
|
If this project already has a \`hexclave.config.ts\` file for another frontend or backend, reuse that same file so the whole project shares one Hexclave config. Otherwise, create a new \`hexclave.config.ts\` file in your workspace:
|
|
443
443
|
|
|
444
444
|
\`\`\`ts hexclave.config.ts
|
|
445
|
-
import type { HexclaveConfig } from "@hexclave/js";
|
|
445
|
+
import type { HexclaveConfig } from "@hexclave/js/config";
|
|
446
446
|
|
|
447
447
|
export const config: HexclaveConfig = "show-onboarding";
|
|
448
448
|
\`\`\`
|
|
449
449
|
|
|
450
|
+
The \`/config\` entrypoint is lightweight and free of framework runtime code, so it can be safely loaded by tooling such as the local dashboard. If you later switch to a config object and want type-checking, wrap it with \`defineHexclaveConfig\` imported from the same \`@hexclave/js/config\` path (never from \`@hexclave/js\` directly, which would pull in the whole SDK and fail to load).
|
|
451
|
+
|
|
450
452
|
Run your backend through the Hexclave CLI so it starts the local dashboard and injects the Hexclave environment variables:
|
|
451
453
|
|
|
452
454
|
\`\`\`json package.json
|
|
@@ -662,6 +664,10 @@ export function getSdkSetupPrompt(mainType: "ai-prompt" | "nextjs" | "react" | "
|
|
|
662
664
|
},
|
|
663
665
|
});
|
|
664
666
|
\`\`\`
|
|
667
|
+
|
|
668
|
+
<Note>
|
|
669
|
+
The SDK auto-captures page-view and click analytics. To turn this off (and silence the \`ANALYTICS_NOT_ENABLED\` console warning that appears until you enable the Analytics app in your dashboard), pass \`analytics: { enabled: false }\`.
|
|
670
|
+
</Note>
|
|
665
671
|
` : ""}
|
|
666
672
|
|
|
667
673
|
${isMaybeBackend ? deindent`
|
|
@@ -719,12 +725,14 @@ export function getSdkSetupPrompt(mainType: "ai-prompt" | "nextjs" | "react" | "
|
|
|
719
725
|
First, create a \`hexclave.config.ts\` configuration file in the root directory of the workspace (or anywhere else):
|
|
720
726
|
|
|
721
727
|
\`\`\`ts hexclave.config.ts
|
|
722
|
-
import type { HexclaveConfig } from "${packageName}";
|
|
728
|
+
import type { HexclaveConfig } from "${packageName}/config";
|
|
723
729
|
|
|
724
730
|
// default: show-onboarding, which shows the onboarding flow for this project when Hexclave starts
|
|
725
731
|
export const config: HexclaveConfig = "show-onboarding";
|
|
726
732
|
\`\`\`
|
|
727
733
|
|
|
734
|
+
The \`/config\` entrypoint is lightweight and free of framework runtime code, so it can be safely loaded by tooling such as the local dashboard. If you later switch to a config object and want type-checking, wrap it with \`defineHexclaveConfig\` imported from the same \`${packageName}/config\` path (never from \`${packageName}\` directly, which would pull in the whole SDK and fail to load).
|
|
735
|
+
|
|
728
736
|
To run your application with Hexclave, you can then start the dev environment and set environment variables expected by your application. Hexclave's CLI has a \`dev\` command does both of these, so let's install it as a dev dependency and wrap your existing \`dev\` script in your package.json:
|
|
729
737
|
|
|
730
738
|
\`\`\`sh
|
package/src/config-rendering.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { parseHexclaveConfigFileContent, renderConfigFileContent };
|
|
|
13
13
|
const CONFIG_IMPORT_PACKAGES = [
|
|
14
14
|
"@hexclave/next",
|
|
15
15
|
"@hexclave/react",
|
|
16
|
+
"@hexclave/tanstack-start",
|
|
16
17
|
"@hexclave/js",
|
|
17
18
|
"@hexclave/template",
|
|
18
19
|
"@stackframe/stack",
|
|
@@ -120,18 +121,26 @@ import.meta.vitest?.test("renderConfigFileContent rejects invalid config exports
|
|
|
120
121
|
|
|
121
122
|
import.meta.vitest?.test("renderConfigFileContent uses custom import package", ({ expect }) => {
|
|
122
123
|
const content = renderConfigFileContent({}, "@hexclave/next");
|
|
123
|
-
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/next";');
|
|
124
|
+
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/next/config";');
|
|
124
125
|
});
|
|
125
126
|
|
|
126
127
|
import.meta.vitest?.test("renderConfigFileContent defaults to @hexclave/js", ({ expect }) => {
|
|
127
128
|
const content = renderConfigFileContent({});
|
|
128
|
-
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js";');
|
|
129
|
+
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js/config";');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
import.meta.vitest?.test("renderConfigFileContent keeps legacy @stackframe packages on their root entrypoint", ({ expect }) => {
|
|
133
|
+
// The lightweight `/config` subpath only exists on Hexclave-branded packages;
|
|
134
|
+
// already-published @stackframe/* releases predate it.
|
|
135
|
+
const content = renderConfigFileContent({}, "@stackframe/next");
|
|
136
|
+
expect(content).toContain('import type { HexclaveConfig } from "@stackframe/next";');
|
|
129
137
|
});
|
|
130
138
|
|
|
131
139
|
import.meta.vitest?.test("detectConfigImportPackage picks first matching package by priority", ({ expect }) => {
|
|
132
140
|
expect(detectConfigImportPackage(["@hexclave/next", "@hexclave/js"])).toBe("@hexclave/next");
|
|
133
141
|
expect(detectConfigImportPackage(["@hexclave/react", "@hexclave/js"])).toBe("@hexclave/react");
|
|
134
142
|
expect(detectConfigImportPackage(["@hexclave/js"])).toBe("@hexclave/js");
|
|
143
|
+
expect(detectConfigImportPackage(["@hexclave/tanstack-start"])).toBe("@hexclave/tanstack-start");
|
|
135
144
|
// Hexclave names take priority over legacy stackframe names when both appear.
|
|
136
145
|
expect(detectConfigImportPackage(["@stackframe/stack", "@hexclave/next"])).toBe("@hexclave/next");
|
|
137
146
|
// Legacy fallback still works for projects pinned to the last @stackframe/* release.
|
|
@@ -28,7 +28,13 @@ export function renderConfigFileContent(config: unknown, importPackage?: string)
|
|
|
28
28
|
throw new Error(`Config has conflicting keys that would be dropped during normalization: ${droppedKeys.map(k => JSON.stringify(k)).join(", ")}`);
|
|
29
29
|
}
|
|
30
30
|
const pkg = importPackage ?? DEFAULT_CONFIG_IMPORT_PACKAGE;
|
|
31
|
-
|
|
31
|
+
// Import the `HexclaveConfig` type from the package's lightweight `/config`
|
|
32
|
+
// entrypoint, which is free of framework runtime code and therefore safe for
|
|
33
|
+
// tooling (e.g. the local dashboard) to load in a plain Node context. Only the
|
|
34
|
+
// Hexclave-branded packages expose this subpath; legacy `@stackframe/*`
|
|
35
|
+
// releases predate it, so fall back to their package root.
|
|
36
|
+
const importSpecifier = pkg.startsWith("@hexclave/") ? `${pkg}/config` : pkg;
|
|
37
|
+
const importLine = `import type { HexclaveConfig } from "${importSpecifier}";`;
|
|
32
38
|
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -361,11 +361,29 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
|
|
|
361
361
|
);
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
async getMetrics(
|
|
364
|
+
async getMetrics(
|
|
365
|
+
includeAnonymous: boolean = false,
|
|
366
|
+
filters?: {
|
|
367
|
+
country_code?: string,
|
|
368
|
+
referrer?: string,
|
|
369
|
+
browser?: string,
|
|
370
|
+
os?: string,
|
|
371
|
+
device?: string,
|
|
372
|
+
since?: string,
|
|
373
|
+
until?: string,
|
|
374
|
+
},
|
|
375
|
+
): Promise<MetricsResponse> {
|
|
365
376
|
const params = new URLSearchParams();
|
|
366
377
|
if (includeAnonymous) {
|
|
367
378
|
params.append('include_anonymous', 'true');
|
|
368
379
|
}
|
|
380
|
+
if (filters?.country_code) params.append('filter_country_code', filters.country_code);
|
|
381
|
+
if (filters?.referrer) params.append('filter_referrer', filters.referrer);
|
|
382
|
+
if (filters?.browser) params.append('filter_browser', filters.browser);
|
|
383
|
+
if (filters?.os) params.append('filter_os', filters.os);
|
|
384
|
+
if (filters?.device) params.append('filter_device', filters.device);
|
|
385
|
+
if (filters?.since) params.append('filter_since', filters.since);
|
|
386
|
+
if (filters?.until) params.append('filter_until', filters.until);
|
|
369
387
|
const queryString = params.toString();
|
|
370
388
|
const response = await this.sendAdminRequest(
|
|
371
389
|
`/internal/metrics${queryString ? `?${queryString}` : ''}`,
|
|
@@ -374,7 +392,36 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
|
|
|
374
392
|
},
|
|
375
393
|
null,
|
|
376
394
|
);
|
|
377
|
-
|
|
395
|
+
const body = (await response.json()) as MetricsResponse;
|
|
396
|
+
// The yup schema's .optional().default(...) fallbacks only run during
|
|
397
|
+
// backend response validation, not on this client-side cast — apply them
|
|
398
|
+
// here too so the one-release-cycle tolerance for older servers that the
|
|
399
|
+
// schema comments promise actually holds for dashboard consumers. The
|
|
400
|
+
// Partial views widen the static type (which claims these are always
|
|
401
|
+
// defined) to match what an older server can actually send.
|
|
402
|
+
const rawBody: Partial<MetricsResponse> = body;
|
|
403
|
+
const rawAnalytics: Partial<MetricsResponse["analytics_overview"]> = body.analytics_overview;
|
|
404
|
+
return {
|
|
405
|
+
...body,
|
|
406
|
+
live_users: rawBody.live_users ?? 0,
|
|
407
|
+
hourly_users: rawBody.hourly_users ?? [],
|
|
408
|
+
hourly_active_users: rawBody.hourly_active_users ?? [],
|
|
409
|
+
analytics_overview: {
|
|
410
|
+
...body.analytics_overview,
|
|
411
|
+
hourly_page_views: rawAnalytics.hourly_page_views ?? [],
|
|
412
|
+
hourly_active_users: rawAnalytics.hourly_active_users ?? [],
|
|
413
|
+
hourly_visitors: rawAnalytics.hourly_visitors ?? [],
|
|
414
|
+
daily_anonymous_visitors_fallback: rawAnalytics.daily_anonymous_visitors_fallback ?? [],
|
|
415
|
+
anonymous_visitors_fallback: rawAnalytics.anonymous_visitors_fallback ?? 0,
|
|
416
|
+
top_regions: rawAnalytics.top_regions ?? [],
|
|
417
|
+
bounce_rate: rawAnalytics.bounce_rate ?? 0,
|
|
418
|
+
daily_bounce_rate: rawAnalytics.daily_bounce_rate ?? [],
|
|
419
|
+
daily_avg_session_seconds: rawAnalytics.daily_avg_session_seconds ?? [],
|
|
420
|
+
top_browsers: rawAnalytics.top_browsers ?? [],
|
|
421
|
+
top_operating_systems: rawAnalytics.top_operating_systems ?? [],
|
|
422
|
+
top_devices: rawAnalytics.top_devices ?? [],
|
|
423
|
+
},
|
|
424
|
+
};
|
|
378
425
|
}
|
|
379
426
|
|
|
380
427
|
async getUserActivity(userId: string): Promise<UserActivityResponse> {
|
|
@@ -88,16 +88,31 @@ export const MetricsTopReferrerSchema = yupObject({
|
|
|
88
88
|
visitors: yupNumber().integer().defined(),
|
|
89
89
|
}).defined();
|
|
90
90
|
|
|
91
|
+
// Named-count breakdowns used by the analytics overview for top browsers,
|
|
92
|
+
// operating systems, and device classes (Desktop / Mobile / Tablet).
|
|
93
|
+
export const MetricsNamedCountSchema = yupObject({
|
|
94
|
+
name: yupString().defined(),
|
|
95
|
+
visitors: yupNumber().integer().defined(),
|
|
96
|
+
}).defined();
|
|
97
|
+
|
|
91
98
|
export const MetricsTopRegionSchema = yupObject({
|
|
92
99
|
country_code: yupString().nullable().defined(),
|
|
93
100
|
region_code: yupString().nullable().defined(),
|
|
94
101
|
count: yupNumber().integer().defined(),
|
|
95
102
|
}).defined();
|
|
96
103
|
|
|
104
|
+
export const MetricsTopCountrySchema = yupObject({
|
|
105
|
+
country_code: yupString().defined(),
|
|
106
|
+
count: yupNumber().integer().defined(),
|
|
107
|
+
}).defined();
|
|
108
|
+
|
|
97
109
|
export const MetricsAnalyticsOverviewSchema = yupObject({
|
|
98
110
|
daily_page_views: MetricsDataPointsSchema,
|
|
99
111
|
daily_clicks: MetricsDataPointsSchema,
|
|
100
112
|
daily_visitors: MetricsDataPointsSchema,
|
|
113
|
+
hourly_page_views: yupArray(MetricsDataPointSchema).optional().default([]),
|
|
114
|
+
hourly_active_users: yupArray(MetricsDataPointSchema).optional().default([]),
|
|
115
|
+
hourly_visitors: yupArray(MetricsDataPointSchema).optional().default([]),
|
|
101
116
|
// Token-refresh-derived anonymous-visitor fallback. Populated only when the
|
|
102
117
|
// analytics app isn't installed (no `$page-view` events) — counts DISTINCT
|
|
103
118
|
// anonymous users per day from the events table. See
|
|
@@ -117,8 +132,20 @@ export const MetricsAnalyticsOverviewSchema = yupObject({
|
|
|
117
132
|
revenue_per_visitor: yupNumber().defined(),
|
|
118
133
|
top_referrers: yupArray(MetricsTopReferrerSchema).defined(),
|
|
119
134
|
top_region: MetricsTopRegionSchema.nullable().defined(),
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
top_regions: yupArray(MetricsTopCountrySchema).optional().default([]),
|
|
136
|
+
// Weighted across the window: sum(bounced)/sum(sessions) * 100. .optional()
|
|
137
|
+
// for one release cycle so older servers (that don't return it yet) don't
|
|
138
|
+
// hard-fail validation; default to 0 so consumers can read unconditionally.
|
|
139
|
+
bounce_rate: yupNumber().optional().default(0),
|
|
140
|
+
daily_bounce_rate: yupArray(MetricsDataPointSchema).optional().default([]),
|
|
141
|
+
daily_avg_session_seconds: yupArray(MetricsDataPointSchema).optional().default([]),
|
|
142
|
+
// User-Agent-derived breakdowns for the analytics overview. Computed from the
|
|
143
|
+
// `data.user_agent` blob on `$page-view` events (captured client-side only,
|
|
144
|
+
// no server-side fallback). Optional + default-[] for one release cycle
|
|
145
|
+
// so older clients / servers without UA capture don't fail validation.
|
|
146
|
+
top_browsers: yupArray(MetricsNamedCountSchema).optional().default([]),
|
|
147
|
+
top_operating_systems: yupArray(MetricsNamedCountSchema).optional().default([]),
|
|
148
|
+
top_devices: yupArray(MetricsNamedCountSchema).optional().default([]),
|
|
122
149
|
conversion_rate: yupNumber().optional(),
|
|
123
150
|
deltas: yupMixed().optional(),
|
|
124
151
|
}).defined();
|
|
@@ -176,6 +203,8 @@ export const MetricsResponseBodySchema = yupObject({
|
|
|
176
203
|
live_users: yupNumber().integer().optional().default(0),
|
|
177
204
|
daily_users: MetricsDataPointsSchema,
|
|
178
205
|
daily_active_users: MetricsDataPointsSchema,
|
|
206
|
+
hourly_users: yupArray(MetricsDataPointSchema).optional().default([]),
|
|
207
|
+
hourly_active_users: yupArray(MetricsDataPointSchema).optional().default([]),
|
|
179
208
|
users_by_country: yupRecord(yupString().defined(), yupNumber().defined()).defined(),
|
|
180
209
|
active_users_by_country: MetricsActiveUsersByCountrySchema,
|
|
181
210
|
// recently_registered/active are CRUD User objects passed through from the
|
|
@@ -206,6 +235,8 @@ export type MetricsEmailOverview = yup.InferType<typeof MetricsEmailOverviewSche
|
|
|
206
235
|
export type MetricsDailyRevenuePoint = yup.InferType<typeof MetricsDailyRevenuePointSchema>;
|
|
207
236
|
export type MetricsTopReferrer = yup.InferType<typeof MetricsTopReferrerSchema>;
|
|
208
237
|
export type MetricsTopRegion = yup.InferType<typeof MetricsTopRegionSchema>;
|
|
238
|
+
export type MetricsTopCountry = yup.InferType<typeof MetricsTopCountrySchema>;
|
|
239
|
+
export type MetricsNamedCount = yup.InferType<typeof MetricsNamedCountSchema>;
|
|
209
240
|
export type MetricsAnalyticsOverview = yup.InferType<typeof MetricsAnalyticsOverviewSchema>;
|
|
210
241
|
export type MetricsLoginMethodEntry = yup.InferType<typeof MetricsLoginMethodEntrySchema>;
|
|
211
242
|
export type MetricsRecentUser = yup.InferType<typeof MetricsRecentUserSchema>;
|
package/src/schema-fields.ts
CHANGED
|
@@ -612,7 +612,10 @@ export const emailHostSchema = yupString().meta({ openapiField: { description: '
|
|
|
612
612
|
export const emailPortSchema = yupNumber().min(0).max(65535).meta({ openapiField: { description: 'Email port. Needs to be specified when using type="standard"', exampleValue: 587 } });
|
|
613
613
|
export const emailUsernameSchema = yupString().meta({ openapiField: { description: 'Email username. Needs to be specified when using type="standard"', exampleValue: 'smtp-email' } });
|
|
614
614
|
export const emailSenderEmailSchema = emailSchema.meta({ openapiField: { description: 'Email sender email. Needs to be specified when using type="standard"', exampleValue: 'example@your-domain.com' } });
|
|
615
|
-
|
|
615
|
+
// SMTP credentials are stored encrypted (not bcrypt-hashed like user passwords), so they don't
|
|
616
|
+
// need the 70-char bcrypt limit from passwordSchema. Some providers (e.g. ZeptoMail) generate
|
|
617
|
+
// passwords well over 100 chars.
|
|
618
|
+
export const emailPasswordSchema = yupString().max(256).meta({ openapiField: { description: 'Email password. Needs to be specified when using type="standard"', exampleValue: 'your-email-password' } });
|
|
616
619
|
// Project domain config
|
|
617
620
|
export const handlerPathSchema = yupString().test('is-handler-path', 'Handler path must start with /', (value) => value?.startsWith('/')).meta({ openapiField: { description: 'Handler path. If you did not setup a custom handler path, it should be "/handler" by default. It needs to start with /', exampleValue: '/handler' } });
|
|
618
621
|
// Project email theme config
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { InternalSession } from "./sessions";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds a decodable (unsigned) access-token JWT with a valid payload. `refreshTokenId` controls the
|
|
6
|
+
* `refresh_token_id` claim (the session identifier); `iatOffsetSeconds` lets two tokens for the same session
|
|
7
|
+
* differ as strings while sharing a `refresh_token_id`.
|
|
8
|
+
*/
|
|
9
|
+
function createAccessTokenString(refreshTokenId: string, options?: { iatOffsetSeconds?: number, sub?: string }): string {
|
|
10
|
+
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
11
|
+
const nowSeconds = Math.floor(Date.now() / 1000) + (options?.iatOffsetSeconds ?? 0);
|
|
12
|
+
return [
|
|
13
|
+
encode({ alg: "none", typ: "JWT" }),
|
|
14
|
+
encode({
|
|
15
|
+
sub: options?.sub ?? "user-id",
|
|
16
|
+
exp: nowSeconds + 60,
|
|
17
|
+
iat: nowSeconds,
|
|
18
|
+
iss: "https://api.example.test",
|
|
19
|
+
aud: "project-id",
|
|
20
|
+
project_id: "project-id",
|
|
21
|
+
branch_id: "main",
|
|
22
|
+
refresh_token_id: refreshTokenId,
|
|
23
|
+
role: "authenticated",
|
|
24
|
+
name: null,
|
|
25
|
+
email: null,
|
|
26
|
+
email_verified: false,
|
|
27
|
+
selected_team_id: null,
|
|
28
|
+
signed_up_at: nowSeconds,
|
|
29
|
+
is_anonymous: false,
|
|
30
|
+
is_restricted: false,
|
|
31
|
+
restricted_reason: null,
|
|
32
|
+
requires_totp_mfa: false,
|
|
33
|
+
}),
|
|
34
|
+
"",
|
|
35
|
+
].join(".");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createAccessOnlySession(accessToken: string): InternalSession {
|
|
39
|
+
return new InternalSession({
|
|
40
|
+
refreshAccessTokenCallback: async () => null,
|
|
41
|
+
refreshToken: null,
|
|
42
|
+
accessToken,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const currentToken = (session: InternalSession) => session.getAccessTokenIfNotExpiredYet(20_000, null)?.token;
|
|
47
|
+
|
|
48
|
+
describe("InternalSession.calculateSessionKey", () => {
|
|
49
|
+
it("keys by the refresh token when one is present (ignoring any access token)", () => {
|
|
50
|
+
expect(InternalSession.calculateSessionKey({ refreshToken: "rt-abc" })).toBe("refresh-rt-abc");
|
|
51
|
+
expect(InternalSession.calculateSessionKey({ refreshToken: "rt-abc", accessToken: createAccessTokenString("rtid-1") }))
|
|
52
|
+
.toBe("refresh-rt-abc");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns not-logged-in when neither token is present", () => {
|
|
56
|
+
expect(InternalSession.calculateSessionKey({ refreshToken: null })).toBe("not-logged-in");
|
|
57
|
+
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: null })).toBe("not-logged-in");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keys an access-only session by its refresh_token_id", () => {
|
|
61
|
+
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-1") }))
|
|
62
|
+
.toBe("access-session-rtid-1");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("is stable across re-minted access tokens for the same session (the regression this fixes)", () => {
|
|
66
|
+
const first = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 });
|
|
67
|
+
const second = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 });
|
|
68
|
+
expect(second).not.toBe(first);
|
|
69
|
+
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: second }))
|
|
70
|
+
.toBe(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: first }));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("distinguishes access-only sessions with different refresh_token_ids", () => {
|
|
74
|
+
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-1") }))
|
|
75
|
+
.not.toBe(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-2") }));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("falls back to the raw token when the access token can't be decoded", () => {
|
|
79
|
+
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: "not-a-jwt" })).toBe("access-not-a-jwt");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("InternalSession#updateAccessToken", () => {
|
|
84
|
+
it("installs a fresh token for the same access-only session in place", () => {
|
|
85
|
+
const initial = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 });
|
|
86
|
+
const refreshed = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 });
|
|
87
|
+
const session = createAccessOnlySession(initial);
|
|
88
|
+
|
|
89
|
+
session.updateAccessToken({ accessToken: refreshed, refreshToken: null });
|
|
90
|
+
expect(currentToken(session)).toBe(refreshed);
|
|
91
|
+
// identity is unchanged — same session key, same object
|
|
92
|
+
expect(session.sessionKey).toBe("access-session-rtid-1");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects a token pair belonging to a different access-only session", () => {
|
|
96
|
+
const initial = createAccessTokenString("rtid-1");
|
|
97
|
+
const foreign = createAccessTokenString("rtid-2", { sub: "other-user" });
|
|
98
|
+
const session = createAccessOnlySession(initial);
|
|
99
|
+
|
|
100
|
+
session.updateAccessToken({ accessToken: foreign, refreshToken: null });
|
|
101
|
+
expect(currentToken(session)).toBe(initial);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("is a no-op for an unchanged, null, or undecodable token", () => {
|
|
105
|
+
const initial = createAccessTokenString("rtid-1");
|
|
106
|
+
const session = createAccessOnlySession(initial);
|
|
107
|
+
|
|
108
|
+
session.updateAccessToken({ accessToken: initial, refreshToken: null });
|
|
109
|
+
session.updateAccessToken({ accessToken: null, refreshToken: null });
|
|
110
|
+
session.updateAccessToken({ accessToken: "not-a-jwt", refreshToken: null });
|
|
111
|
+
expect(currentToken(session)).toBe(initial);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("never revives an invalidated session", () => {
|
|
115
|
+
const session = createAccessOnlySession(createAccessTokenString("rtid-1"));
|
|
116
|
+
session.markInvalid();
|
|
117
|
+
|
|
118
|
+
session.updateAccessToken({ accessToken: createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 }), refreshToken: null });
|
|
119
|
+
expect(session.isKnownToBeInvalid()).toBe(true);
|
|
120
|
+
expect(currentToken(session)).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("updates a refresh-token-backed session's access token in place when the refresh token matches", () => {
|
|
124
|
+
const session = new InternalSession({
|
|
125
|
+
refreshAccessTokenCallback: async () => null,
|
|
126
|
+
refreshToken: "rt-abc",
|
|
127
|
+
accessToken: createAccessTokenString("rtid-1"),
|
|
128
|
+
});
|
|
129
|
+
const refreshed = createAccessTokenString("rtid-2", { iatOffsetSeconds: 1 });
|
|
130
|
+
|
|
131
|
+
session.updateAccessToken({ accessToken: refreshed, refreshToken: "rt-abc" });
|
|
132
|
+
expect(currentToken(session)).toBe(refreshed);
|
|
133
|
+
expect(session.sessionKey).toBe("refresh-rt-abc");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects a token pair carrying a different refresh token for a refresh-backed session", () => {
|
|
137
|
+
const initial = createAccessTokenString("rtid-1");
|
|
138
|
+
const session = new InternalSession({
|
|
139
|
+
refreshAccessTokenCallback: async () => null,
|
|
140
|
+
refreshToken: "rt-abc",
|
|
141
|
+
accessToken: initial,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
session.updateAccessToken({ accessToken: createAccessTokenString("rtid-2"), refreshToken: "rt-other" });
|
|
145
|
+
expect(currentToken(session)).toBe(initial);
|
|
146
|
+
});
|
|
147
|
+
});
|