@hexclave/shared 1.0.10 → 1.0.12

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.
Files changed (77) hide show
  1. package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.d.ts.map +1 -1
  2. package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js +11 -4
  3. package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js.map +1 -1
  4. package/dist/config/schema.d.ts +172 -172
  5. package/dist/config-rendering.d.ts.map +1 -1
  6. package/dist/config-rendering.js +7 -2
  7. package/dist/config-rendering.js.map +1 -1
  8. package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.d.ts.map +1 -1
  9. package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js +11 -4
  10. package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js.map +1 -1
  11. package/dist/esm/config/schema.d.ts +172 -172
  12. package/dist/esm/config-rendering.d.ts.map +1 -1
  13. package/dist/esm/config-rendering.js +7 -2
  14. package/dist/esm/config-rendering.js.map +1 -1
  15. package/dist/esm/hexclave-config-file.d.ts.map +1 -1
  16. package/dist/esm/hexclave-config-file.js +2 -1
  17. package/dist/esm/hexclave-config-file.js.map +1 -1
  18. package/dist/esm/interface/admin-interface.d.ts +9 -1
  19. package/dist/esm/interface/admin-interface.d.ts.map +1 -1
  20. package/dist/esm/interface/admin-interface.js +32 -2
  21. package/dist/esm/interface/admin-interface.js.map +1 -1
  22. package/dist/esm/interface/admin-metrics.d.ts +123 -7
  23. package/dist/esm/interface/admin-metrics.d.ts.map +1 -1
  24. package/dist/esm/interface/admin-metrics.js +21 -2
  25. package/dist/esm/interface/admin-metrics.js.map +1 -1
  26. package/dist/esm/interface/conversations.d.ts +3 -3
  27. package/dist/esm/interface/crud/current-user.d.ts +5 -5
  28. package/dist/esm/interface/crud/email-outbox.d.ts +64 -64
  29. package/dist/esm/interface/crud/project-api-keys.d.ts +1 -1
  30. package/dist/esm/interface/crud/team-member-profiles.d.ts +8 -8
  31. package/dist/esm/interface/crud/users.d.ts +2 -2
  32. package/dist/esm/schema-fields.d.ts.map +1 -1
  33. package/dist/esm/schema-fields.js +1 -1
  34. package/dist/esm/schema-fields.js.map +1 -1
  35. package/dist/esm/sessions.d.ts +17 -7
  36. package/dist/esm/sessions.d.ts.map +1 -1
  37. package/dist/esm/sessions.js +20 -2
  38. package/dist/esm/sessions.js.map +1 -1
  39. package/dist/esm/sessions.test.d.ts +1 -0
  40. package/dist/esm/sessions.test.js +178 -0
  41. package/dist/esm/sessions.test.js.map +1 -0
  42. package/dist/hexclave-config-file.d.ts.map +1 -1
  43. package/dist/hexclave-config-file.js +2 -1
  44. package/dist/hexclave-config-file.js.map +1 -1
  45. package/dist/interface/admin-interface.d.ts +9 -1
  46. package/dist/interface/admin-interface.d.ts.map +1 -1
  47. package/dist/interface/admin-interface.js +32 -2
  48. package/dist/interface/admin-interface.js.map +1 -1
  49. package/dist/interface/admin-metrics.d.ts +123 -7
  50. package/dist/interface/admin-metrics.d.ts.map +1 -1
  51. package/dist/interface/admin-metrics.js +22 -1
  52. package/dist/interface/admin-metrics.js.map +1 -1
  53. package/dist/interface/conversations.d.ts +3 -3
  54. package/dist/interface/crud/current-user.d.ts +5 -5
  55. package/dist/interface/crud/email-outbox.d.ts +64 -64
  56. package/dist/interface/crud/project-api-keys.d.ts +1 -1
  57. package/dist/interface/crud/team-member-profiles.d.ts +8 -8
  58. package/dist/interface/crud/users.d.ts +2 -2
  59. package/dist/schema-fields.d.ts.map +1 -1
  60. package/dist/schema-fields.js +1 -1
  61. package/dist/schema-fields.js.map +1 -1
  62. package/dist/sessions.d.ts +17 -7
  63. package/dist/sessions.d.ts.map +1 -1
  64. package/dist/sessions.js +20 -2
  65. package/dist/sessions.js.map +1 -1
  66. package/dist/sessions.test.d.ts +1 -0
  67. package/dist/sessions.test.js +178 -0
  68. package/dist/sessions.test.js.map +1 -0
  69. package/package.json +1 -1
  70. package/src/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.ts +11 -4
  71. package/src/config-rendering.ts +11 -2
  72. package/src/hexclave-config-file.ts +7 -1
  73. package/src/interface/admin-interface.ts +49 -2
  74. package/src/interface/admin-metrics.ts +33 -2
  75. package/src/schema-fields.ts +4 -1
  76. package/src/sessions.test.ts +147 -0
  77. package/src/sessions.ts +26 -0
@@ -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
+ });
package/src/sessions.ts CHANGED
@@ -124,6 +124,14 @@ export class InternalSession {
124
124
  if (ofTokens.refreshToken) {
125
125
  return `refresh-${ofTokens.refreshToken}`;
126
126
  } else if (ofTokens.accessToken) {
127
+ // Access-only sessions (no refresh token) are keyed by the underlying session's `refresh_token_id`, not the
128
+ // access token string: access tokens get re-minted frequently, and keying by the raw token would spawn a new
129
+ // session (and cold-invalidate every session-scoped cache) on each refresh. Falls back to the raw token if
130
+ // the JWT can't be decoded.
131
+ const refreshTokenId = decodeAccessTokenIfValid(ofTokens.accessToken)?.refresh_token_id;
132
+ if (refreshTokenId) {
133
+ return `access-session-${refreshTokenId}`;
134
+ }
127
135
  return `access-${ofTokens.accessToken}`;
128
136
  } else {
129
137
  return "not-logged-in";
@@ -210,6 +218,24 @@ export class InternalSession {
210
218
  return accessToken ? { accessToken, refreshToken: this._refreshToken } : null;
211
219
  }
212
220
 
221
+ /**
222
+ * Installs a freshly obtained token pair's access token into this session in place, keeping the session object
223
+ * (and therefore every session-scoped cache) stable instead of constructing a new InternalSession. No-op if the
224
+ * session is invalid, the access token can't be decoded, it's unchanged, or the pair doesn't map to this session
225
+ * (so a foreign token can never be written into this object's cache); never clears an existing token.
226
+ */
227
+ updateAccessToken(tokens: { accessToken: string | null, refreshToken: string | null }) {
228
+ if (this._knownToBeInvalid.get()) return;
229
+ if (!tokens.accessToken) return;
230
+ const newAccessToken = AccessToken.createIfValid(tokens.accessToken);
231
+ if (!newAccessToken) return;
232
+ // Self-enforce the "a session never changes which session it belongs to" invariant: only install a token pair
233
+ // that maps to this same session key (validated against the incoming pair, not this session's existing tokens).
234
+ if (InternalSession.calculateSessionKey(tokens) !== this.sessionKey) return;
235
+ if (this._accessToken.get()?.token === newAccessToken.token) return;
236
+ this._accessToken.set(newAccessToken);
237
+ }
238
+
213
239
  /**
214
240
  * Manually mark the access token as expired, even if the date on its payload may still be valid.
215
241
  *