Compare commits
8 Commits
docs/auto-
...
ralph/feat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22a6c79c3c | ||
|
|
191a2fdb92 | ||
|
|
8957110b50 | ||
|
|
9853f39325 | ||
|
|
f76451f411 | ||
|
|
1dbc01ba00 | ||
|
|
0495189af3 | ||
|
|
6bc75c0ac6 |
5
.changeset/dirty-hairs-know.md
Normal file
5
.changeset/dirty-hairs-know.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"task-master-ai": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve auth token refresh flow
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"test:ci": "vitest run --coverage --reporter=dot"
|
"test:ci": "vitest run --coverage --reporter=dot"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@inquirer/search": "^3.2.0",
|
||||||
"@tm/core": "*",
|
"@tm/core": "*",
|
||||||
"boxen": "^8.0.1",
|
"boxen": "^8.0.1",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type AuthCredentials
|
type AuthCredentials
|
||||||
} from '@tm/core/auth';
|
} from '@tm/core/auth';
|
||||||
import * as ui from '../utils/ui.js';
|
import * as ui from '../utils/ui.js';
|
||||||
|
import { ContextCommand } from './context.command.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result type from auth command
|
* Result type from auth command
|
||||||
@@ -143,7 +144,7 @@ export class AuthCommand extends Command {
|
|||||||
*/
|
*/
|
||||||
private async executeStatus(): Promise<void> {
|
private async executeStatus(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const result = await this.displayStatus();
|
const result = this.displayStatus();
|
||||||
this.setLastResult(result);
|
this.setLastResult(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.handleError(error);
|
this.handleError(error);
|
||||||
@@ -171,8 +172,8 @@ export class AuthCommand extends Command {
|
|||||||
/**
|
/**
|
||||||
* Display authentication status
|
* Display authentication status
|
||||||
*/
|
*/
|
||||||
private async displayStatus(): Promise<AuthResult> {
|
private displayStatus(): AuthResult {
|
||||||
const credentials = await this.authManager.getCredentials();
|
const credentials = this.authManager.getCredentials();
|
||||||
|
|
||||||
console.log(chalk.cyan('\n🔐 Authentication Status\n'));
|
console.log(chalk.cyan('\n🔐 Authentication Status\n'));
|
||||||
|
|
||||||
@@ -325,7 +326,7 @@ export class AuthCommand extends Command {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!continueAuth) {
|
if (!continueAuth) {
|
||||||
const credentials = await this.authManager.getCredentials();
|
const credentials = this.authManager.getCredentials();
|
||||||
ui.displaySuccess('Using existing authentication');
|
ui.displaySuccess('Using existing authentication');
|
||||||
|
|
||||||
if (credentials) {
|
if (credentials) {
|
||||||
@@ -351,6 +352,37 @@ export class AuthCommand extends Command {
|
|||||||
chalk.gray(` Logged in as: ${credentials.email || credentials.userId}`)
|
chalk.gray(` Logged in as: ${credentials.email || credentials.userId}`)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Post-auth: Set up workspace context
|
||||||
|
console.log(); // Add spacing
|
||||||
|
try {
|
||||||
|
const contextCommand = new ContextCommand();
|
||||||
|
const contextResult = await contextCommand.setupContextInteractive();
|
||||||
|
if (contextResult.success) {
|
||||||
|
if (contextResult.orgSelected && contextResult.briefSelected) {
|
||||||
|
console.log(
|
||||||
|
chalk.green('✓ Workspace context configured successfully')
|
||||||
|
);
|
||||||
|
} else if (contextResult.orgSelected) {
|
||||||
|
console.log(chalk.green('✓ Organization selected'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
chalk.yellow('⚠ Context setup was skipped or encountered issues')
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
chalk.gray(' You can set up context later with "tm context"')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (contextError) {
|
||||||
|
console.log(chalk.yellow('⚠ Context setup encountered an error'));
|
||||||
|
console.log(
|
||||||
|
chalk.gray(' You can set up context later with "tm context"')
|
||||||
|
);
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(chalk.gray((contextError as Error).message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
action: 'login',
|
action: 'login',
|
||||||
@@ -490,7 +522,7 @@ export class AuthCommand extends Command {
|
|||||||
/**
|
/**
|
||||||
* Get current credentials (for programmatic usage)
|
* Get current credentials (for programmatic usage)
|
||||||
*/
|
*/
|
||||||
getCredentials(): Promise<AuthCredentials | null> {
|
getCredentials(): AuthCredentials | null {
|
||||||
return this.authManager.getCredentials();
|
return this.authManager.getCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import inquirer from 'inquirer';
|
import inquirer from 'inquirer';
|
||||||
|
import search from '@inquirer/search';
|
||||||
import ora, { Ora } from 'ora';
|
import ora, { Ora } from 'ora';
|
||||||
import {
|
import {
|
||||||
AuthManager,
|
AuthManager,
|
||||||
@@ -115,7 +116,7 @@ export class ContextCommand extends Command {
|
|||||||
*/
|
*/
|
||||||
private async executeShow(): Promise<void> {
|
private async executeShow(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const result = await this.displayContext();
|
const result = this.displayContext();
|
||||||
this.setLastResult(result);
|
this.setLastResult(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.handleError(error);
|
this.handleError(error);
|
||||||
@@ -126,7 +127,7 @@ export class ContextCommand extends Command {
|
|||||||
/**
|
/**
|
||||||
* Display current context
|
* Display current context
|
||||||
*/
|
*/
|
||||||
private async displayContext(): Promise<ContextResult> {
|
private displayContext(): ContextResult {
|
||||||
// Check authentication first
|
// Check authentication first
|
||||||
if (!this.authManager.isAuthenticated()) {
|
if (!this.authManager.isAuthenticated()) {
|
||||||
console.log(chalk.yellow('✗ Not authenticated'));
|
console.log(chalk.yellow('✗ Not authenticated'));
|
||||||
@@ -139,7 +140,7 @@ export class ContextCommand extends Command {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await this.authManager.getContext();
|
const context = this.authManager.getContext();
|
||||||
|
|
||||||
console.log(chalk.cyan('\n🌍 Workspace Context\n'));
|
console.log(chalk.cyan('\n🌍 Workspace Context\n'));
|
||||||
|
|
||||||
@@ -156,10 +157,14 @@ export class ContextCommand extends Command {
|
|||||||
|
|
||||||
if (context.briefName || context.briefId) {
|
if (context.briefName || context.briefId) {
|
||||||
console.log(chalk.green('\n✓ Brief'));
|
console.log(chalk.green('\n✓ Brief'));
|
||||||
if (context.briefName) {
|
if (context.briefName && context.briefId) {
|
||||||
|
const shortId = context.briefId.slice(0, 8);
|
||||||
|
console.log(
|
||||||
|
chalk.white(` ${context.briefName} `) + chalk.gray(`(${shortId})`)
|
||||||
|
);
|
||||||
|
} else if (context.briefName) {
|
||||||
console.log(chalk.white(` ${context.briefName}`));
|
console.log(chalk.white(` ${context.briefName}`));
|
||||||
}
|
} else if (context.briefId) {
|
||||||
if (context.briefId) {
|
|
||||||
console.log(chalk.gray(` ID: ${context.briefId}`));
|
console.log(chalk.gray(` ID: ${context.briefId}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +255,7 @@ export class ContextCommand extends Command {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Update context
|
// Update context
|
||||||
await this.authManager.updateContext({
|
this.authManager.updateContext({
|
||||||
orgId: selectedOrg.id,
|
orgId: selectedOrg.id,
|
||||||
orgName: selectedOrg.name,
|
orgName: selectedOrg.name,
|
||||||
// Clear brief when changing org
|
// Clear brief when changing org
|
||||||
@@ -263,7 +268,7 @@ export class ContextCommand extends Command {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
action: 'select-org',
|
action: 'select-org',
|
||||||
context: (await this.authManager.getContext()) || undefined,
|
context: this.authManager.getContext() || undefined,
|
||||||
message: `Selected organization: ${selectedOrg.name}`
|
message: `Selected organization: ${selectedOrg.name}`
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -284,7 +289,7 @@ export class ContextCommand extends Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if org is selected
|
// Check if org is selected
|
||||||
const context = await this.authManager.getContext();
|
const context = this.authManager.getContext();
|
||||||
if (!context?.orgId) {
|
if (!context?.orgId) {
|
||||||
ui.displayError(
|
ui.displayError(
|
||||||
'No organization selected. Run "tm context org" first.'
|
'No organization selected. Run "tm context org" first.'
|
||||||
@@ -324,26 +329,54 @@ export class ContextCommand extends Command {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prompt for selection
|
// Prompt for selection with search
|
||||||
const { selectedBrief } = await inquirer.prompt([
|
const selectedBrief = await search<(typeof briefs)[0] | null>({
|
||||||
{
|
message: 'Search for a brief:',
|
||||||
type: 'list',
|
source: async (input) => {
|
||||||
name: 'selectedBrief',
|
const searchTerm = input?.toLowerCase() || '';
|
||||||
message: 'Select a brief:',
|
|
||||||
choices: [
|
// Static option for no brief
|
||||||
{ name: '(No brief - organization level)', value: null },
|
const noBriefOption = {
|
||||||
...briefs.map((brief) => ({
|
name: '(No brief - organization level)',
|
||||||
name: `Brief ${brief.id} (${new Date(brief.createdAt).toLocaleDateString()})`,
|
value: null as any,
|
||||||
|
description: 'Clear brief selection'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter and map brief options
|
||||||
|
const briefOptions = briefs
|
||||||
|
.filter((brief) => {
|
||||||
|
if (!searchTerm) return true;
|
||||||
|
|
||||||
|
const title = brief.document?.title || '';
|
||||||
|
const shortId = brief.id.slice(0, 8);
|
||||||
|
|
||||||
|
// Search by title first, then by UUID
|
||||||
|
return (
|
||||||
|
title.toLowerCase().includes(searchTerm) ||
|
||||||
|
brief.id.toLowerCase().includes(searchTerm) ||
|
||||||
|
shortId.toLowerCase().includes(searchTerm)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((brief) => {
|
||||||
|
const title =
|
||||||
|
brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
|
||||||
|
const shortId = brief.id.slice(0, 8);
|
||||||
|
return {
|
||||||
|
name: `${title} ${chalk.gray(`(${shortId})`)}`,
|
||||||
value: brief
|
value: brief
|
||||||
}))
|
};
|
||||||
]
|
});
|
||||||
|
|
||||||
|
return [noBriefOption, ...briefOptions];
|
||||||
}
|
}
|
||||||
]);
|
});
|
||||||
|
|
||||||
if (selectedBrief) {
|
if (selectedBrief) {
|
||||||
// Update context with brief
|
// Update context with brief
|
||||||
const briefName = `Brief ${selectedBrief.id.slice(0, 8)}`;
|
const briefName =
|
||||||
await this.authManager.updateContext({
|
selectedBrief.document?.title ||
|
||||||
|
`Brief ${selectedBrief.id.slice(0, 8)}`;
|
||||||
|
this.authManager.updateContext({
|
||||||
briefId: selectedBrief.id,
|
briefId: selectedBrief.id,
|
||||||
briefName: briefName
|
briefName: briefName
|
||||||
});
|
});
|
||||||
@@ -353,12 +386,12 @@ export class ContextCommand extends Command {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
action: 'select-brief',
|
action: 'select-brief',
|
||||||
context: (await this.authManager.getContext()) || undefined,
|
context: this.authManager.getContext() || undefined,
|
||||||
message: `Selected brief: ${selectedBrief.name}`
|
message: `Selected brief: ${selectedBrief.document?.title}`
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Clear brief selection
|
// Clear brief selection
|
||||||
await this.authManager.updateContext({
|
this.authManager.updateContext({
|
||||||
briefId: undefined,
|
briefId: undefined,
|
||||||
briefName: undefined
|
briefName: undefined
|
||||||
});
|
});
|
||||||
@@ -368,7 +401,7 @@ export class ContextCommand extends Command {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
action: 'select-brief',
|
action: 'select-brief',
|
||||||
context: (await this.authManager.getContext()) || undefined,
|
context: this.authManager.getContext() || undefined,
|
||||||
message: 'Cleared brief selection'
|
message: 'Cleared brief selection'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -468,7 +501,7 @@ export class ContextCommand extends Command {
|
|||||||
if (!briefId) {
|
if (!briefId) {
|
||||||
spinner.fail('Could not extract a brief ID from the provided input');
|
spinner.fail('Could not extract a brief ID from the provided input');
|
||||||
ui.displayError(
|
ui.displayError(
|
||||||
`Provide a valid brief ID or a Hamster brief URL, e.g. https://${process.env.TM_PUBLIC_BASE_DOMAIN}/home/hamster/briefs/<id>`
|
`Provide a valid brief ID or a Hamster brief URL, e.g. https://${process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN}/home/hamster/briefs/<id>`
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -490,8 +523,9 @@ export class ContextCommand extends Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update context: set org and brief
|
// Update context: set org and brief
|
||||||
const briefName = `Brief ${brief.id.slice(0, 8)}`;
|
const briefName =
|
||||||
await this.authManager.updateContext({
|
brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
|
||||||
|
this.authManager.updateContext({
|
||||||
orgId: brief.accountId,
|
orgId: brief.accountId,
|
||||||
orgName,
|
orgName,
|
||||||
briefId: brief.id,
|
briefId: brief.id,
|
||||||
@@ -508,7 +542,7 @@ export class ContextCommand extends Command {
|
|||||||
this.setLastResult({
|
this.setLastResult({
|
||||||
success: true,
|
success: true,
|
||||||
action: 'set',
|
action: 'set',
|
||||||
context: (await this.authManager.getContext()) || undefined,
|
context: this.authManager.getContext() || undefined,
|
||||||
message: 'Context set from brief'
|
message: 'Context set from brief'
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -613,7 +647,7 @@ export class ContextCommand extends Command {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.authManager.updateContext(context);
|
this.authManager.updateContext(context);
|
||||||
ui.displaySuccess('Context updated');
|
ui.displaySuccess('Context updated');
|
||||||
|
|
||||||
// Display what was set
|
// Display what was set
|
||||||
@@ -631,7 +665,7 @@ export class ContextCommand extends Command {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
action: 'set',
|
action: 'set',
|
||||||
context: (await this.authManager.getContext()) || undefined,
|
context: this.authManager.getContext() || undefined,
|
||||||
message: 'Context updated'
|
message: 'Context updated'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -682,10 +716,57 @@ export class ContextCommand extends Command {
|
|||||||
/**
|
/**
|
||||||
* Get current context (for programmatic usage)
|
* Get current context (for programmatic usage)
|
||||||
*/
|
*/
|
||||||
getContext(): Promise<UserContext | null> {
|
getContext(): UserContext | null {
|
||||||
return this.authManager.getContext();
|
return this.authManager.getContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive context setup (for post-auth flow)
|
||||||
|
* Prompts user to select org and brief
|
||||||
|
*/
|
||||||
|
async setupContextInteractive(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
orgSelected: boolean;
|
||||||
|
briefSelected: boolean;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// Ask if user wants to set up workspace context
|
||||||
|
const { setupContext } = await inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'setupContext',
|
||||||
|
message: 'Would you like to set up your workspace context now?',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!setupContext) {
|
||||||
|
return { success: true, orgSelected: false, briefSelected: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select organization
|
||||||
|
const orgResult = await this.selectOrganization();
|
||||||
|
if (!orgResult.success || !orgResult.context?.orgId) {
|
||||||
|
return { success: false, orgSelected: false, briefSelected: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select brief
|
||||||
|
const briefResult = await this.selectBrief(orgResult.context.orgId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
orgSelected: true,
|
||||||
|
briefSelected: briefResult.success
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
chalk.yellow(
|
||||||
|
'\nContext setup skipped due to error. You can set it up later with "tm context"'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { success: false, orgSelected: false, briefSelected: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up resources
|
* Clean up resources
|
||||||
*/
|
*/
|
||||||
|
|||||||
86
package-lock.json
generated
86
package-lock.json
generated
@@ -104,6 +104,7 @@
|
|||||||
"name": "@tm/cli",
|
"name": "@tm/cli",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@inquirer/search": "^3.2.0",
|
||||||
"@tm/core": "*",
|
"@tm/core": "*",
|
||||||
"boxen": "^8.0.1",
|
"boxen": "^8.0.1",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
@@ -124,6 +125,91 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"apps/cli/node_modules/@inquirer/ansi": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/cli/node_modules/@inquirer/figures": {
|
||||||
|
"version": "1.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.14.tgz",
|
||||||
|
"integrity": "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/cli/node_modules/@inquirer/search": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-a5SzB/qrXafDX1Z4AZW3CsVoiNxcIYCzYP7r9RzrfMpaLpB+yWi5U8BWagZyLmwR0pKbbL5umnGRd0RzGVI8bQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@inquirer/core": "^10.3.0",
|
||||||
|
"@inquirer/figures": "^1.0.14",
|
||||||
|
"@inquirer/type": "^3.0.9",
|
||||||
|
"yoctocolors-cjs": "^2.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/cli/node_modules/@inquirer/search/node_modules/@inquirer/core": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@inquirer/ansi": "^1.0.1",
|
||||||
|
"@inquirer/figures": "^1.0.14",
|
||||||
|
"@inquirer/type": "^3.0.9",
|
||||||
|
"cli-width": "^4.1.0",
|
||||||
|
"mute-stream": "^2.0.0",
|
||||||
|
"signal-exit": "^4.1.0",
|
||||||
|
"wrap-ansi": "^6.2.0",
|
||||||
|
"yoctocolors-cjs": "^2.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/cli/node_modules/@inquirer/search/node_modules/@inquirer/type": {
|
||||||
|
"version": "3.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.9.tgz",
|
||||||
|
"integrity": "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"apps/docs": {
|
"apps/docs": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ vi.mock('./credential-store.js', () => {
|
|||||||
}
|
}
|
||||||
saveCredentials() {}
|
saveCredentials() {}
|
||||||
clearCredentials() {}
|
clearCredentials() {}
|
||||||
hasValidCredentials() {
|
hasCredentials() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ export class AuthManager {
|
|||||||
private oauthService: OAuthService;
|
private oauthService: OAuthService;
|
||||||
private supabaseClient: SupabaseAuthClient;
|
private supabaseClient: SupabaseAuthClient;
|
||||||
private organizationService?: OrganizationService;
|
private organizationService?: OrganizationService;
|
||||||
private logger = getLogger('AuthManager');
|
|
||||||
private refreshPromise: Promise<AuthCredentials> | null = null;
|
|
||||||
|
|
||||||
private constructor(config?: Partial<AuthConfig>) {
|
private constructor(config?: Partial<AuthConfig>) {
|
||||||
this.credentialStore = CredentialStore.getInstance(config);
|
this.credentialStore = CredentialStore.getInstance(config);
|
||||||
@@ -83,60 +81,10 @@ export class AuthManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get stored authentication credentials
|
* Get stored authentication credentials
|
||||||
* Automatically refreshes the token if expired
|
* Returns credentials as-is (even if expired). Refresh must be triggered explicitly
|
||||||
|
* via refreshToken() or will occur automatically when using the Supabase client for API calls.
|
||||||
*/
|
*/
|
||||||
async getCredentials(): Promise<AuthCredentials | null> {
|
getCredentials(): AuthCredentials | null {
|
||||||
const credentials = this.credentialStore.getCredentials({
|
|
||||||
allowExpired: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!credentials) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if credentials are expired (with 30-second clock skew buffer)
|
|
||||||
const CLOCK_SKEW_MS = 30_000;
|
|
||||||
const isExpired = credentials.expiresAt
|
|
||||||
? new Date(credentials.expiresAt).getTime() <= Date.now() + CLOCK_SKEW_MS
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// If expired and we have a refresh token, attempt refresh
|
|
||||||
if (isExpired && credentials.refreshToken) {
|
|
||||||
// Return existing refresh promise if one is in progress
|
|
||||||
if (this.refreshPromise) {
|
|
||||||
try {
|
|
||||||
return await this.refreshPromise;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.logger.info('Token expired, attempting automatic refresh...');
|
|
||||||
this.refreshPromise = this.refreshToken();
|
|
||||||
const result = await this.refreshPromise;
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn('Automatic token refresh failed:', error);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
this.refreshPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return null if expired and no refresh token
|
|
||||||
if (isExpired) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return credentials;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stored authentication credentials (synchronous version)
|
|
||||||
* Does not attempt automatic refresh
|
|
||||||
*/
|
|
||||||
getCredentialsSync(): AuthCredentials | null {
|
|
||||||
return this.credentialStore.getCredentials();
|
return this.credentialStore.getCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,25 +167,26 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if authenticated
|
* Check if authenticated (credentials exist, regardless of expiration)
|
||||||
|
* @returns true if credentials are stored, including expired credentials
|
||||||
*/
|
*/
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
return this.credentialStore.hasValidCredentials();
|
return this.credentialStore.hasCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current user context (org/brief selection)
|
* Get the current user context (org/brief selection)
|
||||||
*/
|
*/
|
||||||
async getContext(): Promise<UserContext | null> {
|
getContext(): UserContext | null {
|
||||||
const credentials = await this.getCredentials();
|
const credentials = this.getCredentials();
|
||||||
return credentials?.selectedContext || null;
|
return credentials?.selectedContext || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the user context (org/brief selection)
|
* Update the user context (org/brief selection)
|
||||||
*/
|
*/
|
||||||
async updateContext(context: Partial<UserContext>): Promise<void> {
|
updateContext(context: Partial<UserContext>): void {
|
||||||
const credentials = await this.getCredentials();
|
const credentials = this.getCredentials();
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
|
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
|
||||||
}
|
}
|
||||||
@@ -262,8 +211,8 @@ export class AuthManager {
|
|||||||
/**
|
/**
|
||||||
* Clear the user context
|
* Clear the user context
|
||||||
*/
|
*/
|
||||||
async clearContext(): Promise<void> {
|
clearContext(): void {
|
||||||
const credentials = await this.getCredentials();
|
const credentials = this.getCredentials();
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
|
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
|
||||||
}
|
}
|
||||||
@@ -280,7 +229,7 @@ export class AuthManager {
|
|||||||
private async getOrganizationService(): Promise<OrganizationService> {
|
private async getOrganizationService(): Promise<OrganizationService> {
|
||||||
if (!this.organizationService) {
|
if (!this.organizationService) {
|
||||||
// First check if we have credentials with a token
|
// First check if we have credentials with a token
|
||||||
const credentials = await this.getCredentials();
|
const credentials = this.getCredentials();
|
||||||
if (!credentials || !credentials.token) {
|
if (!credentials || !credentials.token) {
|
||||||
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
|
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import path from 'path';
|
|||||||
import { AuthConfig } from './types.js';
|
import { AuthConfig } from './types.js';
|
||||||
|
|
||||||
// Single base domain for all URLs
|
// Single base domain for all URLs
|
||||||
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsup's env option
|
// Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
|
||||||
|
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option
|
||||||
|
// Runtime: process.env.TM_BASE_DOMAIN can override for staging/development
|
||||||
// Default: https://tryhamster.com for production
|
// Default: https://tryhamster.com for production
|
||||||
const BASE_DOMAIN =
|
const BASE_DOMAIN =
|
||||||
process.env.TM_PUBLIC_BASE_DOMAIN || // This gets replaced at build time by tsup
|
process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux)
|
||||||
'https://tryhamster.com';
|
process.env.TM_PUBLIC_BASE_DOMAIN; // Build-time (baked into compiled code)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default authentication configuration
|
* Default authentication configuration
|
||||||
@@ -19,7 +21,7 @@ const BASE_DOMAIN =
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_AUTH_CONFIG: AuthConfig = {
|
export const DEFAULT_AUTH_CONFIG: AuthConfig = {
|
||||||
// Base domain for all services
|
// Base domain for all services
|
||||||
baseUrl: BASE_DOMAIN,
|
baseUrl: BASE_DOMAIN!,
|
||||||
|
|
||||||
// Configuration directory and file paths
|
// Configuration directory and file paths
|
||||||
configDir: path.join(os.homedir(), '.taskmaster'),
|
configDir: path.join(os.homedir(), '.taskmaster'),
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(expiredCredentials);
|
credentialStore.saveCredentials(expiredCredentials);
|
||||||
|
|
||||||
const retrieved = credentialStore.getCredentials();
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||||
|
|
||||||
expect(retrieved).toBeNull();
|
expect(retrieved).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -69,7 +69,7 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(validCredentials);
|
credentialStore.saveCredentials(validCredentials);
|
||||||
|
|
||||||
const retrieved = credentialStore.getCredentials();
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||||
|
|
||||||
expect(retrieved).not.toBeNull();
|
expect(retrieved).not.toBeNull();
|
||||||
expect(retrieved?.token).toBe('valid-token');
|
expect(retrieved?.token).toBe('valid-token');
|
||||||
@@ -92,6 +92,25 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
expect(retrieved).not.toBeNull();
|
expect(retrieved).not.toBeNull();
|
||||||
expect(retrieved?.token).toBe('expired-token');
|
expect(retrieved?.token).toBe('expired-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return expired token by default (allowExpired defaults to true)', () => {
|
||||||
|
const expiredCredentials: AuthCredentials = {
|
||||||
|
token: 'expired-token-default',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
userId: 'test-user',
|
||||||
|
email: 'test@example.com',
|
||||||
|
expiresAt: new Date(Date.now() - 60000).toISOString(),
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
credentialStore.saveCredentials(expiredCredentials);
|
||||||
|
|
||||||
|
// Call without options - should default to allowExpired: true
|
||||||
|
const retrieved = credentialStore.getCredentials();
|
||||||
|
|
||||||
|
expect(retrieved).not.toBeNull();
|
||||||
|
expect(retrieved?.token).toBe('expired-token-default');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Clock Skew Tolerance', () => {
|
describe('Clock Skew Tolerance', () => {
|
||||||
@@ -108,7 +127,7 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(almostExpiredCredentials);
|
credentialStore.saveCredentials(almostExpiredCredentials);
|
||||||
|
|
||||||
const retrieved = credentialStore.getCredentials();
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||||
|
|
||||||
expect(retrieved).toBeNull();
|
expect(retrieved).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -126,7 +145,7 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(validCredentials);
|
credentialStore.saveCredentials(validCredentials);
|
||||||
|
|
||||||
const retrieved = credentialStore.getCredentials();
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||||
|
|
||||||
expect(retrieved).not.toBeNull();
|
expect(retrieved).not.toBeNull();
|
||||||
expect(retrieved?.token).toBe('valid-token');
|
expect(retrieved?.token).toBe('valid-token');
|
||||||
@@ -146,7 +165,7 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(credentials);
|
credentialStore.saveCredentials(credentials);
|
||||||
|
|
||||||
const retrieved = credentialStore.getCredentials();
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||||
|
|
||||||
expect(retrieved).not.toBeNull();
|
expect(retrieved).not.toBeNull();
|
||||||
expect(typeof retrieved?.expiresAt).toBe('number'); // Normalized to number
|
expect(typeof retrieved?.expiresAt).toBe('number'); // Normalized to number
|
||||||
@@ -164,7 +183,7 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(credentials);
|
credentialStore.saveCredentials(credentials);
|
||||||
|
|
||||||
const retrieved = credentialStore.getCredentials();
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||||
|
|
||||||
expect(retrieved).not.toBeNull();
|
expect(retrieved).not.toBeNull();
|
||||||
expect(typeof retrieved?.expiresAt).toBe('number');
|
expect(typeof retrieved?.expiresAt).toBe('number');
|
||||||
@@ -185,7 +204,7 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
mode: 0o600
|
mode: 0o600
|
||||||
});
|
});
|
||||||
|
|
||||||
const retrieved = credentialStore.getCredentials();
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||||
|
|
||||||
expect(retrieved).toBeNull();
|
expect(retrieved).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -203,7 +222,7 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
mode: 0o600
|
mode: 0o600
|
||||||
});
|
});
|
||||||
|
|
||||||
const retrieved = credentialStore.getCredentials();
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||||
|
|
||||||
expect(retrieved).toBeNull();
|
expect(retrieved).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -244,15 +263,15 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(credentials);
|
credentialStore.saveCredentials(credentials);
|
||||||
|
|
||||||
const retrieved = credentialStore.getCredentials();
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
||||||
|
|
||||||
// Should be normalized to number for runtime use
|
// Should be normalized to number for runtime use
|
||||||
expect(typeof retrieved?.expiresAt).toBe('number');
|
expect(typeof retrieved?.expiresAt).toBe('number');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hasValidCredentials', () => {
|
describe('hasCredentials', () => {
|
||||||
it('should return false for expired credentials', () => {
|
it('should return true for expired credentials', () => {
|
||||||
const expiredCredentials: AuthCredentials = {
|
const expiredCredentials: AuthCredentials = {
|
||||||
token: 'expired-token',
|
token: 'expired-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
@@ -264,7 +283,7 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(expiredCredentials);
|
credentialStore.saveCredentials(expiredCredentials);
|
||||||
|
|
||||||
expect(credentialStore.hasValidCredentials()).toBe(false);
|
expect(credentialStore.hasCredentials()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for valid credentials', () => {
|
it('should return true for valid credentials', () => {
|
||||||
@@ -279,11 +298,11 @@ describe('CredentialStore - Token Expiration', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(validCredentials);
|
credentialStore.saveCredentials(validCredentials);
|
||||||
|
|
||||||
expect(credentialStore.hasValidCredentials()).toBe(true);
|
expect(credentialStore.hasCredentials()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when no credentials exist', () => {
|
it('should return false when no credentials exist', () => {
|
||||||
expect(credentialStore.hasValidCredentials()).toBe(false);
|
expect(credentialStore.hasCredentials()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ describe('CredentialStore', () => {
|
|||||||
JSON.stringify(mockCredentials)
|
JSON.stringify(mockCredentials)
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = store.getCredentials();
|
const result = store.getCredentials({ allowExpired: false });
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
@@ -226,6 +226,31 @@ describe('CredentialStore', () => {
|
|||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result?.token).toBe('expired-token');
|
expect(result?.token).toBe('expired-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return expired tokens by default (allowExpired defaults to true)', () => {
|
||||||
|
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
|
||||||
|
const mockCredentials = {
|
||||||
|
token: 'expired-token-default',
|
||||||
|
userId: 'user-expired',
|
||||||
|
expiresAt: expiredTimestamp,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||||
|
JSON.stringify(mockCredentials)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call without options - should default to allowExpired: true
|
||||||
|
const result = store.getCredentials();
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.token).toBe('expired-token-default');
|
||||||
|
expect(mockLogger.warn).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Authentication token has expired')
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('saveCredentials with timestamp normalization', () => {
|
describe('saveCredentials with timestamp normalization', () => {
|
||||||
@@ -451,7 +476,7 @@ describe('CredentialStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hasValidCredentials', () => {
|
describe('hasCredentials', () => {
|
||||||
it('should return true when valid unexpired credentials exist', () => {
|
it('should return true when valid unexpired credentials exist', () => {
|
||||||
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
|
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
|
||||||
const credentials = {
|
const credentials = {
|
||||||
@@ -465,10 +490,10 @@ describe('CredentialStore', () => {
|
|||||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
||||||
|
|
||||||
expect(store.hasValidCredentials()).toBe(true);
|
expect(store.hasCredentials()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when credentials are expired', () => {
|
it('should return true when credentials are expired', () => {
|
||||||
const pastDate = new Date(Date.now() - 3600000); // 1 hour ago
|
const pastDate = new Date(Date.now() - 3600000); // 1 hour ago
|
||||||
const credentials = {
|
const credentials = {
|
||||||
token: 'expired-token',
|
token: 'expired-token',
|
||||||
@@ -481,13 +506,13 @@ describe('CredentialStore', () => {
|
|||||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
||||||
|
|
||||||
expect(store.hasValidCredentials()).toBe(false);
|
expect(store.hasCredentials()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when no credentials exist', () => {
|
it('should return false when no credentials exist', () => {
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
expect(store.hasValidCredentials()).toBe(false);
|
expect(store.hasCredentials()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when file contains invalid JSON', () => {
|
it('should return false when file contains invalid JSON', () => {
|
||||||
@@ -495,7 +520,7 @@ describe('CredentialStore', () => {
|
|||||||
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
||||||
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
||||||
|
|
||||||
expect(store.hasValidCredentials()).toBe(false);
|
expect(store.hasCredentials()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for credentials without expiry', () => {
|
it('should return false for credentials without expiry', () => {
|
||||||
@@ -510,7 +535,7 @@ describe('CredentialStore', () => {
|
|||||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
||||||
|
|
||||||
// Credentials without expiry are considered invalid
|
// Credentials without expiry are considered invalid
|
||||||
expect(store.hasValidCredentials()).toBe(false);
|
expect(store.hasCredentials()).toBe(false);
|
||||||
|
|
||||||
// Should log warning about missing expiration
|
// Should log warning about missing expiration
|
||||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
@@ -518,14 +543,14 @@ describe('CredentialStore', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use allowExpired=false by default', () => {
|
it('should use allowExpired=true', () => {
|
||||||
// Spy on getCredentials to verify it's called with correct params
|
// Spy on getCredentials to verify it's called with correct params
|
||||||
const getCredentialsSpy = vi.spyOn(store, 'getCredentials');
|
const getCredentialsSpy = vi.spyOn(store, 'getCredentials');
|
||||||
|
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
store.hasValidCredentials();
|
store.hasCredentials();
|
||||||
|
|
||||||
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: false });
|
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export class CredentialStore {
|
|||||||
private config: AuthConfig;
|
private config: AuthConfig;
|
||||||
// Clock skew tolerance for expiry checks (30 seconds)
|
// Clock skew tolerance for expiry checks (30 seconds)
|
||||||
private readonly CLOCK_SKEW_MS = 30_000;
|
private readonly CLOCK_SKEW_MS = 30_000;
|
||||||
|
// Track if we've already warned about missing expiration to avoid spam
|
||||||
|
private hasWarnedAboutMissingExpiration = false;
|
||||||
|
|
||||||
private constructor(config?: Partial<AuthConfig>) {
|
private constructor(config?: Partial<AuthConfig>) {
|
||||||
this.config = getAuthConfig(config);
|
this.config = getAuthConfig(config);
|
||||||
@@ -54,9 +56,12 @@ export class CredentialStore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get stored authentication credentials
|
* Get stored authentication credentials
|
||||||
|
* @param options.allowExpired - Whether to return expired credentials (default: true)
|
||||||
* @returns AuthCredentials with expiresAt as number (milliseconds) for runtime use
|
* @returns AuthCredentials with expiresAt as number (milliseconds) for runtime use
|
||||||
*/
|
*/
|
||||||
getCredentials(options?: { allowExpired?: boolean }): AuthCredentials | null {
|
getCredentials({
|
||||||
|
allowExpired = true
|
||||||
|
}: { allowExpired?: boolean } = {}): AuthCredentials | null {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(this.config.configFile)) {
|
if (!fs.existsSync(this.config.configFile)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -81,7 +86,11 @@ export class CredentialStore {
|
|||||||
|
|
||||||
// Validate expiration time for tokens
|
// Validate expiration time for tokens
|
||||||
if (expiresAtMs === undefined) {
|
if (expiresAtMs === undefined) {
|
||||||
|
// Only log this warning once to avoid spam during auth flows
|
||||||
|
if (!this.hasWarnedAboutMissingExpiration) {
|
||||||
this.logger.warn('No valid expiration time provided for token');
|
this.logger.warn('No valid expiration time provided for token');
|
||||||
|
this.hasWarnedAboutMissingExpiration = true;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +99,6 @@ export class CredentialStore {
|
|||||||
|
|
||||||
// Check if the token has expired (with clock skew tolerance)
|
// Check if the token has expired (with clock skew tolerance)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const allowExpired = options?.allowExpired ?? false;
|
|
||||||
if (now >= expiresAtMs - this.CLOCK_SKEW_MS && !allowExpired) {
|
if (now >= expiresAtMs - this.CLOCK_SKEW_MS && !allowExpired) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'Authentication token has expired or is about to expire',
|
'Authentication token has expired or is about to expire',
|
||||||
@@ -103,7 +111,7 @@ export class CredentialStore {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return valid token
|
// Return credentials (even if expired) to enable refresh flows
|
||||||
return authData;
|
return authData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -172,6 +180,9 @@ export class CredentialStore {
|
|||||||
mode: 0o600
|
mode: 0o600
|
||||||
});
|
});
|
||||||
fs.renameSync(tempFile, this.config.configFile);
|
fs.renameSync(tempFile, this.config.configFile);
|
||||||
|
|
||||||
|
// Reset the warning flag so it can be shown again for future invalid tokens
|
||||||
|
this.hasWarnedAboutMissingExpiration = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AuthenticationError(
|
throw new AuthenticationError(
|
||||||
`Failed to save auth credentials: ${(error as Error).message}`,
|
`Failed to save auth credentials: ${(error as Error).message}`,
|
||||||
@@ -199,10 +210,11 @@ export class CredentialStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if credentials exist and are valid
|
* Check if credentials exist (regardless of expiration status)
|
||||||
|
* @returns true if credentials are stored, including expired credentials
|
||||||
*/
|
*/
|
||||||
hasValidCredentials(): boolean {
|
hasCredentials(): boolean {
|
||||||
const credentials = this.getCredentials({ allowExpired: false });
|
const credentials = this.getCredentials({ allowExpired: true });
|
||||||
return credentials !== null;
|
return credentials !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -281,15 +281,26 @@ export class OAuthService {
|
|||||||
// Exchange code for session using PKCE
|
// Exchange code for session using PKCE
|
||||||
const session = await this.supabaseClient.exchangeCodeForSession(code);
|
const session = await this.supabaseClient.exchangeCodeForSession(code);
|
||||||
|
|
||||||
|
// Calculate expiration - can be overridden with TM_TOKEN_EXPIRY_MINUTES
|
||||||
|
let expiresAt: string | undefined;
|
||||||
|
const tokenExpiryMinutes = process.env.TM_TOKEN_EXPIRY_MINUTES;
|
||||||
|
if (tokenExpiryMinutes) {
|
||||||
|
const minutes = parseInt(tokenExpiryMinutes);
|
||||||
|
expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
|
||||||
|
this.logger.warn(`Token expiry overridden to ${minutes} minute(s)`);
|
||||||
|
} else {
|
||||||
|
expiresAt = session.expires_at
|
||||||
|
? new Date(session.expires_at * 1000).toISOString()
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Save authentication data
|
// Save authentication data
|
||||||
const authData: AuthCredentials = {
|
const authData: AuthCredentials = {
|
||||||
token: session.access_token,
|
token: session.access_token,
|
||||||
refreshToken: session.refresh_token,
|
refreshToken: session.refresh_token,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
expiresAt: session.expires_at
|
expiresAt,
|
||||||
? new Date(session.expires_at * 1000).toISOString()
|
|
||||||
: undefined,
|
|
||||||
tokenType: 'standard',
|
tokenType: 'standard',
|
||||||
savedAt: new Date().toISOString()
|
savedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
@@ -340,10 +351,18 @@ export class OAuthService {
|
|||||||
// Get user info from the session
|
// Get user info from the session
|
||||||
const user = await this.supabaseClient.getUser();
|
const user = await this.supabaseClient.getUser();
|
||||||
|
|
||||||
// Calculate expiration time
|
// Calculate expiration time - can be overridden with TM_TOKEN_EXPIRY_MINUTES
|
||||||
const expiresAt = expiresIn
|
let expiresAt: string | undefined;
|
||||||
|
const tokenExpiryMinutes = process.env.TM_TOKEN_EXPIRY_MINUTES;
|
||||||
|
if (tokenExpiryMinutes) {
|
||||||
|
const minutes = parseInt(tokenExpiryMinutes);
|
||||||
|
expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
|
||||||
|
this.logger.warn(`Token expiry overridden to ${minutes} minute(s)`);
|
||||||
|
} else {
|
||||||
|
expiresAt = expiresIn
|
||||||
? new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString()
|
? new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Save authentication data
|
// Save authentication data
|
||||||
const authData: AuthCredentials = {
|
const authData: AuthCredentials = {
|
||||||
@@ -351,7 +370,7 @@ export class OAuthService {
|
|||||||
refreshToken: refreshToken || undefined,
|
refreshToken: refreshToken || undefined,
|
||||||
userId: user?.id || 'unknown',
|
userId: user?.id || 'unknown',
|
||||||
email: user?.email,
|
email: user?.email,
|
||||||
expiresAt: expiresAt,
|
expiresAt,
|
||||||
tokenType: 'standard',
|
tokenType: 'standard',
|
||||||
savedAt: new Date().toISOString()
|
savedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,11 +98,11 @@ export class SupabaseSessionStorage implements SupportedStorage {
|
|||||||
// Only handle Supabase session keys
|
// Only handle Supabase session keys
|
||||||
if (key === STORAGE_KEY || key.includes('auth-token')) {
|
if (key === STORAGE_KEY || key.includes('auth-token')) {
|
||||||
try {
|
try {
|
||||||
|
this.logger.info('Supabase called setItem - storing refreshed session');
|
||||||
|
|
||||||
// Parse the session and update our credentials
|
// Parse the session and update our credentials
|
||||||
const sessionUpdates = this.parseSessionToCredentials(value);
|
const sessionUpdates = this.parseSessionToCredentials(value);
|
||||||
const existingCredentials = this.store.getCredentials({
|
const existingCredentials = this.store.getCredentials();
|
||||||
allowExpired: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sessionUpdates.token) {
|
if (sessionUpdates.token) {
|
||||||
const updatedCredentials: AuthCredentials = {
|
const updatedCredentials: AuthCredentials = {
|
||||||
@@ -113,6 +113,9 @@ export class SupabaseSessionStorage implements SupportedStorage {
|
|||||||
} as AuthCredentials;
|
} as AuthCredentials;
|
||||||
|
|
||||||
this.store.saveCredentials(updatedCredentials);
|
this.store.saveCredentials(updatedCredentials);
|
||||||
|
this.logger.info(
|
||||||
|
'Successfully saved refreshed credentials from Supabase'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Error setting session:', error);
|
this.logger.error('Error setting session:', error);
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ export class SupabaseAuthClient {
|
|||||||
private client: SupabaseJSClient | null = null;
|
private client: SupabaseJSClient | null = null;
|
||||||
private sessionStorage: SupabaseSessionStorage;
|
private sessionStorage: SupabaseSessionStorage;
|
||||||
private logger = getLogger('SupabaseAuthClient');
|
private logger = getLogger('SupabaseAuthClient');
|
||||||
|
private credentialStore: CredentialStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const credentialStore = CredentialStore.getInstance();
|
this.credentialStore = CredentialStore.getInstance();
|
||||||
this.sessionStorage = new SupabaseSessionStorage(credentialStore);
|
this.sessionStorage = new SupabaseSessionStorage(this.credentialStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,13 +29,17 @@ export class SupabaseAuthClient {
|
|||||||
*/
|
*/
|
||||||
getClient(): SupabaseJSClient {
|
getClient(): SupabaseJSClient {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
// Get Supabase configuration from environment - using TM_PUBLIC prefix
|
// Get Supabase configuration from environment
|
||||||
const supabaseUrl = process.env.TM_PUBLIC_SUPABASE_URL;
|
// Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
|
||||||
const supabaseAnonKey = process.env.TM_PUBLIC_SUPABASE_ANON_KEY;
|
const supabaseUrl =
|
||||||
|
process.env.TM_SUPABASE_URL || process.env.TM_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey =
|
||||||
|
process.env.TM_SUPABASE_ANON_KEY ||
|
||||||
|
process.env.TM_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
throw new AuthenticationError(
|
throw new AuthenticationError(
|
||||||
'Supabase configuration missing. Please set TM_PUBLIC_SUPABASE_URL and TM_PUBLIC_SUPABASE_ANON_KEY environment variables.',
|
'Supabase configuration missing. Please set TM_SUPABASE_URL and TM_SUPABASE_ANON_KEY (runtime) or TM_PUBLIC_SUPABASE_URL and TM_PUBLIC_SUPABASE_ANON_KEY (build-time) environment variables.',
|
||||||
'CONFIG_MISSING'
|
'CONFIG_MISSING'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,11 +358,12 @@ export class ExportService {
|
|||||||
tasks: any[]
|
tasks: any[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Check if we should use the API endpoint or direct Supabase
|
// Check if we should use the API endpoint or direct Supabase
|
||||||
const useAPIEndpoint = process.env.TM_PUBLIC_BASE_DOMAIN;
|
const apiEndpoint =
|
||||||
|
process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN;
|
||||||
|
|
||||||
if (useAPIEndpoint) {
|
if (apiEndpoint) {
|
||||||
// Use the new bulk import API endpoint
|
// Use the new bulk import API endpoint
|
||||||
const apiUrl = `${process.env.TM_PUBLIC_BASE_DOMAIN}/ai/api/v1/briefs/${briefId}/tasks`;
|
const apiUrl = `${apiEndpoint}/ai/api/v1/briefs/${briefId}/tasks`;
|
||||||
|
|
||||||
// Transform tasks to flat structure for API
|
// Transform tasks to flat structure for API
|
||||||
const flatTasks = this.transformTasksForBulkImport(tasks);
|
const flatTasks = this.transformTasksForBulkImport(tasks);
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export interface Brief {
|
|||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
document?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
document_name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,7 +177,12 @@ export class OrganizationService {
|
|||||||
document_id,
|
document_id,
|
||||||
status,
|
status,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at,
|
||||||
|
document:document_id (
|
||||||
|
id,
|
||||||
|
document_name,
|
||||||
|
title
|
||||||
|
)
|
||||||
`)
|
`)
|
||||||
.eq('account_id', orgId);
|
.eq('account_id', orgId);
|
||||||
|
|
||||||
@@ -196,7 +207,14 @@ export class OrganizationService {
|
|||||||
documentId: brief.document_id,
|
documentId: brief.document_id,
|
||||||
status: brief.status,
|
status: brief.status,
|
||||||
createdAt: brief.created_at,
|
createdAt: brief.created_at,
|
||||||
updatedAt: brief.updated_at
|
updatedAt: brief.updated_at,
|
||||||
|
document: brief.document
|
||||||
|
? {
|
||||||
|
id: brief.document.id,
|
||||||
|
document_name: brief.document.document_name,
|
||||||
|
title: brief.document.title
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TaskMasterError) {
|
if (error instanceof TaskMasterError) {
|
||||||
@@ -224,7 +242,13 @@ export class OrganizationService {
|
|||||||
document_id,
|
document_id,
|
||||||
status,
|
status,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at,
|
||||||
|
document:document_id (
|
||||||
|
id,
|
||||||
|
document_name,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
)
|
||||||
`)
|
`)
|
||||||
.eq('id', briefId)
|
.eq('id', briefId)
|
||||||
.single();
|
.single();
|
||||||
@@ -253,7 +277,15 @@ export class OrganizationService {
|
|||||||
documentId: briefData.document_id,
|
documentId: briefData.document_id,
|
||||||
status: briefData.status,
|
status: briefData.status,
|
||||||
createdAt: briefData.created_at,
|
createdAt: briefData.created_at,
|
||||||
updatedAt: briefData.updated_at
|
updatedAt: briefData.updated_at,
|
||||||
|
document: briefData.document
|
||||||
|
? {
|
||||||
|
id: briefData.document.id,
|
||||||
|
document_name: briefData.document.document_name,
|
||||||
|
title: briefData.document.title,
|
||||||
|
description: briefData.document.description
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TaskMasterError) {
|
if (error instanceof TaskMasterError) {
|
||||||
|
|||||||
@@ -112,6 +112,13 @@ export class ApiStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the storage type
|
||||||
|
*/
|
||||||
|
getType(): 'api' {
|
||||||
|
return 'api';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load tags into cache
|
* Load tags into cache
|
||||||
* In our API-based system, "tags" represent briefs
|
* In our API-based system, "tags" represent briefs
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export class FileStorage implements IStorage {
|
|||||||
await this.fileOps.cleanup();
|
await this.fileOps.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the storage type
|
||||||
|
*/
|
||||||
|
getType(): 'file' {
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get statistics about the storage
|
* Get statistics about the storage
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export class StorageFactory {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Use auth token from AuthManager (synchronous - no auto-refresh here)
|
// Use auth token from AuthManager (synchronous - no auto-refresh here)
|
||||||
const credentials = authManager.getCredentialsSync();
|
const credentials = authManager.getCredentials();
|
||||||
if (credentials) {
|
if (credentials) {
|
||||||
// Merge with existing storage config, ensuring required fields
|
// Merge with existing storage config, ensuring required fields
|
||||||
const nextStorage: StorageSettings = {
|
const nextStorage: StorageSettings = {
|
||||||
@@ -82,8 +82,8 @@ export class StorageFactory {
|
|||||||
apiAccessToken: credentials.token,
|
apiAccessToken: credentials.token,
|
||||||
apiEndpoint:
|
apiEndpoint:
|
||||||
config.storage?.apiEndpoint ||
|
config.storage?.apiEndpoint ||
|
||||||
process.env.TM_PUBLIC_BASE_DOMAIN ||
|
process.env.TM_BASE_DOMAIN ||
|
||||||
'https://tryhamster.com/api'
|
process.env.TM_PUBLIC_BASE_DOMAIN
|
||||||
};
|
};
|
||||||
config.storage = nextStorage;
|
config.storage = nextStorage;
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ export class StorageFactory {
|
|||||||
|
|
||||||
// Then check if authenticated via AuthManager
|
// Then check if authenticated via AuthManager
|
||||||
if (authManager.isAuthenticated()) {
|
if (authManager.isAuthenticated()) {
|
||||||
const credentials = authManager.getCredentialsSync();
|
const credentials = authManager.getCredentials();
|
||||||
if (credentials) {
|
if (credentials) {
|
||||||
// Configure API storage with auth credentials
|
// Configure API storage with auth credentials
|
||||||
const nextStorage: StorageSettings = {
|
const nextStorage: StorageSettings = {
|
||||||
@@ -112,6 +112,7 @@ export class StorageFactory {
|
|||||||
apiAccessToken: credentials.token,
|
apiAccessToken: credentials.token,
|
||||||
apiEndpoint:
|
apiEndpoint:
|
||||||
config.storage?.apiEndpoint ||
|
config.storage?.apiEndpoint ||
|
||||||
|
process.env.TM_BASE_DOMAIN ||
|
||||||
process.env.TM_PUBLIC_BASE_DOMAIN ||
|
process.env.TM_PUBLIC_BASE_DOMAIN ||
|
||||||
'https://tryhamster.com/api'
|
'https://tryhamster.com/api'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe('AuthManager Token Refresh', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not make concurrent refresh requests', async () => {
|
it('should return expired credentials to enable refresh flows', () => {
|
||||||
// Set up expired credentials with refresh token
|
// Set up expired credentials with refresh token
|
||||||
const expiredCredentials: AuthCredentials = {
|
const expiredCredentials: AuthCredentials = {
|
||||||
token: 'expired_access_token',
|
token: 'expired_access_token',
|
||||||
@@ -63,50 +63,16 @@ describe('AuthManager Token Refresh', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(expiredCredentials);
|
credentialStore.saveCredentials(expiredCredentials);
|
||||||
|
|
||||||
// Mock the refreshToken method to track calls
|
// Get credentials should return them even if expired
|
||||||
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
|
// Refresh will be handled by explicit calls or client operations
|
||||||
const mockSession: Session = {
|
const credentials = authManager.getCredentials();
|
||||||
access_token: 'new_access_token',
|
|
||||||
refresh_token: 'new_refresh_token',
|
|
||||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
||||||
user: {
|
|
||||||
id: 'test-user-id',
|
|
||||||
email: 'test@example.com',
|
|
||||||
app_metadata: {},
|
|
||||||
user_metadata: {},
|
|
||||||
aud: 'authenticated',
|
|
||||||
created_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
refreshTokenSpy.mockResolvedValue({
|
expect(credentials).not.toBeNull();
|
||||||
token: mockSession.access_token,
|
expect(credentials?.token).toBe('expired_access_token');
|
||||||
refreshToken: mockSession.refresh_token,
|
expect(credentials?.refreshToken).toBe('valid_refresh_token');
|
||||||
userId: mockSession.user.id,
|
|
||||||
email: mockSession.user.email,
|
|
||||||
expiresAt: new Date(mockSession.expires_at! * 1000).toISOString(),
|
|
||||||
savedAt: new Date().toISOString()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Make multiple concurrent calls to getCredentials
|
it('should return valid credentials', () => {
|
||||||
const promises = [
|
|
||||||
authManager.getCredentials(),
|
|
||||||
authManager.getCredentials(),
|
|
||||||
authManager.getCredentials()
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
|
|
||||||
// Verify all calls returned the same new credentials
|
|
||||||
expect(results[0]?.token).toBe('new_access_token');
|
|
||||||
expect(results[1]?.token).toBe('new_access_token');
|
|
||||||
expect(results[2]?.token).toBe('new_access_token');
|
|
||||||
|
|
||||||
// Verify refreshToken was only called once, not three times
|
|
||||||
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return valid credentials without attempting refresh', async () => {
|
|
||||||
// Set up valid (non-expired) credentials
|
// Set up valid (non-expired) credentials
|
||||||
const validCredentials: AuthCredentials = {
|
const validCredentials: AuthCredentials = {
|
||||||
token: 'valid_access_token',
|
token: 'valid_access_token',
|
||||||
@@ -119,17 +85,14 @@ describe('AuthManager Token Refresh', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(validCredentials);
|
credentialStore.saveCredentials(validCredentials);
|
||||||
|
|
||||||
// Spy on refreshToken to ensure it's not called
|
const credentials = authManager.getCredentials();
|
||||||
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
|
|
||||||
|
|
||||||
const credentials = await authManager.getCredentials();
|
|
||||||
|
|
||||||
expect(credentials?.token).toBe('valid_access_token');
|
expect(credentials?.token).toBe('valid_access_token');
|
||||||
expect(refreshTokenSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if credentials are expired with no refresh token', async () => {
|
it('should return expired credentials even without refresh token', () => {
|
||||||
// Set up expired credentials WITHOUT refresh token
|
// Set up expired credentials WITHOUT refresh token
|
||||||
|
// We still return them - it's up to the caller to handle
|
||||||
const expiredCredentials: AuthCredentials = {
|
const expiredCredentials: AuthCredentials = {
|
||||||
token: 'expired_access_token',
|
token: 'expired_access_token',
|
||||||
refreshToken: undefined,
|
refreshToken: undefined,
|
||||||
@@ -141,17 +104,19 @@ describe('AuthManager Token Refresh', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(expiredCredentials);
|
credentialStore.saveCredentials(expiredCredentials);
|
||||||
|
|
||||||
const credentials = await authManager.getCredentials();
|
const credentials = authManager.getCredentials();
|
||||||
|
|
||||||
|
// Returns credentials even if expired
|
||||||
|
expect(credentials).not.toBeNull();
|
||||||
|
expect(credentials?.token).toBe('expired_access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if no credentials exist', () => {
|
||||||
|
const credentials = authManager.getCredentials();
|
||||||
expect(credentials).toBeNull();
|
expect(credentials).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if no credentials exist', async () => {
|
it('should return credentials regardless of refresh token validity', () => {
|
||||||
const credentials = await authManager.getCredentials();
|
|
||||||
expect(credentials).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle refresh failures gracefully', async () => {
|
|
||||||
// Set up expired credentials with refresh token
|
// Set up expired credentials with refresh token
|
||||||
const expiredCredentials: AuthCredentials = {
|
const expiredCredentials: AuthCredentials = {
|
||||||
token: 'expired_access_token',
|
token: 'expired_access_token',
|
||||||
@@ -164,13 +129,11 @@ describe('AuthManager Token Refresh', () => {
|
|||||||
|
|
||||||
credentialStore.saveCredentials(expiredCredentials);
|
credentialStore.saveCredentials(expiredCredentials);
|
||||||
|
|
||||||
// Mock refreshToken to throw an error
|
const credentials = authManager.getCredentials();
|
||||||
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
|
|
||||||
refreshTokenSpy.mockRejectedValue(new Error('Refresh failed'));
|
|
||||||
|
|
||||||
const credentials = await authManager.getCredentials();
|
// Returns credentials - refresh will be attempted by the client which will handle failure
|
||||||
|
expect(credentials).not.toBeNull();
|
||||||
expect(credentials).toBeNull();
|
expect(credentials?.token).toBe('expired_access_token');
|
||||||
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
|
expect(credentials?.refreshToken).toBe('invalid_refresh_token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Expired Token Detection', () => {
|
describe('Expired Token Detection', () => {
|
||||||
it('should detect expired token', async () => {
|
it('should return expired token for Supabase to refresh', () => {
|
||||||
// Set up expired credentials
|
// Set up expired credentials
|
||||||
const expiredCredentials: AuthCredentials = {
|
const expiredCredentials: AuthCredentials = {
|
||||||
token: 'expired-token',
|
token: 'expired-token',
|
||||||
@@ -91,24 +91,15 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
|
|
||||||
authManager = AuthManager.getInstance();
|
authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
// Mock the Supabase refreshSession to return new tokens
|
// Get credentials returns them even if expired
|
||||||
const mockRefreshSession = vi
|
const credentials = authManager.getCredentials();
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(mockRefreshedSession);
|
|
||||||
vi.spyOn(
|
|
||||||
authManager['supabaseClient'],
|
|
||||||
'refreshSession'
|
|
||||||
).mockImplementation(mockRefreshSession);
|
|
||||||
|
|
||||||
// Get credentials should trigger refresh
|
|
||||||
const credentials = await authManager.getCredentials();
|
|
||||||
|
|
||||||
expect(mockRefreshSession).toHaveBeenCalledTimes(1);
|
|
||||||
expect(credentials).not.toBeNull();
|
expect(credentials).not.toBeNull();
|
||||||
expect(credentials?.token).toBe('new-access-token-xyz');
|
expect(credentials?.token).toBe('expired-token');
|
||||||
|
expect(credentials?.refreshToken).toBe('valid-refresh-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not refresh valid token', async () => {
|
it('should return valid token', () => {
|
||||||
// Set up valid credentials
|
// Set up valid credentials
|
||||||
const validCredentials: AuthCredentials = {
|
const validCredentials: AuthCredentials = {
|
||||||
token: 'valid-token',
|
token: 'valid-token',
|
||||||
@@ -123,22 +114,14 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
|
|
||||||
authManager = AuthManager.getInstance();
|
authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
// Mock refresh to ensure it's not called
|
const credentials = authManager.getCredentials();
|
||||||
const mockRefreshSession = vi.fn();
|
|
||||||
vi.spyOn(
|
|
||||||
authManager['supabaseClient'],
|
|
||||||
'refreshSession'
|
|
||||||
).mockImplementation(mockRefreshSession);
|
|
||||||
|
|
||||||
const credentials = await authManager.getCredentials();
|
|
||||||
|
|
||||||
expect(mockRefreshSession).not.toHaveBeenCalled();
|
|
||||||
expect(credentials?.token).toBe('valid-token');
|
expect(credentials?.token).toBe('valid-token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Token Refresh Flow', () => {
|
describe('Token Refresh Flow', () => {
|
||||||
it('should refresh expired token and save new credentials', async () => {
|
it('should manually refresh expired token and save new credentials', async () => {
|
||||||
const expiredCredentials: AuthCredentials = {
|
const expiredCredentials: AuthCredentials = {
|
||||||
token: 'old-token',
|
token: 'old-token',
|
||||||
refreshToken: 'old-refresh-token',
|
refreshToken: 'old-refresh-token',
|
||||||
@@ -162,23 +145,24 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
'refreshSession'
|
'refreshSession'
|
||||||
).mockResolvedValue(mockRefreshedSession);
|
).mockResolvedValue(mockRefreshedSession);
|
||||||
|
|
||||||
const refreshedCredentials = await authManager.getCredentials();
|
// Explicitly call refreshToken() method
|
||||||
|
const refreshedCredentials = await authManager.refreshToken();
|
||||||
|
|
||||||
expect(refreshedCredentials).not.toBeNull();
|
expect(refreshedCredentials).not.toBeNull();
|
||||||
expect(refreshedCredentials?.token).toBe('new-access-token-xyz');
|
expect(refreshedCredentials.token).toBe('new-access-token-xyz');
|
||||||
expect(refreshedCredentials?.refreshToken).toBe('new-refresh-token-xyz');
|
expect(refreshedCredentials.refreshToken).toBe('new-refresh-token-xyz');
|
||||||
|
|
||||||
// Verify context was preserved
|
// Verify context was preserved
|
||||||
expect(refreshedCredentials?.selectedContext?.orgId).toBe('test-org');
|
expect(refreshedCredentials.selectedContext?.orgId).toBe('test-org');
|
||||||
expect(refreshedCredentials?.selectedContext?.briefId).toBe('test-brief');
|
expect(refreshedCredentials.selectedContext?.briefId).toBe('test-brief');
|
||||||
|
|
||||||
// Verify new expiration is in the future
|
// Verify new expiration is in the future
|
||||||
const newExpiry = new Date(refreshedCredentials!.expiresAt!).getTime();
|
const newExpiry = new Date(refreshedCredentials.expiresAt!).getTime();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
expect(newExpiry).toBeGreaterThan(now);
|
expect(newExpiry).toBeGreaterThan(now);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if refresh fails', async () => {
|
it('should throw error if manual refresh fails', async () => {
|
||||||
const expiredCredentials: AuthCredentials = {
|
const expiredCredentials: AuthCredentials = {
|
||||||
token: 'expired-token',
|
token: 'expired-token',
|
||||||
refreshToken: 'invalid-refresh-token',
|
refreshToken: 'invalid-refresh-token',
|
||||||
@@ -198,12 +182,11 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
'refreshSession'
|
'refreshSession'
|
||||||
).mockRejectedValue(new Error('Refresh token expired'));
|
).mockRejectedValue(new Error('Refresh token expired'));
|
||||||
|
|
||||||
const credentials = await authManager.getCredentials();
|
// Explicit refreshToken() call should throw
|
||||||
|
await expect(authManager.refreshToken()).rejects.toThrow();
|
||||||
expect(credentials).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if no refresh token available', async () => {
|
it('should return expired credentials even without refresh token', () => {
|
||||||
const expiredCredentials: AuthCredentials = {
|
const expiredCredentials: AuthCredentials = {
|
||||||
token: 'expired-token',
|
token: 'expired-token',
|
||||||
// No refresh token
|
// No refresh token
|
||||||
@@ -217,18 +200,21 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
|
|
||||||
authManager = AuthManager.getInstance();
|
authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
const credentials = await authManager.getCredentials();
|
const credentials = authManager.getCredentials();
|
||||||
|
|
||||||
expect(credentials).toBeNull();
|
// Credentials are returned even without refresh token
|
||||||
|
expect(credentials).not.toBeNull();
|
||||||
|
expect(credentials?.token).toBe('expired-token');
|
||||||
|
expect(credentials?.refreshToken).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if credentials missing expiresAt', async () => {
|
it('should return null if credentials missing expiresAt', () => {
|
||||||
const credentialsWithoutExpiry: AuthCredentials = {
|
const credentialsWithoutExpiry: AuthCredentials = {
|
||||||
token: 'test-token',
|
token: 'test-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
userId: 'test-user-id',
|
userId: 'test-user-id',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
// Missing expiresAt
|
// Missing expiresAt - invalid token
|
||||||
savedAt: new Date().toISOString()
|
savedAt: new Date().toISOString()
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
@@ -236,16 +222,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
|
|
||||||
authManager = AuthManager.getInstance();
|
authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
const credentials = await authManager.getCredentials();
|
const credentials = authManager.getCredentials();
|
||||||
|
|
||||||
// Should return null because no valid expiration
|
// Tokens without valid expiration are considered invalid
|
||||||
expect(credentials).toBeNull();
|
expect(credentials).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Clock Skew Tolerance', () => {
|
describe('Clock Skew Tolerance', () => {
|
||||||
it('should refresh token within 30-second expiry window', async () => {
|
it('should return credentials within 30-second expiry window', () => {
|
||||||
// Token expires in 15 seconds (within 30-second buffer)
|
// Token expires in 15 seconds (within 30-second buffer)
|
||||||
|
// Supabase will handle refresh automatically
|
||||||
const almostExpiredCredentials: AuthCredentials = {
|
const almostExpiredCredentials: AuthCredentials = {
|
||||||
token: 'almost-expired-token',
|
token: 'almost-expired-token',
|
||||||
refreshToken: 'valid-refresh-token',
|
refreshToken: 'valid-refresh-token',
|
||||||
@@ -259,23 +246,16 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
|
|
||||||
authManager = AuthManager.getInstance();
|
authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
const mockRefreshSession = vi
|
const credentials = authManager.getCredentials();
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(mockRefreshedSession);
|
|
||||||
vi.spyOn(
|
|
||||||
authManager['supabaseClient'],
|
|
||||||
'refreshSession'
|
|
||||||
).mockImplementation(mockRefreshSession);
|
|
||||||
|
|
||||||
const credentials = await authManager.getCredentials();
|
// Credentials are returned (Supabase handles auto-refresh in background)
|
||||||
|
expect(credentials).not.toBeNull();
|
||||||
// Should trigger refresh due to 30-second buffer
|
expect(credentials?.token).toBe('almost-expired-token');
|
||||||
expect(mockRefreshSession).toHaveBeenCalledTimes(1);
|
expect(credentials?.refreshToken).toBe('valid-refresh-token');
|
||||||
expect(credentials?.token).toBe('new-access-token-xyz');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not refresh token well before expiry', async () => {
|
it('should return valid token well before expiry', () => {
|
||||||
// Token expires in 5 minutes (well outside 30-second buffer)
|
// Token expires in 5 minutes
|
||||||
const validCredentials: AuthCredentials = {
|
const validCredentials: AuthCredentials = {
|
||||||
token: 'valid-token',
|
token: 'valid-token',
|
||||||
refreshToken: 'valid-refresh-token',
|
refreshToken: 'valid-refresh-token',
|
||||||
@@ -289,21 +269,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
|
|
||||||
authManager = AuthManager.getInstance();
|
authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
const mockRefreshSession = vi.fn();
|
const credentials = authManager.getCredentials();
|
||||||
vi.spyOn(
|
|
||||||
authManager['supabaseClient'],
|
|
||||||
'refreshSession'
|
|
||||||
).mockImplementation(mockRefreshSession);
|
|
||||||
|
|
||||||
const credentials = await authManager.getCredentials();
|
// Valid credentials are returned as-is
|
||||||
|
expect(credentials).not.toBeNull();
|
||||||
expect(mockRefreshSession).not.toHaveBeenCalled();
|
|
||||||
expect(credentials?.token).toBe('valid-token');
|
expect(credentials?.token).toBe('valid-token');
|
||||||
|
expect(credentials?.refreshToken).toBe('valid-refresh-token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Synchronous vs Async Methods', () => {
|
describe('Synchronous vs Async Methods', () => {
|
||||||
it('getCredentialsSync should not trigger refresh', () => {
|
it('getCredentials should return expired credentials', () => {
|
||||||
const expiredCredentials: AuthCredentials = {
|
const expiredCredentials: AuthCredentials = {
|
||||||
token: 'expired-token',
|
token: 'expired-token',
|
||||||
refreshToken: 'valid-refresh-token',
|
refreshToken: 'valid-refresh-token',
|
||||||
@@ -317,40 +293,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
|
|
||||||
authManager = AuthManager.getInstance();
|
authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
// Synchronous call should return null without refresh
|
// Returns credentials even if expired - Supabase will handle refresh
|
||||||
const credentials = authManager.getCredentialsSync();
|
const credentials = authManager.getCredentials();
|
||||||
|
|
||||||
expect(credentials).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getCredentials async should trigger refresh', async () => {
|
|
||||||
const expiredCredentials: AuthCredentials = {
|
|
||||||
token: 'expired-token',
|
|
||||||
refreshToken: 'valid-refresh-token',
|
|
||||||
userId: 'test-user-id',
|
|
||||||
email: 'test@example.com',
|
|
||||||
expiresAt: new Date(Date.now() - 60000).toISOString(),
|
|
||||||
savedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
credentialStore.saveCredentials(expiredCredentials);
|
|
||||||
|
|
||||||
authManager = AuthManager.getInstance();
|
|
||||||
|
|
||||||
vi.spyOn(
|
|
||||||
authManager['supabaseClient'],
|
|
||||||
'refreshSession'
|
|
||||||
).mockResolvedValue(mockRefreshedSession);
|
|
||||||
|
|
||||||
const credentials = await authManager.getCredentials();
|
|
||||||
|
|
||||||
expect(credentials).not.toBeNull();
|
expect(credentials).not.toBeNull();
|
||||||
expect(credentials?.token).toBe('new-access-token-xyz');
|
expect(credentials?.token).toBe('expired-token');
|
||||||
|
expect(credentials?.refreshToken).toBe('valid-refresh-token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Multiple Concurrent Calls', () => {
|
describe('Multiple Concurrent Calls', () => {
|
||||||
it('should handle concurrent getCredentials calls gracefully', async () => {
|
it('should handle concurrent getCredentials calls gracefully', () => {
|
||||||
const expiredCredentials: AuthCredentials = {
|
const expiredCredentials: AuthCredentials = {
|
||||||
token: 'expired-token',
|
token: 'expired-token',
|
||||||
refreshToken: 'valid-refresh-token',
|
refreshToken: 'valid-refresh-token',
|
||||||
@@ -364,29 +317,20 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
|
|||||||
|
|
||||||
authManager = AuthManager.getInstance();
|
authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
const mockRefreshSession = vi
|
// Make multiple concurrent calls (synchronous now)
|
||||||
.fn()
|
const creds1 = authManager.getCredentials();
|
||||||
.mockResolvedValue(mockRefreshedSession);
|
const creds2 = authManager.getCredentials();
|
||||||
vi.spyOn(
|
const creds3 = authManager.getCredentials();
|
||||||
authManager['supabaseClient'],
|
|
||||||
'refreshSession'
|
|
||||||
).mockImplementation(mockRefreshSession);
|
|
||||||
|
|
||||||
// Make multiple concurrent calls
|
// All should get the same credentials (even if expired)
|
||||||
const [creds1, creds2, creds3] = await Promise.all([
|
expect(creds1?.token).toBe('expired-token');
|
||||||
authManager.getCredentials(),
|
expect(creds2?.token).toBe('expired-token');
|
||||||
authManager.getCredentials(),
|
expect(creds3?.token).toBe('expired-token');
|
||||||
authManager.getCredentials()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// All should get the refreshed token
|
// All include refresh token for Supabase to use
|
||||||
expect(creds1?.token).toBe('new-access-token-xyz');
|
expect(creds1?.refreshToken).toBe('valid-refresh-token');
|
||||||
expect(creds2?.token).toBe('new-access-token-xyz');
|
expect(creds2?.refreshToken).toBe('valid-refresh-token');
|
||||||
expect(creds3?.token).toBe('new-access-token-xyz');
|
expect(creds3?.refreshToken).toBe('valid-refresh-token');
|
||||||
|
|
||||||
// Refresh might be called multiple times, but that's okay
|
|
||||||
// (ideally we'd debounce, but this is acceptable behavior)
|
|
||||||
expect(mockRefreshSession).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Load .env BEFORE any other imports to ensure env vars are available
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
// Add at the very beginning of the file
|
// Add at the very beginning of the file
|
||||||
@@ -16,7 +18,8 @@ if (process.env.DEBUG === '1') {
|
|||||||
console.error('DEBUG - dev.js received args:', process.argv.slice(2));
|
console.error('DEBUG - dev.js received args:', process.argv.slice(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
import { runCLI } from './modules/commands.js';
|
// Use dynamic import to ensure dotenv.config() runs before module-level code executes
|
||||||
|
const { runCLI } = await import('./modules/commands.js');
|
||||||
|
|
||||||
// Run the CLI with the process arguments
|
// Run the CLI with the process arguments
|
||||||
runCLI(process.argv);
|
runCLI(process.argv);
|
||||||
|
|||||||
Reference in New Issue
Block a user