Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions client/packages/cli/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
mkdir,
mkdtemp,
readFile,
rm,
unlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const paths = vi.hoisted(() => ({ root: '' }));

vi.mock('env-paths', () => ({
default: (name: string) => ({ config: `${paths.root}/${name}` }),
}));

import {
readAuthToken,
removeAuthToken,
writeAuthToken,
} from '../src/auth/index.ts';

beforeEach(async () => {
paths.root = await mkdtemp(join(tmpdir(), 'instant-cli-auth-'));
});

afterEach(async () => {
await rm(paths.root, { recursive: true, force: true });
});

const writeLegacyAuthToken = async (
configName: 'instantdb-prod' | 'instantdb-dev',
authToken: string,
) => {
const configDir = join(paths.root, configName);
await mkdir(configDir, { recursive: true });
await writeFile(join(configDir, 'a'), authToken);
};

describe('backend-scoped auth tokens', () => {
it('treats equivalent backend URLs as the same credential scope', async () => {
await writeAuthToken(
'https://EXAMPLE.com:443/backend/?ignored=true',
'auth-token',
);

expect(await readAuthToken('https://example.com/backend')).toBe(
'auth-token',
);
});

it('rejects non-HTTP URLs', async () => {
await expect(readAuthToken('ftp://example.com')).rejects.toThrow(
'Instant API URI must use http:// or https://',
);
});

it('stores and removes credentials independently by backend', async () => {
const cloudApiURI = 'https://api.instantdb.com';
const selfHostedApiURI = 'https://instant.example.com';

await writeAuthToken(cloudApiURI, 'cloud-token');
await writeAuthToken(selfHostedApiURI, 'self-hosted-token');

expect(await readAuthToken(cloudApiURI)).toBe('cloud-token');
expect(await readAuthToken(selfHostedApiURI)).toBe('self-hosted-token');

expect(await removeAuthToken(selfHostedApiURI)).toBe(true);
expect(await readAuthToken(selfHostedApiURI)).toBeNull();
expect(await readAuthToken(cloudApiURI)).toBe('cloud-token');
});

it('does not read or overwrite a legacy Cloud token for a custom backend', async () => {
await writeLegacyAuthToken('instantdb-prod', 'cloud-token');

const selfHostedApiURI = 'https://instant.example.com';
expect(await readAuthToken(selfHostedApiURI)).toBeNull();

await writeAuthToken(selfHostedApiURI, 'self-hosted-token');
expect(
await readFile(join(paths.root, 'instantdb-prod', 'a'), 'utf8'),
).toBe('cloud-token');
});

it('copies the legacy Cloud token into scoped storage', async () => {
const apiURI = 'https://api.instantdb.com';
const legacyPath = join(paths.root, 'instantdb-prod', 'a');
await writeLegacyAuthToken('instantdb-prod', 'cloud-token');

expect(await readAuthToken(apiURI)).toBe('cloud-token');
await unlink(legacyPath);
expect(await readAuthToken(apiURI)).toBe('cloud-token');
});

it('copies the legacy localhost token into scoped storage', async () => {
const apiURI = 'http://localhost:8888';
const legacyPath = join(paths.root, 'instantdb-dev', 'a');
await writeLegacyAuthToken('instantdb-dev', 'local-token');

expect(await readAuthToken(apiURI)).toBe('local-token');
await unlink(legacyPath);
expect(await readAuthToken(apiURI)).toBe('local-token');
});

it('keeps legacy clients logged in for known backends', async () => {
await writeAuthToken('https://api.instantdb.com', 'cloud-token');
await writeAuthToken('http://localhost:8888', 'local-token');

expect(
await readFile(join(paths.root, 'instantdb-prod', 'a'), 'utf8'),
).toBe('cloud-token');
expect(await readFile(join(paths.root, 'instantdb-dev', 'a'), 'utf8')).toBe(
'local-token',
);
});

it('logs out legacy and scoped credentials for the current backend', async () => {
const apiURI = 'https://api.instantdb.com';
await writeLegacyAuthToken('instantdb-prod', 'legacy-token');
await writeAuthToken(apiURI, 'scoped-token');

expect(await removeAuthToken(apiURI)).toBe(true);
expect(await readAuthToken(apiURI)).toBeNull();
expect(await removeAuthToken(apiURI)).toBe(false);
});
});
47 changes: 47 additions & 0 deletions client/packages/cli/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Effect } from 'effect';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
readInstantConfigFile: vi.fn(),
}));

vi.mock('../src/util/instantConfig.ts', () => ({
readInstantConfigFile: mocks.readInstantConfigFile,
}));

import { getDashUrl } from '../src/lib/config.ts';

beforeEach(() => {
vi.stubEnv('INSTANT_CLI_API_URI', undefined);
vi.stubEnv('INSTANT_CLI_DASH_URI', undefined);
vi.stubEnv('INSTANT_CLI_DEV', undefined);
mocks.readInstantConfigFile.mockResolvedValue({});
});

afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});

