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
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"node": ">=20.18.1"
},
"dependencies": {
"@doist/cli-core": "0.9.0",
"@doist/cli-core": "0.12.0",
"chalk": "5.6.2",
"commander": "14.0.2",
"marked": "18.0.3",
Expand Down
106 changes: 106 additions & 0 deletions src/__tests__/auth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ describe('OutlineTokenStore', () => {
teamName: 'Analytics',
}

/** Config-file shape that round-trips to `sampleAccount`. */
const sampleConfig = {
api_token: 'tok',
base_url: sampleAccount.baseUrl,
oauth_client_id: sampleAccount.oauthClientId,
auth_user_id: sampleAccount.id,
auth_user_name: sampleAccount.label,
auth_team_name: sampleAccount.teamName,
}

it('round-trips token + account through the config file', async () => {
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
const store = createOutlineTokenStore()
Expand Down Expand Up @@ -176,4 +186,100 @@ describe('OutlineTokenStore', () => {
const after = JSON.parse(readFileSync(TEST_CONFIG_PATH, 'utf8'))
expect(after).toEqual({ update_channel: 'pre-release' })
})

describe('ref-aware lookups', () => {
it('active(ref) returns the snapshot when the id ref matches', async () => {
writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig))
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
expect(await createOutlineTokenStore().active(sampleAccount.id)).toEqual({
token: sampleConfig.api_token,
account: sampleAccount,
})
})

it('active(ref) matches the stored label case-insensitively', async () => {
writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig))
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
expect(await createOutlineTokenStore().active('ADA')).toEqual({
token: sampleConfig.api_token,
account: sampleAccount,
})
})

it('active(ref) throws ACCOUNT_NOT_FOUND on mismatch', async () => {
writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig))
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
await expect(createOutlineTokenStore().active('other')).rejects.toMatchObject({
code: 'ACCOUNT_NOT_FOUND',
})
})

it('active(ref) throws ACCOUNT_NOT_FOUND when no token is stored', async () => {
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
await expect(createOutlineTokenStore().active(sampleAccount.id)).rejects.toMatchObject({
code: 'ACCOUNT_NOT_FOUND',
})
})

it('clear(ref) clears the config when the ref matches', async () => {
writeFileSync(
TEST_CONFIG_PATH,
JSON.stringify({ ...sampleConfig, update_channel: 'stable' }),
)
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
await createOutlineTokenStore().clear(sampleAccount.id)
const after = JSON.parse(readFileSync(TEST_CONFIG_PATH, 'utf8'))
expect(after).toEqual({ update_channel: 'stable' })
})

it('clear(ref) throws ACCOUNT_NOT_FOUND on mismatch and does not touch storage', async () => {
Comment thread
scottlovegrove marked this conversation as resolved.
writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig))
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
await expect(createOutlineTokenStore().clear('other')).rejects.toMatchObject({
code: 'ACCOUNT_NOT_FOUND',
})
const after = JSON.parse(readFileSync(TEST_CONFIG_PATH, 'utf8'))
expect(after).toEqual(sampleConfig)
})

it('clear(ref) throws ACCOUNT_NOT_FOUND when no token is stored at all', async () => {
// Guards against a regression to the old silent no-op when the
// store is empty — `attachLogoutCommand` would otherwise emit
// ✓ Logged out for a ref that never had any backing account.
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
await expect(createOutlineTokenStore().clear(sampleAccount.id)).rejects.toMatchObject({
code: 'ACCOUNT_NOT_FOUND',
})
expect(existsSync(TEST_CONFIG_PATH)).toBe(false)
})

it('list() returns the stored account flagged as default', async () => {
writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig))
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
expect(await createOutlineTokenStore().list()).toEqual([
{ account: sampleAccount, isDefault: true },
])
})

it('list() returns an empty array when no token is stored', async () => {
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
expect(await createOutlineTokenStore().list()).toEqual([])
})

it('setDefault(ref) resolves silently when the ref matches', async () => {
writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig))
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
await expect(
createOutlineTokenStore().setDefault(sampleAccount.id),
).resolves.toBeUndefined()
})

