@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
@@ -1 +1 @@
1
- {"version":3,"file":"sessions.js","names":["accessTokenPayloadSchema","HexclaveAssertionError","Store"],"sources":["../src/sessions.ts"],"sourcesContent":["import * as jose from 'jose';\nimport { InferType } from 'yup';\nimport { accessTokenPayloadSchema } from './schema-fields';\nimport { HexclaveAssertionError, throwErr } from \"./utils/errors\";\nimport { runAsynchronously, wait } from './utils/promises';\nimport { Store } from \"./utils/stores\";\n\n\nexport type AccessTokenPayload = InferType<typeof accessTokenPayloadSchema>;\n\nfunction decodeAccessTokenIfValid(token: string): AccessTokenPayload | null {\n try {\n const payload = jose.decodeJwt(token);\n return accessTokenPayloadSchema.validateSync(payload);\n } catch (e) {\n return null;\n }\n}\n\nexport class AccessToken {\n static createIfValid(token: string): AccessToken | null {\n const payload = decodeAccessTokenIfValid(token);\n if (!payload) return null;\n return new AccessToken(token);\n }\n\n private constructor(\n public readonly token: string,\n ) {\n if (token === \"undefined\") {\n throw new HexclaveAssertionError(\"Access token is the string 'undefined'; it's unlikely this is the correct value. They're supposed to be unguessable!\");\n }\n }\n\n get payload() {\n return decodeAccessTokenIfValid(this.token) ?? throwErr(\"Invalid access token in payload (should've been validated in createIfValid)\", { token: this.token });\n }\n\n get expiresAt(): Date {\n const { exp } = this.payload;\n if (exp === undefined) return new Date(8640000000000000); // max date value\n return new Date(exp * 1000);\n }\n\n get issuedAt(): Date {\n const { iat } = this.payload;\n return new Date(iat * 1000);\n }\n\n /**\n * @returns The number of milliseconds until the access token expires, or 0 if it has already expired.\n */\n get expiresInMillis(): number {\n return Math.max(0, this.expiresAt.getTime() - Date.now());\n }\n\n get issuedMillisAgo(): number {\n return Math.max(0, Date.now() - this.issuedAt.getTime());\n }\n\n isExpired(): boolean {\n return this.expiresInMillis <= 0;\n }\n}\n\nexport class RefreshToken {\n constructor(\n public readonly token: string,\n ) {\n if (token === \"undefined\") {\n throw new HexclaveAssertionError(\"Refresh token is the string 'undefined'; it's unlikely this is the correct value. They're supposed to be unguessable!\");\n }\n }\n}\n\n/**\n * An InternalSession represents a user's session, which may or may not be valid. It may contain an access token, a refresh token, or both.\n *\n * A session never changes which user or session it belongs to, but the tokens in it may change over time.\n */\nexport class InternalSession {\n /**\n * Each session has a session key that depends on the tokens inside. If the session has a refresh token, the session key depends only on the refresh token. If the session does not have a refresh token, the session key depends only on the access token.\n *\n * Multiple Session objects may have the same session key, which implies that they represent the same session by the same user. Furthermore, a session's key never changes over the lifetime of a session object.\n *\n * This is useful for caching and indexing sessions.\n */\n public readonly sessionKey: string;\n\n /**\n * An access token that is not known to be invalid (ie. may be valid, but may have expired).\n */\n private _accessToken: Store<AccessToken | null>;\n private readonly _refreshToken: RefreshToken | null;\n\n /**\n * Whether the session as a whole is known to be invalid (ie. both access and refresh tokens are invalid). Used as a cache to avoid making multiple requests to the server (sessions never go back to being valid after being invalidated).\n *\n * It is possible for the access token to be invalid but the refresh token to be valid, in which case the session is\n * still valid (just needs a refresh). It is also possible for the access token to be valid but the refresh token to\n * be invalid, in which case the session is also valid (eg. if the refresh token is null because the user only passed\n * in an access token, eg. in a server-side request handler).\n */\n private _knownToBeInvalid = new Store<boolean>(false);\n\n private _refreshPromise: Promise<AccessToken | null> | null = null;\n\n constructor(private readonly _options: {\n refreshAccessTokenCallback(refreshToken: RefreshToken): Promise<AccessToken | null>,\n refreshToken: string | null,\n accessToken?: string | null,\n }) {\n this._accessToken = new Store(_options.accessToken ? AccessToken.createIfValid(_options.accessToken) : null);\n this._refreshToken = _options.refreshToken ? new RefreshToken(_options.refreshToken) : null;\n if (_options.accessToken === null && _options.refreshToken === null) {\n // this session is already invalid\n this._knownToBeInvalid.set(true);\n }\n this.sessionKey = InternalSession.calculateSessionKey({ accessToken: _options.accessToken ?? null, refreshToken: _options.refreshToken });\n }\n\n static calculateSessionKey(ofTokens: { refreshToken: string | null, accessToken?: string | null }): string {\n if (ofTokens.refreshToken) {\n return `refresh-${ofTokens.refreshToken}`;\n } else if (ofTokens.accessToken) {\n return `access-${ofTokens.accessToken}`;\n } else {\n return \"not-logged-in\";\n }\n }\n\n isKnownToBeInvalid() {\n return this._knownToBeInvalid.get();\n }\n\n /**\n * Marks the session object as invalid, meaning that the refresh and access tokens can no longer be used. There is no\n * way out of this state, and the session object will never return valid tokens again.\n */\n markInvalid() {\n this._accessToken.set(null);\n this._knownToBeInvalid.set(true);\n }\n\n onInvalidate(callback: () => void): { unsubscribe: () => void } {\n return this._knownToBeInvalid.onChange(() => callback());\n }\n\n getRefreshToken(): RefreshToken | null {\n if (this.isKnownToBeInvalid()) return null;\n return this._refreshToken;\n }\n\n /**\n * Returns the access token if it is found in the cache and not expired yet, or null otherwise. Never fetches new tokens.\n */\n getAccessTokenIfNotExpiredYet(minMillisUntilExpiration: number, maxMillisSinceIssued: number | null): AccessToken | null {\n if (minMillisUntilExpiration > 45_000) {\n throw new Error(`Required access token expiry ${minMillisUntilExpiration}ms is too long; access tokens are too short to be used for more than 45s`);\n }\n if (maxMillisSinceIssued !== null && maxMillisSinceIssued < 15_000) {\n throw new Error(`Required access token issuance ${maxMillisSinceIssued}ms is too short; assume that access token generation can take at least 15s`);\n }\n\n const accessToken = this._getPotentiallyInvalidAccessTokenIfAvailable();\n if (!accessToken || accessToken.expiresInMillis < minMillisUntilExpiration) return null;\n if (maxMillisSinceIssued !== null && accessToken.issuedMillisAgo > maxMillisSinceIssued) return null;\n return accessToken;\n }\n\n /**\n * Returns the access token if it is found in the cache, fetching it otherwise.\n *\n * This is usually the function you want to call to get an access token. Either set `minMillisUntilExpiration` to a reasonable value, or catch errors that occur if it expires, and call `markAccessTokenExpired` to mark the token as expired if so (after which a call to this function will always refetch the token).\n *\n * @returns null if the session is known to be invalid, cached tokens if they exist in the cache and the access token hasn't expired yet (the refresh token might still be invalid), or new tokens otherwise.\n */\n async getOrFetchLikelyValidTokens(minMillisUntilExpiration: number, maxMillisSinceIssued: number | null): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> {\n // fast path to save a roundtrip to the server if the session is known to be invalid\n if (this.isKnownToBeInvalid()) return null;\n\n const accessToken = this.getAccessTokenIfNotExpiredYet(minMillisUntilExpiration, maxMillisSinceIssued);\n if (!accessToken) {\n const newTokens = await this.fetchNewTokens();\n const expiresInMillis = newTokens?.accessToken.expiresInMillis;\n const issuedMillisAgo = newTokens?.accessToken.issuedMillisAgo;\n if (expiresInMillis !== undefined && expiresInMillis < minMillisUntilExpiration) {\n throw new HexclaveAssertionError(`Required access token expiry ${minMillisUntilExpiration}ms is too long; access tokens are too short when they're generated (${expiresInMillis}ms)`);\n }\n if (maxMillisSinceIssued !== null && issuedMillisAgo !== undefined && issuedMillisAgo > maxMillisSinceIssued) {\n throw new HexclaveAssertionError(`Required access token issuance ${maxMillisSinceIssued}ms is too short; access token issuance is too slow (${issuedMillisAgo}ms)`);\n }\n return newTokens;\n }\n return { accessToken, refreshToken: this.getRefreshToken() };\n }\n\n /**\n * Fetches new tokens that are, at the time of fetching, guaranteed to be valid.\n *\n * The newly generated tokens are short-lived, so it's good practice not to rely on their validity (if possible). However, this function is useful in some cases where you only want to pass access tokens to a service, and you want to make sure said access token has the longest possible lifetime.\n *\n * In most cases, you should prefer `getOrFetchLikelyValidTokens`.\n *\n * @returns null if the session is known to be invalid, or new tokens otherwise (which, at the time of fetching, are guaranteed to be valid).\n */\n async fetchNewTokens(): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> {\n const accessToken = await this._getNewlyFetchedAccessToken();\n return accessToken ? { accessToken, refreshToken: this._refreshToken } : null;\n }\n\n /**\n * Manually mark the access token as expired, even if the date on its payload may still be valid.\n *\n * You don't usually have to call this function anymore, but you may want to call suggestAccessTokenExpired\n * to hint that the access token should be refreshed as its data may have changed, if possible.\n */\n markAccessTokenExpired(accessToken?: AccessToken) {\n if (!accessToken || this._accessToken.get()?.token === accessToken.token) {\n this._accessToken.set(null);\n }\n }\n\n /**\n * Strongly suggests that the access token should be refreshed as its data may have changed, although it's up to this\n * implementation to decide whether or when the access token will be refreshed.\n *\n * This is particularly useful when the data associated with the access token may have changed for example due to an\n * update to the user's profile.\n *\n * The current implementation marks the access token as expired if and only if a refresh token is available (regardless of\n * whether the refresh token is actually valid or not), although this is not a guarantee and subject to change.\n *\n * If you need a stronger guarantee of revoking an access token, use markAccessTokenExpired instead.\n */\n suggestAccessTokenExpired(): void {\n if (this._refreshToken) {\n this.markAccessTokenExpired();\n }\n }\n\n startRefreshingAccessToken(minMillisUntilExpiration: number, maxMillisSinceIssued: number | null): { unsubscribe: () => void } {\n let canceled = false;\n runAsynchronously(async () => {\n while (!canceled) {\n const tokens = await this.getOrFetchLikelyValidTokens(minMillisUntilExpiration, maxMillisSinceIssued);\n if (!tokens) return; // session is invalid, stop refreshing\n const nextRefreshIn = Math.min(\n tokens.accessToken.expiresInMillis - minMillisUntilExpiration,\n (maxMillisSinceIssued ?? Infinity) - tokens.accessToken.issuedMillisAgo,\n );\n await wait(Math.max(1, nextRefreshIn));\n }\n });\n return {\n unsubscribe: () => {\n canceled = true;\n },\n };\n }\n\n /**\n * Note that a callback invocation with `null` does not mean the session has been invalidated; the access token may just have expired. Use `onInvalidate` to detect invalidation.\n */\n onAccessTokenChange(callback: (newAccessToken: AccessToken | null) => void): { unsubscribe: () => void } {\n return this._accessToken.onChange(callback);\n }\n\n /**\n * @returns An access token, which may be expired or expire soon, or null if it is known to be invalid.\n */\n private _getPotentiallyInvalidAccessTokenIfAvailable(): AccessToken | null {\n if (this.isKnownToBeInvalid()) return null;\n\n const accessToken = this._accessToken.get();\n if (accessToken && !accessToken.isExpired()) return accessToken;\n\n return null;\n }\n\n /**\n * You should prefer `_getOrFetchPotentiallyInvalidAccessToken` in almost all cases.\n *\n * @returns A newly fetched access token (never read from cache), or null if the session either does not represent a user or the session is invalid.\n */\n private async _getNewlyFetchedAccessToken(): Promise<AccessToken | null> {\n if (!this._refreshToken) return null;\n if (this._knownToBeInvalid.get()) return null;\n\n if (!this._refreshPromise) {\n this._refreshAndSetRefreshPromise(this._refreshToken);\n }\n return await this._refreshPromise;\n }\n\n private _refreshAndSetRefreshPromise(refreshToken: RefreshToken) {\n let refreshPromise: Promise<AccessToken | null> = this._options.refreshAccessTokenCallback(refreshToken).then((accessToken) => {\n if (refreshPromise === this._refreshPromise) {\n this._refreshPromise = null;\n this._accessToken.set(accessToken);\n if (!accessToken) {\n this.markInvalid();\n }\n }\n return accessToken;\n });\n this._refreshPromise = refreshPromise;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sessions.js","names":["accessTokenPayloadSchema","HexclaveAssertionError","Store"],"sources":["../src/sessions.ts"],"sourcesContent":["import * as jose from 'jose';\nimport { InferType } from 'yup';\nimport { accessTokenPayloadSchema } from './schema-fields';\nimport { HexclaveAssertionError, throwErr } from \"./utils/errors\";\nimport { runAsynchronously, wait } from './utils/promises';\nimport { Store } from \"./utils/stores\";\n\n\nexport type AccessTokenPayload = InferType<typeof accessTokenPayloadSchema>;\n\nfunction decodeAccessTokenIfValid(token: string): AccessTokenPayload | null {\n try {\n const payload = jose.decodeJwt(token);\n return accessTokenPayloadSchema.validateSync(payload);\n } catch (e) {\n return null;\n }\n}\n\nexport class AccessToken {\n static createIfValid(token: string): AccessToken | null {\n const payload = decodeAccessTokenIfValid(token);\n if (!payload) return null;\n return new AccessToken(token);\n }\n\n private constructor(\n public readonly token: string,\n ) {\n if (token === \"undefined\") {\n throw new HexclaveAssertionError(\"Access token is the string 'undefined'; it's unlikely this is the correct value. They're supposed to be unguessable!\");\n }\n }\n\n get payload() {\n return decodeAccessTokenIfValid(this.token) ?? throwErr(\"Invalid access token in payload (should've been validated in createIfValid)\", { token: this.token });\n }\n\n get expiresAt(): Date {\n const { exp } = this.payload;\n if (exp === undefined) return new Date(8640000000000000); // max date value\n return new Date(exp * 1000);\n }\n\n get issuedAt(): Date {\n const { iat } = this.payload;\n return new Date(iat * 1000);\n }\n\n /**\n * @returns The number of milliseconds until the access token expires, or 0 if it has already expired.\n */\n get expiresInMillis(): number {\n return Math.max(0, this.expiresAt.getTime() - Date.now());\n }\n\n get issuedMillisAgo(): number {\n return Math.max(0, Date.now() - this.issuedAt.getTime());\n }\n\n isExpired(): boolean {\n return this.expiresInMillis <= 0;\n }\n}\n\nexport class RefreshToken {\n constructor(\n public readonly token: string,\n ) {\n if (token === \"undefined\") {\n throw new HexclaveAssertionError(\"Refresh token is the string 'undefined'; it's unlikely this is the correct value. They're supposed to be unguessable!\");\n }\n }\n}\n\n/**\n * An InternalSession represents a user's session, which may or may not be valid. It may contain an access token, a refresh token, or both.\n *\n * A session never changes which user or session it belongs to, but the tokens in it may change over time.\n */\nexport class InternalSession {\n /**\n * Each session has a session key that depends on the tokens inside. If the session has a refresh token, the session key depends only on the refresh token. If the session does not have a refresh token, the session key depends only on the access token.\n *\n * Multiple Session objects may have the same session key, which implies that they represent the same session by the same user. Furthermore, a session's key never changes over the lifetime of a session object.\n *\n * This is useful for caching and indexing sessions.\n */\n public readonly sessionKey: string;\n\n /**\n * An access token that is not known to be invalid (ie. may be valid, but may have expired).\n */\n private _accessToken: Store<AccessToken | null>;\n private readonly _refreshToken: RefreshToken | null;\n\n /**\n * Whether the session as a whole is known to be invalid (ie. both access and refresh tokens are invalid). Used as a cache to avoid making multiple requests to the server (sessions never go back to being valid after being invalidated).\n *\n * It is possible for the access token to be invalid but the refresh token to be valid, in which case the session is\n * still valid (just needs a refresh). It is also possible for the access token to be valid but the refresh token to\n * be invalid, in which case the session is also valid (eg. if the refresh token is null because the user only passed\n * in an access token, eg. in a server-side request handler).\n */\n private _knownToBeInvalid = new Store<boolean>(false);\n\n private _refreshPromise: Promise<AccessToken | null> | null = null;\n\n constructor(private readonly _options: {\n refreshAccessTokenCallback(refreshToken: RefreshToken): Promise<AccessToken | null>,\n refreshToken: string | null,\n accessToken?: string | null,\n }) {\n this._accessToken = new Store(_options.accessToken ? AccessToken.createIfValid(_options.accessToken) : null);\n this._refreshToken = _options.refreshToken ? new RefreshToken(_options.refreshToken) : null;\n if (_options.accessToken === null && _options.refreshToken === null) {\n // this session is already invalid\n this._knownToBeInvalid.set(true);\n }\n this.sessionKey = InternalSession.calculateSessionKey({ accessToken: _options.accessToken ?? null, refreshToken: _options.refreshToken });\n }\n\n static calculateSessionKey(ofTokens: { refreshToken: string | null, accessToken?: string | null }): string {\n if (ofTokens.refreshToken) {\n return `refresh-${ofTokens.refreshToken}`;\n } else if (ofTokens.accessToken) {\n // Access-only sessions (no refresh token) are keyed by the underlying session's `refresh_token_id`, not the\n // access token string: access tokens get re-minted frequently, and keying by the raw token would spawn a new\n // session (and cold-invalidate every session-scoped cache) on each refresh. Falls back to the raw token if\n // the JWT can't be decoded.\n const refreshTokenId = decodeAccessTokenIfValid(ofTokens.accessToken)?.refresh_token_id;\n if (refreshTokenId) {\n return `access-session-${refreshTokenId}`;\n }\n return `access-${ofTokens.accessToken}`;\n } else {\n return \"not-logged-in\";\n }\n }\n\n isKnownToBeInvalid() {\n return this._knownToBeInvalid.get();\n }\n\n /**\n * Marks the session object as invalid, meaning that the refresh and access tokens can no longer be used. There is no\n * way out of this state, and the session object will never return valid tokens again.\n */\n markInvalid() {\n this._accessToken.set(null);\n this._knownToBeInvalid.set(true);\n }\n\n onInvalidate(callback: () => void): { unsubscribe: () => void } {\n return this._knownToBeInvalid.onChange(() => callback());\n }\n\n getRefreshToken(): RefreshToken | null {\n if (this.isKnownToBeInvalid()) return null;\n return this._refreshToken;\n }\n\n /**\n * Returns the access token if it is found in the cache and not expired yet, or null otherwise. Never fetches new tokens.\n */\n getAccessTokenIfNotExpiredYet(minMillisUntilExpiration: number, maxMillisSinceIssued: number | null): AccessToken | null {\n if (minMillisUntilExpiration > 45_000) {\n throw new Error(`Required access token expiry ${minMillisUntilExpiration}ms is too long; access tokens are too short to be used for more than 45s`);\n }\n if (maxMillisSinceIssued !== null && maxMillisSinceIssued < 15_000) {\n throw new Error(`Required access token issuance ${maxMillisSinceIssued}ms is too short; assume that access token generation can take at least 15s`);\n }\n\n const accessToken = this._getPotentiallyInvalidAccessTokenIfAvailable();\n if (!accessToken || accessToken.expiresInMillis < minMillisUntilExpiration) return null;\n if (maxMillisSinceIssued !== null && accessToken.issuedMillisAgo > maxMillisSinceIssued) return null;\n return accessToken;\n }\n\n /**\n * Returns the access token if it is found in the cache, fetching it otherwise.\n *\n * This is usually the function you want to call to get an access token. Either set `minMillisUntilExpiration` to a reasonable value, or catch errors that occur if it expires, and call `markAccessTokenExpired` to mark the token as expired if so (after which a call to this function will always refetch the token).\n *\n * @returns null if the session is known to be invalid, cached tokens if they exist in the cache and the access token hasn't expired yet (the refresh token might still be invalid), or new tokens otherwise.\n */\n async getOrFetchLikelyValidTokens(minMillisUntilExpiration: number, maxMillisSinceIssued: number | null): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> {\n // fast path to save a roundtrip to the server if the session is known to be invalid\n if (this.isKnownToBeInvalid()) return null;\n\n const accessToken = this.getAccessTokenIfNotExpiredYet(minMillisUntilExpiration, maxMillisSinceIssued);\n if (!accessToken) {\n const newTokens = await this.fetchNewTokens();\n const expiresInMillis = newTokens?.accessToken.expiresInMillis;\n const issuedMillisAgo = newTokens?.accessToken.issuedMillisAgo;\n if (expiresInMillis !== undefined && expiresInMillis < minMillisUntilExpiration) {\n throw new HexclaveAssertionError(`Required access token expiry ${minMillisUntilExpiration}ms is too long; access tokens are too short when they're generated (${expiresInMillis}ms)`);\n }\n if (maxMillisSinceIssued !== null && issuedMillisAgo !== undefined && issuedMillisAgo > maxMillisSinceIssued) {\n throw new HexclaveAssertionError(`Required access token issuance ${maxMillisSinceIssued}ms is too short; access token issuance is too slow (${issuedMillisAgo}ms)`);\n }\n return newTokens;\n }\n return { accessToken, refreshToken: this.getRefreshToken() };\n }\n\n /**\n * Fetches new tokens that are, at the time of fetching, guaranteed to be valid.\n *\n * The newly generated tokens are short-lived, so it's good practice not to rely on their validity (if possible). However, this function is useful in some cases where you only want to pass access tokens to a service, and you want to make sure said access token has the longest possible lifetime.\n *\n * In most cases, you should prefer `getOrFetchLikelyValidTokens`.\n *\n * @returns null if the session is known to be invalid, or new tokens otherwise (which, at the time of fetching, are guaranteed to be valid).\n */\n async fetchNewTokens(): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> {\n const accessToken = await this._getNewlyFetchedAccessToken();\n return accessToken ? { accessToken, refreshToken: this._refreshToken } : null;\n }\n\n /**\n * Installs a freshly obtained token pair's access token into this session in place, keeping the session object\n * (and therefore every session-scoped cache) stable instead of constructing a new InternalSession. No-op if the\n * session is invalid, the access token can't be decoded, it's unchanged, or the pair doesn't map to this session\n * (so a foreign token can never be written into this object's cache); never clears an existing token.\n */\n updateAccessToken(tokens: { accessToken: string | null, refreshToken: string | null }) {\n if (this._knownToBeInvalid.get()) return;\n if (!tokens.accessToken) return;\n const newAccessToken = AccessToken.createIfValid(tokens.accessToken);\n if (!newAccessToken) return;\n // Self-enforce the \"a session never changes which session it belongs to\" invariant: only install a token pair\n // that maps to this same session key (validated against the incoming pair, not this session's existing tokens).\n if (InternalSession.calculateSessionKey(tokens) !== this.sessionKey) return;\n if (this._accessToken.get()?.token === newAccessToken.token) return;\n this._accessToken.set(newAccessToken);\n }\n\n /**\n * Manually mark the access token as expired, even if the date on its payload may still be valid.\n *\n * You don't usually have to call this function anymore, but you may want to call suggestAccessTokenExpired\n * to hint that the access token should be refreshed as its data may have changed, if possible.\n */\n markAccessTokenExpired(accessToken?: AccessToken) {\n if (!accessToken || this._accessToken.get()?.token === accessToken.token) {\n this._accessToken.set(null);\n }\n }\n\n /**\n * Strongly suggests that the access token should be refreshed as its data may have changed, although it's up to this\n * implementation to decide whether or when the access token will be refreshed.\n *\n * This is particularly useful when the data associated with the access token may have changed for example due to an\n * update to the user's profile.\n *\n * The current implementation marks the access token as expired if and only if a refresh token is available (regardless of\n * whether the refresh token is actually valid or not), although this is not a guarantee and subject to change.\n *\n * If you need a stronger guarantee of revoking an access token, use markAccessTokenExpired instead.\n */\n suggestAccessTokenExpired(): void {\n if (this._refreshToken) {\n this.markAccessTokenExpired();\n }\n }\n\n startRefreshingAccessToken(minMillisUntilExpiration: number, maxMillisSinceIssued: number | null): { unsubscribe: () => void } {\n let canceled = false;\n runAsynchronously(async () => {\n while (!canceled) {\n const tokens = await this.getOrFetchLikelyValidTokens(minMillisUntilExpiration, maxMillisSinceIssued);\n if (!tokens) return; // session is invalid, stop refreshing\n const nextRefreshIn = Math.min(\n tokens.accessToken.expiresInMillis - minMillisUntilExpiration,\n (maxMillisSinceIssued ?? Infinity) - tokens.accessToken.issuedMillisAgo,\n );\n await wait(Math.max(1, nextRefreshIn));\n }\n });\n return {\n unsubscribe: () => {\n canceled = true;\n },\n };\n }\n\n /**\n * Note that a callback invocation with `null` does not mean the session has been invalidated; the access token may just have expired. Use `onInvalidate` to detect invalidation.\n */\n onAccessTokenChange(callback: (newAccessToken: AccessToken | null) => void): { unsubscribe: () => void } {\n return this._accessToken.onChange(callback);\n }\n\n /**\n * @returns An access token, which may be expired or expire soon, or null if it is known to be invalid.\n */\n private _getPotentiallyInvalidAccessTokenIfAvailable(): AccessToken | null {\n if (this.isKnownToBeInvalid()) return null;\n\n const accessToken = this._accessToken.get();\n if (accessToken && !accessToken.isExpired()) return accessToken;\n\n return null;\n }\n\n /**\n * You should prefer `_getOrFetchPotentiallyInvalidAccessToken` in almost all cases.\n *\n * @returns A newly fetched access token (never read from cache), or null if the session either does not represent a user or the session is invalid.\n */\n private async _getNewlyFetchedAccessToken(): Promise<AccessToken | null> {\n if (!this._refreshToken) return null;\n if (this._knownToBeInvalid.get()) return null;\n\n if (!this._refreshPromise) {\n this._refreshAndSetRefreshPromise(this._refreshToken);\n }\n return await this._refreshPromise;\n }\n\n private _refreshAndSetRefreshPromise(refreshToken: RefreshToken) {\n let refreshPromise: Promise<AccessToken | null> = this._options.refreshAccessTokenCallback(refreshToken).then((accessToken) => {\n if (refreshPromise === this._refreshPromise) {\n this._refreshPromise = null;\n this._accessToken.set(accessToken);\n if (!accessToken) {\n this.markInvalid();\n }\n }\n return accessToken;\n });\n this._refreshPromise = refreshPromise;\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export { };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexclave/shared",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "repository": "https://github.com/hexclave/hexclave",
5
5
  "files": [
6
6
  "README.md",
@@ -275,7 +275,7 @@ export const cliSetupPrompt = deindent`
275
275
  </Step>
276
276
 
277
277
  <Step title="Prompt the user to log in">
278
- Import and call \`prompt_cli_login\`. It opens the browser, lets the user authenticate, and returns a refresh token.
278
+ Import and call \`prompt_cli_login\`. It opens the browser, lets the user authenticate, and returns a refresh token. The project ID is enough for most projects; only pass \`publishable_client_key\` if the project has \`requirePublishableClientKey\` enabled.
279
279
 
280
280
  \`\`\`py main.py
281
281
  from hexclave_cli_template import prompt_cli_login
@@ -283,7 +283,6 @@ export const cliSetupPrompt = deindent`
283
283
  refresh_token = prompt_cli_login(
284
284
  app_url="https://your-app-url.example.com",
285
285
  project_id="your-project-id-here",
286
- publishable_client_key="your-publishable-client-key-here",
287
286
  )
288
287
 
289
288
  if refresh_token is None:
@@ -442,11 +441,13 @@ function getRestBackendSetupPrompt(kind: "python" | "rest-api") {
442
441
  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
442
 
444
443
  \`\`\`ts hexclave.config.ts
445
- import type { HexclaveConfig } from "@hexclave/js";
444
+ import type { HexclaveConfig } from "@hexclave/js/config";
446
445
 
447
446
  export const config: HexclaveConfig = "show-onboarding";
448
447
  \`\`\`
449
448
 
449
+ 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).
450
+
450
451
  Run your backend through the Hexclave CLI so it starts the local dashboard and injects the Hexclave environment variables:
451
452
 
452
453
  \`\`\`json package.json
@@ -662,6 +663,10 @@ export function getSdkSetupPrompt(mainType: "ai-prompt" | "nextjs" | "react" | "
662
663
  },
663
664
  });
664
665
  \`\`\`
666
+
667
+ <Note>
668
+ 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 }\`.
669
+ </Note>
665
670
  ` : ""}
666
671
 
667
672
  ${isMaybeBackend ? deindent`
@@ -719,12 +724,14 @@ export function getSdkSetupPrompt(mainType: "ai-prompt" | "nextjs" | "react" | "
719
724
  First, create a \`hexclave.config.ts\` configuration file in the root directory of the workspace (or anywhere else):
720
725
 
721
726
  \`\`\`ts hexclave.config.ts
722
- import type { HexclaveConfig } from "${packageName}";
727
+ import type { HexclaveConfig } from "${packageName}/config";
723
728
 
724
729
  // default: show-onboarding, which shows the onboarding flow for this project when Hexclave starts
725
730
  export const config: HexclaveConfig = "show-onboarding";
726
731
  \`\`\`
727
732
 
733
+ 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).
734
+
728
735
  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
736
 
730
737
  \`\`\`sh
@@ -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
- const importLine = `import type { HexclaveConfig } from "${pkg}";`;
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(includeAnonymous: boolean = false): Promise<MetricsResponse> {
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
- return (await response.json()) as MetricsResponse;
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
- // dev-fallback fields (only present in non-production environments)
121
- bounce_rate: yupNumber().optional(),
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>;
@@ -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
- export const emailPasswordSchema = passwordSchema.meta({ openapiField: { description: 'Email password. Needs to be specified when using type="standard"', exampleValue: 'your-email-password' } });
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