describe('dashboard URL configuration', () => {
it('reads dashURI from instant.config.ts', async () => {
mocks.readInstantConfigFile.mockResolvedValue({
dashURI: 'https://dash.instant.example',
});

await expect(Effect.runPromise(getDashUrl)).resolves.toBe(
'https://dash.instant.example',
);
});

it('prefers INSTANT_CLI_DASH_URI over instant.config.ts', async () => {
vi.stubEnv('INSTANT_CLI_DASH_URI', 'https://dash.env.example');
mocks.readInstantConfigFile.mockResolvedValue({
dashURI: 'https://dash.config.example',
});

await expect(Effect.runPromise(getDashUrl)).resolves.toBe(
'https://dash.env.example',
);
});
});
6 changes: 6 additions & 0 deletions client/packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
"directory": "client/packages/cli"
},
"exports": {
"./auth": {
"import": {
"types": "./dist/auth/index.d.ts",
"default": "./dist/auth/index.js"
}
},
"./ui": {
"import": {
"types": "./dist/ui/index.d.ts",
Expand Down
125 changes: 125 additions & 0 deletions client/packages/cli/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import envPaths from 'env-paths';

const CLOUD_API_URI = 'https://api.instantdb.com';
const LOCAL_API_URI = 'http://localhost:8888';

function normalizeApiURI(apiURI: string) {
const url = new URL(apiURI);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Instant API URI must use http:// or https://');
}

url.search = '';
url.hash = '';
url.pathname = url.pathname.replace(/\/+$/, '');

return url.toString().replace(/\/$/, '');
}

function getAuthConfigFilePath(apiURI: string) {
const normalizedApiURI = normalizeApiURI(apiURI);
const backendKey = createHash('sha256')
.update(normalizedApiURI)
.digest('hex');
const { config: configDir } = envPaths('instantdb-prod');

return join(configDir, 'auth', backendKey);
}

function getLegacyAuthConfigFilePath(apiURI: string) {
const normalizedApiURI = normalizeApiURI(apiURI);
const legacyConfigName =
normalizedApiURI === CLOUD_API_URI
? 'instantdb-prod'
: normalizedApiURI === LOCAL_API_URI
? 'instantdb-dev'
: null;

if (!legacyConfigName) {
return null;
}

return join(envPaths(legacyConfigName).config, 'a');
}

async function readFileOrNull(filePath: string) {
try {
return (await readFile(filePath, 'utf8')).trim();
} catch (error) {
if (isFileNotFoundError(error)) {
return null;
}
throw error;
}
}

export async function readAuthToken(apiURI: string) {
const authConfigFilePath = getAuthConfigFilePath(apiURI);
const authToken = await readFileOrNull(authConfigFilePath);
if (authToken) {
return authToken;
}

const legacyPath = getLegacyAuthConfigFilePath(apiURI);
if (!legacyPath) {
return null;
}

const legacyAuthToken = await readFileOrNull(legacyPath);
if (!legacyAuthToken) {
return null;
}

await writeAuthTokenFile(authConfigFilePath, legacyAuthToken).catch(
() => undefined,
);
return legacyAuthToken;
}

export async function writeAuthToken(apiURI: string, authToken: string) {
const authConfigFilePath = getAuthConfigFilePath(apiURI);
await writeAuthTokenFile(authConfigFilePath, authToken);

const legacyPath = getLegacyAuthConfigFilePath(apiURI);
if (legacyPath) {
await writeAuthTokenFile(legacyPath, authToken).catch(() => undefined);
}
}

async function writeAuthTokenFile(filePath: string, authToken: string) {
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, authToken, { encoding: 'utf8', mode: 0o600 });
}

export async function removeAuthToken(apiURI: string) {
const authConfigFilePath = getAuthConfigFilePath(apiURI);
const legacyPath = getLegacyAuthConfigFilePath(apiURI);
const paths = legacyPath
? [authConfigFilePath, legacyPath]
: [authConfigFilePath];
const removed = await Promise.all(paths.map(removeFileIfExists));
return removed.some(Boolean);
}

async function removeFileIfExists(filePath: string) {
try {
await unlink(filePath);
return true;
} catch (error) {
if (isFileNotFoundError(error)) {
return false;
}
throw error;
}
}

function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException {
return (
error instanceof Error &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
);
}
4 changes: 2 additions & 2 deletions client/packages/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Effect } from 'effect';
import openInBrowser from 'open';
import { loginDef } from '../index.ts';
import type { OptsFromCommand } from '../index.ts';
import { getDashUrl } from '../lib/http.ts';
import { getDashUrl } from '../lib/config.ts';
import {
getLoginTicketAndSecret,
saveConfigAuthToken,
Expand Down Expand Up @@ -31,7 +31,7 @@ export const loginCommand = Effect.fn(function* (
const ok = yield* promptOk(
{
promptText:
'This will open instantdb.com in your browser, OK to proceed?',
'This will open your Instant dashboard in your browser, OK to proceed?',
},
true,
);
Expand Down
35 changes: 17 additions & 18 deletions client/packages/cli/src/commands/logout.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import { Effect } from 'effect';
import { getAuthPaths } from '../util/getAuthPaths.ts';
import { FileSystem } from '@effect/platform';
import chalk from 'chalk';
import { SystemError } from '@effect/platform/Error';
import { removeAuthToken } from '../auth/index.ts';
import { getBaseUrl } from '../lib/config.ts';

export const logoutCommand = Effect.fn(function* () {
const { authConfigFilePath } = getAuthPaths();
const fs = yield* FileSystem.FileSystem;

yield* Effect.matchEffect(fs.remove(authConfigFilePath), {
onFailure: (e) =>
Effect.gen(function* () {
if (e instanceof SystemError && e.reason === 'NotFound') {
yield* Effect.log(chalk.green('You were already logged out!'));
} else {
yield* Effect.logError(chalk.red('Failed to logout: ' + e.message));
}
}),
onSuccess: () =>
Effect.log(chalk.green('Successfully logged out from Instant!')),
});
const apiURI = yield* getBaseUrl;
yield* Effect.tryPromise(() => removeAuthToken(apiURI)).pipe(
Effect.matchEffect({
onFailure: (error) =>
Effect.logError(chalk.red(`Failed to logout: ${error.message}`)),
onSuccess: (removed) =>
Effect.log(
chalk.green(
removed
? 'Successfully logged out from Instant!'
: 'You were already logged out!',
),
),
}),
);
});
Loading
Loading