@levr-one/auth 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +37 -0
  2. package/dist/config-watcher.d.ts +65 -0
  3. package/dist/config-watcher.d.ts.map +1 -0
  4. package/dist/config-watcher.js +140 -0
  5. package/dist/config-watcher.js.map +1 -0
  6. package/dist/config-watcher.test.d.ts +2 -0
  7. package/dist/config-watcher.test.d.ts.map +1 -0
  8. package/dist/config-watcher.test.js +116 -0
  9. package/dist/config-watcher.test.js.map +1 -0
  10. package/dist/config.d.ts +58 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +234 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/config.test.d.ts +2 -0
  15. package/dist/config.test.d.ts.map +1 -0
  16. package/dist/config.test.js +221 -0
  17. package/dist/config.test.js.map +1 -0
  18. package/dist/file-lock.d.ts +41 -0
  19. package/dist/file-lock.d.ts.map +1 -0
  20. package/dist/file-lock.js +110 -0
  21. package/dist/file-lock.js.map +1 -0
  22. package/dist/index.d.ts +43 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +43 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/oauth-client.d.ts +156 -0
  27. package/dist/oauth-client.d.ts.map +1 -0
  28. package/dist/oauth-client.js +572 -0
  29. package/dist/oauth-client.js.map +1 -0
  30. package/dist/oauth-client.test.d.ts +2 -0
  31. package/dist/oauth-client.test.d.ts.map +1 -0
  32. package/dist/oauth-client.test.js +290 -0
  33. package/dist/oauth-client.test.js.map +1 -0
  34. package/dist/test-utils.d.ts +10 -0
  35. package/dist/test-utils.d.ts.map +1 -0
  36. package/dist/test-utils.js +45 -0
  37. package/dist/test-utils.js.map +1 -0
  38. package/dist/token-store.d.ts +78 -0
  39. package/dist/token-store.d.ts.map +1 -0
  40. package/dist/token-store.js +299 -0
  41. package/dist/token-store.js.map +1 -0
  42. package/dist/token-store.test.d.ts +2 -0
  43. package/dist/token-store.test.d.ts.map +1 -0
  44. package/dist/token-store.test.js +239 -0
  45. package/dist/token-store.test.js.map +1 -0
  46. package/dist/token-watcher.d.ts +82 -0
  47. package/dist/token-watcher.d.ts.map +1 -0
  48. package/dist/token-watcher.js +185 -0
  49. package/dist/token-watcher.js.map +1 -0
  50. package/dist/types.d.ts +70 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +5 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/workspace-store.d.ts +60 -0
  55. package/dist/workspace-store.d.ts.map +1 -0
  56. package/dist/workspace-store.js +147 -0
  57. package/dist/workspace-store.js.map +1 -0
  58. package/dist/workspace-store.test.d.ts +2 -0
  59. package/dist/workspace-store.test.d.ts.map +1 -0
  60. package/dist/workspace-store.test.js +169 -0
  61. package/dist/workspace-store.test.js.map +1 -0
  62. package/dist/workspaces.d.ts +60 -0
  63. package/dist/workspaces.d.ts.map +1 -0
  64. package/dist/workspaces.js +80 -0
  65. package/dist/workspaces.js.map +1 -0
  66. package/dist/workspaces.test.d.ts +2 -0
  67. package/dist/workspaces.test.d.ts.map +1 -0
  68. package/dist/workspaces.test.js +111 -0
  69. package/dist/workspaces.test.js.map +1 -0
  70. package/package.json +58 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * TokenWatcher - Self-Healing Token Management
