@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.
- package/README.md +37 -0
- package/dist/config-watcher.d.ts +65 -0
- package/dist/config-watcher.d.ts.map +1 -0
- package/dist/config-watcher.js +140 -0
- package/dist/config-watcher.js.map +1 -0
- package/dist/config-watcher.test.d.ts +2 -0
- package/dist/config-watcher.test.d.ts.map +1 -0
- package/dist/config-watcher.test.js +116 -0
- package/dist/config-watcher.test.js.map +1 -0
- package/dist/config.d.ts +58 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +234 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +2 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/config.test.js +221 -0
- package/dist/config.test.js.map +1 -0
- package/dist/file-lock.d.ts +41 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +110 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth-client.d.ts +156 -0
- package/dist/oauth-client.d.ts.map +1 -0
- package/dist/oauth-client.js +572 -0
- package/dist/oauth-client.js.map +1 -0
- package/dist/oauth-client.test.d.ts +2 -0
- package/dist/oauth-client.test.d.ts.map +1 -0
- package/dist/oauth-client.test.js +290 -0
- package/dist/oauth-client.test.js.map +1 -0
- package/dist/test-utils.d.ts +10 -0
- package/dist/test-utils.d.ts.map +1 -0
- package/dist/test-utils.js +45 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/token-store.d.ts +78 -0
- package/dist/token-store.d.ts.map +1 -0
- package/dist/token-store.js +299 -0
- package/dist/token-store.js.map +1 -0
- package/dist/token-store.test.d.ts +2 -0
- package/dist/token-store.test.d.ts.map +1 -0
- package/dist/token-store.test.js +239 -0
- package/dist/token-store.test.js.map +1 -0
- package/dist/token-watcher.d.ts +82 -0
- package/dist/token-watcher.d.ts.map +1 -0
- package/dist/token-watcher.js +185 -0
- package/dist/token-watcher.js.map +1 -0
- package/dist/types.d.ts +70 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace-store.d.ts +60 -0
- package/dist/workspace-store.d.ts.map +1 -0
- package/dist/workspace-store.js +147 -0
- package/dist/workspace-store.js.map +1 -0
- package/dist/workspace-store.test.d.ts +2 -0
- package/dist/workspace-store.test.d.ts.map +1 -0
- package/dist/workspace-store.test.js +169 -0
- package/dist/workspace-store.test.js.map +1 -0
- package/dist/workspaces.d.ts +60 -0
- package/dist/workspaces.d.ts.map +1 -0
- package/dist/workspaces.js +80 -0
- package/dist/workspaces.js.map +1 -0
- package/dist/workspaces.test.d.ts +2 -0
- package/dist/workspaces.test.d.ts.map +1 -0
- package/dist/workspaces.test.js +111 -0
- package/dist/workspaces.test.js.map +1 -0
- 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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"}
|
package/dist/types.d.ts
ADDED
|
@@ -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 @@
|
|
|
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":"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"}
|
|
@@ -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
|