it('setDefault(ref) throws ACCOUNT_NOT_FOUND when the ref does not match', async () => {
writeFileSync(TEST_CONFIG_PATH, JSON.stringify(sampleConfig))
const { createOutlineTokenStore } = await import('../lib/auth-provider.js')
await expect(createOutlineTokenStore().setDefault('other')).rejects.toMatchObject({
code: 'ACCOUNT_NOT_FOUND',
})
})
})
})
96 changes: 71 additions & 25 deletions src/lib/auth-provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createInterface } from 'node:readline/promises'
import {
type AccountRef,
type AuthAccount,
type AuthProvider,
deriveChallenge,
Expand All @@ -9,7 +10,8 @@ import {
import { fetchWithRetry } from '../transport/fetch-with-retry.js'
import { apiRequest } from './api.js'
import { clearConfig, getBaseUrl, getOAuthClientId } from './auth.js'
import { getConfig, updateConfig } from './config.js'
import { type Config, getConfig, updateConfig } from './config.js'
import { CliError } from './errors.js'

const DEFAULT_BASE_URL = 'https://app.getoutline.com'

Expand Down Expand Up @@ -160,29 +162,54 @@ export function createOutlineAuthProvider(): AuthProvider<OutlineAccount> {
}

export function createOutlineTokenStore(): TokenStore<OutlineAccount> {
/**
* Derive a snapshot from an already-loaded config. Pure, so ref-aware
* callers can validate without a second config read.
*/
function deriveSnapshot(
config: Partial<Config>,
): { token: string; account: OutlineAccount } | null {
if (!config.api_token) return null
const id = config.auth_user_id
const label = config.auth_user_name
if (!id || !label) {
// Stored token predates this adapter (env var, pre-upgrade
// config). No persisted identity to round-trip.
return null
}
return {
token: config.api_token,
account: {
id,
label,
baseUrl: config.base_url ?? DEFAULT_BASE_URL,
oauthClientId: config.oauth_client_id ?? '',
teamName: config.auth_team_name,
},
}
}

/**
* Match the stored account against `--user <ref>`. Outline accounts use
* UUID ids and a display name — id matches are case-sensitive (UUIDs
* are canonical), label matches are case-insensitive so users can pass
* the name they see in `auth status` regardless of casing.
*/
function matchesRef(account: OutlineAccount, ref: AccountRef): boolean {
if (account.id === ref) return true
return account.label.toLowerCase() === ref.toLowerCase()
}

function refMismatch(ref: AccountRef): CliError {
return new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`)
}

return {
async active() {
const config = await getConfig()
if (!config.api_token) return null
const baseUrl = config.base_url ?? DEFAULT_BASE_URL
const oauthClientId = config.oauth_client_id ?? ''
const id = config.auth_user_id
const label = config.auth_user_name
if (!id || !label) {
// Stored token predates this adapter (env var, pre-upgrade
// config). No persisted identity to round-trip.
return null
}
return {
token: config.api_token,
account: {
id,
label,
baseUrl,
oauthClientId,
teamName: config.auth_team_name,
},
}
async active(ref?: AccountRef) {
const snapshot = deriveSnapshot(await getConfig())
if (ref === undefined) return snapshot
if (!snapshot || !matchesRef(snapshot.account, ref)) throw refMismatch(ref)
return snapshot
},
async set(account, token) {
await updateConfig({
Expand All @@ -194,8 +221,27 @@ export function createOutlineTokenStore(): TokenStore<OutlineAccount> {
auth_team_name: account.teamName,
})
},
async clear() {
await clearConfig()
async clear(ref?: AccountRef) {
// With `ref`, validate before touching storage so a mismatch is
// an `ACCOUNT_NOT_FOUND` error rather than a silent success —
// `attachLogoutCommand` treats any non-throwing `clear()` as
// success. Load the config once and hand it to `clearConfig`
// so ref-based logout stays at one read + one write.
const config = await getConfig()
if (ref !== undefined) {
const snapshot = deriveSnapshot(config)
if (!snapshot || !matchesRef(snapshot.account, ref)) throw refMismatch(ref)
}
await clearConfig(config)
},
async list() {
const snapshot = deriveSnapshot(await getConfig())
return snapshot ? [{ account: snapshot.account, isDefault: true }] : []
},
async setDefault(ref: AccountRef) {
const snapshot = deriveSnapshot(await getConfig())
if (!snapshot || !matchesRef(snapshot.account, ref)) throw refMismatch(ref)
// Single-user store — already the default once `ref` matches.
},
}
}
10 changes: 7 additions & 3 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,13 @@ export async function getTokenSource(): Promise<'env' | 'config' | null> {
* Clear the auth-related keys without deleting the file. The config is now
* shared with non-auth settings (notably `update_channel`); a blanket unlink
* would silently reset the user's update-channel preference too.
*
* Pass `existing` to skip the read when the caller has already loaded the
* config (e.g. ref-validated logout) — keeps that flow at one read + one
* write instead of two reads.
*/
export async function clearConfig(): Promise<void> {
const existing = await getConfig()
export async function clearConfig(existing?: Partial<Config>): Promise<void> {
const config = existing ?? (await getConfig())
const {
api_token,
base_url,
Expand All @@ -52,7 +56,7 @@ export async function clearConfig(): Promise<void> {
auth_user_name,
auth_team_name,
...rest
} = existing
} = config
void api_token
void base_url
void oauth_client_id
Expand Down
1 change: 1 addition & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { CoreCliError as BaseCliError }
export type { ErrorType } from '@doist/cli-core'

export type ErrorCode =
| 'ACCOUNT_NOT_FOUND'
| 'AUTH_VERIFICATION_FAILED'
| 'CONFIRMATION_REQUIRED'
| 'CONFLICTING_OPTIONS'
Expand Down
Loading