3
+ *
4
+ * Watches the token file for changes and notifies subscribers.
5
+ * This enables MCP servers to auto-recover when:
6
+ * - Tokens appear after startup (user runs authorize)
7
+ * - Tokens are refreshed externally
8
+ * - Token file is fixed after corruption
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { loadTokens, getTokenFilePath, getTqDir } from './token-store.js';
13
+ const DEFAULT_OPTIONS = {
14
+ pollInterval: 30000, // 30 seconds
15
+ expirationWarningMinutes: 5,
16
+ };
17
+ /**
18
+ * TokenWatcher for self-healing token management
19
+ *
20
+ * Usage:
21
+ * ```typescript
22
+ * const watcher = new TokenWatcher();
23
+ * watcher.onTokenChange((tokens) => {
24
+ * if (tokens) {
25
+ * console.log('Tokens available:', tokens.expiresAt > Date.now() ? 'valid' : 'expired');
26
+ * } else {
27
+ * console.log('No tokens available');
28
+ * }
29
+ * });
30
+ * watcher.start();
31
+ * ```
32
+ */
33
+ export class TokenWatcher {
34
+ watcher = null;
35
+ pollInterval = null;
36
+ lastTokens = null;
37
+ lastTokensJson = '';
38
+ callbacks = new Set();
39
+ options;
40
+ isRunning = false;
41
+ constructor(options) {
42
+ this.options = { ...DEFAULT_OPTIONS, ...options };
43
+ }
44
+ /**
45
+ * Start watching for token changes
46
+ */
47
+ start() {
48
+ if (this.isRunning) {
49
+ return;
50
+ }
51
+ this.isRunning = true;
52
+ const tokenFilePath = getTokenFilePath();
53
+ const tqDir = getTqDir();
54
+ // Ensure directory exists for watching
55
+ if (!fs.existsSync(tqDir)) {
56
+ try {
57
+ fs.mkdirSync(tqDir, { recursive: true, mode: 0o700 });
58
+ }
59
+ catch {
60
+ console.error('[tq-oauth] Failed to create .tq directory for watching');
61
+ }
62
+ }
63
+ // Watch the directory (not the file) because the file may not exist yet
64
+ // and fs.watch on a non-existent file fails
65
+ try {
66
+ this.watcher = fs.watch(tqDir, (eventType, filename) => {
67
+ if (filename === path.basename(tokenFilePath)) {
68
+ this.checkTokens();
69
+ }
70
+ });
71
+ this.watcher.on('error', (error) => {
72
+ console.error('[tq-oauth] Watch error:', error);
73
+ // Continue with polling as fallback
74
+ });
75
+ }
76
+ catch (error) {
77
+ console.error('[tq-oauth] Failed to start file watcher:', error);
78
+ // Continue with polling as fallback
79
+ }
80
+ // Also poll periodically (backup if fs.watch misses events)
81
+ // unref so timer doesn't keep the process alive
82
+ this.pollInterval = setInterval(() => {
83
+ this.checkTokens();
84
+ }, this.options.pollInterval);
85
+ this.pollInterval.unref();
86
+ // Initial check
87
+ this.checkTokens();
88
+ }
89
+ /**
90
+ * Stop watching for token changes
91
+ */
92
+ stop() {
93
+ this.isRunning = false;
94
+ if (this.watcher) {
95
+ this.watcher.close();
96
+ this.watcher = null;
97
+ }
98
+ if (this.pollInterval) {
99
+ clearInterval(this.pollInterval);
100
+ this.pollInterval = null;
101
+ }
102
+ }
103
+ /**
104
+ * Register a callback for token changes
105
+ */
106
+ onTokenChange(callback) {
107
+ this.callbacks.add(callback);
108
+ // Immediately notify with current state if watcher is running
109
+ if (this.isRunning) {
110
+ callback(this.lastTokens);
111
+ }
112
+ }
113
+ /**
114
+ * Remove a token change callback
115
+ */
116
+ offTokenChange(callback) {
117
+ this.callbacks.delete(callback);
118
+ }
119
+ /**
120
+ * Get current tokens (synchronous)
121
+ */
122
+ getCurrentTokens() {
123
+ return this.lastTokens;
124
+ }
125
+ /**
126
+ * Get current auth status based on tokens
127
+ */
128
+ getAuthStatus() {
129
+ if (!this.lastTokens) {
130
+ return 'missing';
131
+ }
132
+ const now = Date.now();
133
+ const warningThreshold = this.options.expirationWarningMinutes * 60 * 1000;
134
+ if (now >= this.lastTokens.expiresAt) {
135
+ return 'expired';
136
+ }
137
+ if (this.lastTokens.expiresAt - now < warningThreshold) {
138
+ // Expiring soon - could trigger proactive refresh
139
+ return 'expired';
140
+ }
141
+ return 'ok';
142
+ }
143
+ /**
144
+ * Check if tokens are valid (not expired)
145
+ */
146
+ isTokenValid() {
147
+ return this.getAuthStatus() === 'ok';
148
+ }
149
+ /**
150
+ * Force a token check (useful after manual refresh)
151
+ */
152
+ forceCheck() {
153
+ this.checkTokens();
154
+ }
155
+ /**
156
+ * Internal: Check tokens and notify if changed
157
+ */
158
+ checkTokens() {
159
+ const tokens = loadTokens();
160
+ const tokensJson = tokens ? JSON.stringify(tokens) : '';
161
+ // Only notify if tokens actually changed
162
+ if (tokensJson !== this.lastTokensJson) {
163
+ this.lastTokens = tokens;
164
+ this.lastTokensJson = tokensJson;
165
+ // Log state change
166
+ if (tokens) {
167
+ const status = this.getAuthStatus();
168
+ console.error(`[tq-oauth] Token change detected, status: ${status}`);
169
+ }
170
+ else {
171
+ console.error('[tq-oauth] Token file cleared or missing');
172
+ }
173
+ // Notify all callbacks
174
+ this.callbacks.forEach((cb) => {
175
+ try {
176
+ cb(tokens);
177
+ }
178
+ catch (error) {
179
+ console.error('[tq-oauth] Error in token change callback:', error);
180
+ }
181
+ });
182
+ }
183
+ }
184
+ }
185
+ //# sourceMappingURL=token-watcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-watcher.js","sourceRoot":"","sources":["../src/token-watcher.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Shared types for OAuth token management
3
+ */
4
+ /**
5
+ * Token response from OAuth server.
6
+ *
7
+ * `user` is present on non-refresh grants (authorization_code, device_code,
8
+ * password) and absent on refresh_token responses — `setTokens()` uses
9
+ * `user?.workspace_id` to decide whether to persist workspace state.
10
+ *
11
+ * The backend also returns a `workspaces` array alongside `user` on initial
12
+ * grants, but tq-oauth does not cache that list locally (membership can
13
+ * change between sessions, and `tq:workspace` always fetches a fresh list
14
+ * from `/v1/auth/sites`). Callers that need the live workspace list should
15
+ * call the auth sites endpoint directly.
16
+ */
17
+ export interface TokenResponse {
18
+ access_token: string;
19
+ token_type: string;
20
+ expires_in: number;
21
+ refresh_token: string;
22
+ scope: string;
23
+ user?: {
24
+ workspace_id: string;
25
+ };
26
+ }
27
+ /**
28
+ * Structure for stored tokens
29
+ */
30
+ export interface StoredTokens {
31
+ accessToken: string;
32
+ refreshToken: string;
33
+ expiresAt: number;
34
+ /** Backend URL this token was issued for (for environment mismatch detection) */
35
+ apiBaseUrl?: string;
36
+ }
37
+ /**
38
+ * OAuth client configuration
39
+ */
40
+ export interface OAuthConfig {
41
+ /** OAuth authorization server URL (e.g., http://localhost:8080) */
42
+ authServerUrl: string;
43
+ /** Registered OAuth client ID */
44
+ clientId: string;
45
+ /** Local callback port for receiving authorization code */
46
+ redirectPort: number;
47
+ /** Requested OAuth scopes */
48
+ scopes: string[];
49
+ }
50
+ /**
51
+ * Device authorization response from POST /v1/oauth/device/authorize
52
+ * @see https://datatracker.ietf.org/doc/html/rfc8628
53
+ */
54
+ export interface DeviceAuthorizationResponse {
55
+ device_code: string;
56
+ user_code: string;
57
+ verification_uri: string;
58
+ verification_uri_complete: string;
59
+ expires_in: number;
60
+ interval: number;
61
+ }
62
+ /**
63
+ * Authentication status for self-healing
64
+ */
65
+ export type AuthStatus = 'ok' | 'expired' | 'missing' | 'refreshing' | 'error';
66
+ /**
67
+ * Callback for token change events
68
+ */
69
+ export type TokenChangeCallback = (tokens: StoredTokens | null) => void;
70
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE;QAAE,YAAY,EAAE,MAAM,CAAA;KAAE,CAAC;CACjC;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,aAAa,EAAE,MAAM,CAAC;IACtB,iCAAiC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,6BAA6B;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;GAGG;AACH,MAAM,WAAW,2BAA2B;IAC1C,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,yBAAyB,EAAE,MAAM,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,GAAG,YAAY,GAAG,OAAO,CAAC;AAE/E;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,KAAK,IAAI,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared types for OAuth token management
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Disk-backed workspace state at ~/.tq/workspace.json
3
+ * Follows the same atomic-write pattern as token-store.ts
4
+ */
5
+ /**
6
+ * Identity cache shape. Persisted to ~/.tq/workspace.json alongside the
7
+ * existing { workspace_id } single field. All fields are required for a
8
+ * complete identity record — partial files are treated as a cache miss.
9
+ *
10
+ * Cache is hint-only: it is consumed for MCP `instructions` and for
11
+ * `get_context` display. It MUST NOT be used in any security check.
12
+ */
13
+ export interface IdentityCache {
14
+ workspace_id: string;
15
+ user_id: string;
16
+ user_email: string;
17
+ user_name: string;
18
+ role: string;
19
+ }
20
+ /**
21
+ * Load persisted workspace ID from disk
22
+ * @returns workspace_id or null if missing/corrupt
23
+ */
24
+ export declare function loadWorkspace(): string | null;
25
+ /**
26
+ * Save workspace ID to disk with atomic write
27
+ */
28
+ export declare function saveWorkspace(workspaceId: string): void;
29
+ /**
30
+ * Load the full identity cache from disk.
31
+ *
32
+ * Reads ~/.tq/workspace.json and returns a typed `IdentityCache` only when
33
+ * all five required fields are present and are strings. Returns `null` for:
34
+ * - missing file
35
+ * - malformed JSON
36
+ * - legacy single-field shape (`{ workspace_id }` only)
37
+ * - any field present but not a string
38
+ *
39
+ * Never throws — graceful degradation is mandatory because callers wire this
40
+ * into startup paths and watcher callbacks.
41
+ */
42
+ export declare function loadIdentityCache(): IdentityCache | null;
43
+ /**
44
+ * Save the full identity cache to disk with atomic write.
45
+ *
46
+ * Single writer pattern: `saveWorkspace` writes only `{ workspace_id }`;
47
+ * `saveIdentityCache` is the only path that writes the five-field shape.
48
+ * After the first identity-aware save, the file always contains all five
49
+ * fields. Same `.tmp` + `renameSync` pattern as `saveWorkspace`.
50
+ */
51
+ export declare function saveIdentityCache(data: IdentityCache): void;
52
+ /**
53
+ * Clear persisted workspace (e.g. on env switch or login)
54
+ */
55
+ export declare function clearWorkspace(): void;
56
+ /**
57
+ * Get workspace file path (for debugging)
58
+ */
59
+ export declare function getWorkspaceFilePath(): string;
60
+ //# sourceMappingURL=workspace-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-store.d.ts","sourceRoot":"","sources":["../src/workspace-store.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAqCH;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,wBAAgB,aAAa,IAAI,MAAM,GAAG,IAAI,CAc7C;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAEvD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,IAAI,aAAa,GAAG,IAAI,CAmCxD;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAQ3D;AAED;;GAEG;AACH,wBAAgB,cAAc,IAAI,IAAI,CASrC;AAED;;GAEG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,CAE7C"}
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Disk-backed workspace state at ~/.tq/workspace.json
3
+ * Follows the same atomic-write pattern as token-store.ts
4
+ */
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { getTqDir } from './token-store.js';
8
+ const WORKSPACE_FILE = 'workspace.json';
9
+ function getWorkspacePath() {
10
+ return path.join(getTqDir(), WORKSPACE_FILE);
11
+ }
12
+ /**
13
+ * Atomically write `payload` as JSON to `~/.tq/workspace.json`.
14
+ * Ensures the ~/.tq directory exists with 0o700, writes to `*.tmp` with
15
+ * 0o600, then renames over the target. Errors are logged, never thrown.
16
+ */
17
+ function writeWorkspaceJsonAtomic(payload) {
18
+ try {
19
+ const tqDir = getTqDir();
20
+ if (!fs.existsSync(tqDir)) {
21
+ fs.mkdirSync(tqDir, { recursive: true, mode: 0o700 });
22
+ }
23
+ const filePath = getWorkspacePath();
24
+ const tempPath = filePath + '.tmp';
25
+ fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), {
26
+ mode: 0o600,
27
+ });
28
+ fs.renameSync(tempPath, filePath);
29
+ }
30
+ catch (error) {
31
+ console.error('[tq-oauth] Failed to write workspace.json:', error);
32
+ }
33
+ }
34
+ /**
35
+ * Load persisted workspace ID from disk
36
+ * @returns workspace_id or null if missing/corrupt
37
+ */
38
+ export function loadWorkspace() {
39
+ try {
40
+ const filePath = getWorkspacePath();
41
+ if (fs.existsSync(filePath)) {
42
+ const content = fs.readFileSync(filePath, 'utf-8');
43
+ const data = JSON.parse(content);
44
+ if (data.workspace_id && typeof data.workspace_id === 'string') {
45
+ return data.workspace_id;
46
+ }
47
+ }
48
+ }
49
+ catch (error) {
50
+ console.error('[tq-oauth] Failed to load workspace:', error);
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * Save workspace ID to disk with atomic write
56
+ */
57
+ export function saveWorkspace(workspaceId) {
58
+ writeWorkspaceJsonAtomic({ workspace_id: workspaceId });
59
+ }
60
+ /**
61
+ * Load the full identity cache from disk.
62
+ *
63
+ * Reads ~/.tq/workspace.json and returns a typed `IdentityCache` only when
64
+ * all five required fields are present and are strings. Returns `null` for:
65
+ * - missing file
66
+ * - malformed JSON
67
+ * - legacy single-field shape (`{ workspace_id }` only)
68
+ * - any field present but not a string
69
+ *
70
+ * Never throws — graceful degradation is mandatory because callers wire this
71
+ * into startup paths and watcher callbacks.
72
+ */
73
+ export function loadIdentityCache() {
74
+ try {
75
+ const filePath = getWorkspacePath();
76
+ if (!fs.existsSync(filePath))
77
+ return null;
78
+ const content = fs.readFileSync(filePath, 'utf-8');
79
+ const data = JSON.parse(content);
80
+ // `typeof [] === 'object'` — explicitly reject arrays so property
81
+ // access on a parsed JSON array (`[0]`, `[1]`...) cannot accidentally
82
+ // satisfy the per-field checks below.
83
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
84
+ return null;
85
+ }
86
+ const keys = [
87
+ 'workspace_id',
88
+ 'user_id',
89
+ 'user_email',
90
+ 'user_name',
91
+ 'role',
92
+ ];
93
+ for (const key of keys) {
94
+ const value = data[key];
95
+ if (typeof value !== 'string')
96
+ return null;
97
+ }
98
+ return {
99
+ workspace_id: data.workspace_id,
100
+ user_id: data.user_id,
101
+ user_email: data.user_email,
102
+ user_name: data.user_name,
103
+ role: data.role,
104
+ };
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ /**
111
+ * Save the full identity cache to disk with atomic write.
112
+ *
113
+ * Single writer pattern: `saveWorkspace` writes only `{ workspace_id }`;
114
+ * `saveIdentityCache` is the only path that writes the five-field shape.
115
+ * After the first identity-aware save, the file always contains all five
116
+ * fields. Same `.tmp` + `renameSync` pattern as `saveWorkspace`.
117
+ */
118
+ export function saveIdentityCache(data) {
119
+ writeWorkspaceJsonAtomic({
120
+ workspace_id: data.workspace_id,
121
+ user_id: data.user_id,
122
+ user_email: data.user_email,
123
+ user_name: data.user_name,
124
+ role: data.role,
125
+ });
126
+ }
127
+ /**
128
+ * Clear persisted workspace (e.g. on env switch or login)
129
+ */
130
+ export function clearWorkspace() {
131
+ try {
132
+ const filePath = getWorkspacePath();
133
+ if (fs.existsSync(filePath)) {
134
+ fs.unlinkSync(filePath);
135
+ }
136
+ }
137
+ catch (error) {
138
+ console.error('[tq-oauth] Failed to clear workspace:', error);
139
+ }
140
+ }
141
+ /**
142
+ * Get workspace file path (for debugging)
143
+ */
144
+ export function getWorkspaceFilePath() {
145
+ return getWorkspacePath();
146
+ }
147
+ //# sourceMappingURL=workspace-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-store.js","sourceRoot":"","sources":["../src/workspace-store.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,MAAM,cAAc,GAAG,gBAAgB,CAAC;AAExC,SAAS,gBAAgB;IACvB,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,cAAc,CAAC,CAAC;AAC/C,CAAC;AAED;;;;GAIG;AACH,SAAS,wBAAwB,CAAC,OAAe;IAC/C,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,QAAQ,EAAE,CAAC;QACzB,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;YAC1B,EAAE,CAAC,SAAS,CAAC,KAAK,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACxD,CAAC;QAED,MAAM,QAAQ,GAAG,gBAAgB,EAAE,CAAC;QACpC,MAAM,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAC;QAEnC,EAAE,CAAC,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE;YAC3D,IAAI,EAAE,KAAK;SACZ,CAAC,CAAC;QAEH,EAAE,CAAC,UAAU,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IACpC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,4CAA4C,EAAE,KAAK,CAAC,CAAC;IACrE,CAAC;AACH,CAAC;AAkBD;;;GAGG;AACH,MAAM,UAAU,aAAa;IAC3B,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,gBAAgB,EAAE,CAAC;QACpC,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC5B,MAAM,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;YACnD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAA8B,CAAC;YAC9D,IAAI,IAAI,CAAC,YAAY,IAAI,OAAO,IAAI,CAAC,YAAY,KAAK,QAAQ,EAAE,CAAC;gBAC/D,OAAO,IAAI,CAAC,YAAY,CAAC;YAC3B,CAAC;QACH,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,sCAAsC,EAAE,KAAK,CAAC,CAAC;IAC/D,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,aAAa,CAAC,WAAmB;IAC/C,wBAAwB,CAAC,EAAE,YAAY,EAAE,WAAW,EAAE,CAAC,CAAC;AAC1D,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,iBAAiB;IAC/B,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,gBAAgB,EAAE,CAAC;QACpC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC;YAAE,OAAO,IAAI,CAAC;QAC1C,MAAM,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;QACnD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAA4B,CAAC;QAC5D,kEAAkE;QAClE,sEAAsE;QACtE,sCAAsC;QACtC,IAAI,IAAI,KAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YACrE,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,IAAI,GAA+B;YACvC,cAAc;YACd,SAAS;YACT,YAAY;YACZ,WAAW;YACX,MAAM;SACP,CAAC;QACF,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;YACxB,IAAI,OAAO,KAAK,KAAK,QAAQ;gBAAE,OAAO,IAAI,CAAC;QAC7C,CAAC;QAED,OAAO;YACL,YAAY,EAAE,IAAI,CAAC,YAAsB;YACzC,OAAO,EAAE,IAAI,CAAC,OAAiB;YAC/B,UAAU,EAAE,IAAI,CAAC,UAAoB;YACrC,SAAS,EAAE,IAAI,CAAC,SAAmB;YACnC,IAAI,EAAE,IAAI,CAAC,IAAc;SAC1B,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAmB;IACnD,wBAAwB,CAAC;QACvB,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,IAAI,EAAE,IAAI,CAAC,IAAI;KAChB,CAAC,CAAC;AACL,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,cAAc;IAC5B,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,gBAAgB,EAAE,CAAC;QACpC,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC5B,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;QAC1B,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,uCAAuC,EAAE,KAAK,CAAC,CAAC;IAChE,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,oBAAoB;IAClC,OAAO,gBAAgB,EAAE,CAAC;AAC5B,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=workspace-store.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-store.test.d.ts","sourceRoot":"","sources":["../src/workspace-store.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ // Mutable ref so the mock can read the current tmpDir
6
+ const state = vi.hoisted(() => ({ tmpDir: '' }));
7
+ // Mock os.homedir() so token-store's module-level TQ_DIR uses our temp dir
8
+ vi.mock('os', async (importOriginal) => {
9
+ const original = await importOriginal();
10
+ return { ...original, homedir: () => state.tmpDir };
11
+ });
12
+ // Re-import each test so module-level constants re-evaluate
13
+ let loadWorkspace;
14
+ let saveWorkspace;
15
+ let clearWorkspace;
16
+ let getWorkspaceFilePath;
17
+ let loadIdentityCache;
18
+ let saveIdentityCache;
19
+ beforeEach(async () => {
20
+ state.tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tq-ws-test-'));
21
+ vi.resetModules();
22
+ const mod = await import('./workspace-store.js');
23
+ loadWorkspace = mod.loadWorkspace;
24
+ saveWorkspace = mod.saveWorkspace;
25
+ clearWorkspace = mod.clearWorkspace;
26
+ getWorkspaceFilePath = mod.getWorkspaceFilePath;
27
+ loadIdentityCache = mod.loadIdentityCache;
28
+ saveIdentityCache = mod.saveIdentityCache;
29
+ });
30
+ afterEach(() => {
31
+ fs.rmSync(state.tmpDir, { recursive: true, force: true });
32
+ });
33
+ describe('getWorkspaceFilePath', () => {
34
+ it('returns path to workspace.json', () => {
35
+ expect(getWorkspaceFilePath()).toBe(path.join(state.tmpDir, '.tq', 'workspace.json'));
36
+ });
37
+ });
38
+ describe('loadWorkspace', () => {
39
+ it('returns null when no file exists', () => {
40
+ expect(loadWorkspace()).toBeNull();
41
+ });
42
+ it('returns null for malformed JSON', () => {
43
+ const tqDir = path.join(state.tmpDir, '.tq');
44
+ fs.mkdirSync(tqDir, { recursive: true });
45
+ fs.writeFileSync(path.join(tqDir, 'workspace.json'), 'not-json');
46
+ expect(loadWorkspace()).toBeNull();
47
+ });
48
+ it('returns null for missing workspace_id field', () => {
49
+ const tqDir = path.join(state.tmpDir, '.tq');
50
+ fs.mkdirSync(tqDir, { recursive: true });
51
+ fs.writeFileSync(path.join(tqDir, 'workspace.json'), JSON.stringify({ other: 'field' }));
52
+ expect(loadWorkspace()).toBeNull();
53
+ });
54
+ });
55
+ describe('saveWorkspace + loadWorkspace roundtrip', () => {
56
+ it('saves and loads workspace_id correctly', () => {
57
+ saveWorkspace('ws-123');
58
+ const loaded = loadWorkspace();
59
+ expect(loaded).toBe('ws-123');
60
+ });
61
+ it('creates ~/.tq directory if missing', () => {
62
+ expect(fs.existsSync(path.join(state.tmpDir, '.tq'))).toBe(false);
63
+ saveWorkspace('ws-123');
64
+ expect(fs.existsSync(path.join(state.tmpDir, '.tq'))).toBe(true);
65
+ });
66
+ it('overwrites existing workspace', () => {
67
+ saveWorkspace('ws-123');
68
+ saveWorkspace('ws-456');
69
+ const loaded = loadWorkspace();
70
+ expect(loaded).toBe('ws-456');
71
+ });
72
+ it('uses atomic write (temp file + rename)', () => {
73
+ saveWorkspace('ws-123');
74
+ // Verify the temp file was cleaned up (renamed to final)
75
+ const tmpFile = getWorkspaceFilePath() + '.tmp';
76
+ expect(fs.existsSync(tmpFile)).toBe(false);
77
+ expect(fs.existsSync(getWorkspaceFilePath())).toBe(true);
78
+ });
79
+ });
80
+ describe('clearWorkspace', () => {
81
+ it('removes the workspace file', () => {
82
+ saveWorkspace('ws-123');
83
+ expect(loadWorkspace()).toBe('ws-123');
84
+ clearWorkspace();
85
+ expect(loadWorkspace()).toBeNull();
86
+ });
87
+ it('does not throw when no file exists', () => {
88
+ expect(() => clearWorkspace()).not.toThrow();
89
+ });
90
+ });
91
+ describe('saveIdentityCache + loadIdentityCache roundtrip', () => {
92
+ const sample = {
93
+ workspace_id: 'ws-1',
94
+ user_id: 'u-1',
95
+ user_email: 'james@bitmodern.com',
96
+ user_name: 'James Pitts',
97
+ role: 'owner',
98
+ };
99
+ it('saves and loads all 5 fields', () => {
100
+ saveIdentityCache(sample);
101
+ expect(loadIdentityCache()).toEqual(sample);
102
+ });
103
+ it('overwrites prior identity cache', () => {
104
+ saveIdentityCache(sample);
105
+ saveIdentityCache({ ...sample, workspace_id: 'ws-2', role: 'admin' });
106
+ expect(loadIdentityCache()).toEqual({
107
+ ...sample,
108
+ workspace_id: 'ws-2',
109
+ role: 'admin',
110
+ });
111
+ });
112
+ it('uses atomic write (temp file cleaned up)', () => {
113
+ saveIdentityCache(sample);
114
+ const tmpFile = getWorkspaceFilePath() + '.tmp';
115
+ expect(fs.existsSync(tmpFile)).toBe(false);
116
+ expect(fs.existsSync(getWorkspaceFilePath())).toBe(true);
117
+ });
118
+ it('saveWorkspace remains backward-compatible: still loads via loadWorkspace', () => {
119
+ saveIdentityCache(sample);
120
+ expect(loadWorkspace()).toBe('ws-1');
121
+ });
122
+ });
123
+ describe('loadIdentityCache', () => {
124
+ it('returns null when file does not exist', () => {
125
+ expect(loadIdentityCache()).toBeNull();
126
+ });
127
+ it('returns null for legacy single-field shape (workspace_id only)', () => {
128
+ saveWorkspace('ws-legacy');
129
+ // saveWorkspace writes only { workspace_id } — identity fields missing
130
+ expect(loadIdentityCache()).toBeNull();
131
+ });
132
+ it('returns null when any identity field is missing', () => {
133
+ const tqDir = path.join(state.tmpDir, '.tq');
134
+ fs.mkdirSync(tqDir, { recursive: true });
135
+ fs.writeFileSync(path.join(tqDir, 'workspace.json'), JSON.stringify({
136
+ workspace_id: 'ws-1',
137
+ user_id: 'u-1',
138
+ user_email: 'a@b.com',
139
+ // user_name missing
140
+ role: 'owner',
141
+ }));
142
+ expect(loadIdentityCache()).toBeNull();
143
+ });
144
+ it('returns null when a field is present but not a string', () => {
145
+ const tqDir = path.join(state.tmpDir, '.tq');
146
+ fs.mkdirSync(tqDir, { recursive: true });
147
+ fs.writeFileSync(path.join(tqDir, 'workspace.json'), JSON.stringify({
148
+ workspace_id: 'ws-1',
149
+ user_id: 'u-1',
150
+ user_email: 'a@b.com',
151
+ user_name: null,
152
+ role: 'owner',
153
+ }));
154
+ expect(loadIdentityCache()).toBeNull();
155
+ });
156
+ it('returns null for malformed JSON', () => {
157
+ const tqDir = path.join(state.tmpDir, '.tq');
158
+ fs.mkdirSync(tqDir, { recursive: true });
159
+ fs.writeFileSync(path.join(tqDir, 'workspace.json'), 'not-json');
160
+ expect(loadIdentityCache()).toBeNull();
161
+ });
162
+ it('returns null when the JSON root is an array (not an object)', () => {
163
+ const tqDir = path.join(state.tmpDir, '.tq');
164
+ fs.mkdirSync(tqDir, { recursive: true });
165
+ fs.writeFileSync(path.join(tqDir, 'workspace.json'), JSON.stringify(['workspace_id', 'user_id', 'a@b.com', 'A', 'owner']));
166
+ expect(loadIdentityCache()).toBeNull();
167
+ });
168
+ });
169
+ //# sourceMappingURL=workspace-store.test.js.map