diff --git a/.changeset/cli-token-only-auth.md b/.changeset/cli-token-only-auth.md new file mode 100644 index 000000000..eeffdb91b --- /dev/null +++ b/.changeset/cli-token-only-auth.md @@ -0,0 +1,8 @@ +--- +'@walkeros/cli': patch +--- + +The CLI authenticates with a token only: create one under Account > Automation +tokens and set `WALKEROS_TOKEN`. `walkeros auth login`, `logout` and `token` are +removed, with the stored session and their exports. An old sign-in in the config +file is no longer read, and `walkeros api` refuses an `Authorization` header. diff --git a/.changeset/flow-ref-empty-hint.md b/.changeset/flow-ref-empty-hint.md new file mode 100644 index 000000000..8ad98b089 --- /dev/null +++ b/.changeset/flow-ref-empty-hint.md @@ -0,0 +1,6 @@ +--- +'@walkeros/core': patch +--- + +The error for an empty `$flow` reference names only the setting to fill in; it +no longer suggests a `walkeros deploy` command. diff --git a/.changeset/flow-ref-hyphen-names.md b/.changeset/flow-ref-hyphen-names.md new file mode 100644 index 000000000..e3b701999 --- /dev/null +++ b/.changeset/flow-ref-hyphen-names.md @@ -0,0 +1,7 @@ +--- +'@walkeros/core': patch +'@walkeros/explorer': patch +--- + +`$flow` references now accept flow names with a hyphen, such as +`$flow.tracking-server.url`. diff --git a/.changeset/moin-capture-ping.md b/.changeset/moin-capture-ping.md new file mode 100644 index 000000000..bc4311986 --- /dev/null +++ b/.changeset/moin-capture-ping.md @@ -0,0 +1,6 @@ +--- +'@walkeros/core': patch +--- + +Tag Mode's loader now hears the app's call on sites that block messages from +origins they do not know. diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 84abab73c..30a27061a 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -1,17 +1,23 @@ name: Deploy Website on: + # Skills are published as /skills pages and validated only by this workflow, + # so a skills or plugin change alone must build and deploy too. push: branches: [main] paths: - 'website/**' - 'apps/demos/storybook/**' + - 'skills/**' + - '.claude-plugin/**' - '.github/workflows/website.yml' pull_request: types: [opened, synchronize, reopened, closed] paths: - 'website/**' - 'apps/demos/storybook/**' + - 'skills/**' + - '.claude-plugin/**' - '.github/workflows/website.yml' workflow_dispatch: inputs: diff --git a/apps/explorer/src/utils/__tests__/monaco-walkeros-decorations.test.ts b/apps/explorer/src/utils/__tests__/monaco-walkeros-decorations.test.ts index a6394ea21..c3ef501f0 100644 --- a/apps/explorer/src/utils/__tests__/monaco-walkeros-decorations.test.ts +++ b/apps/explorer/src/utils/__tests__/monaco-walkeros-decorations.test.ts @@ -72,6 +72,12 @@ describe('findWalkerOSReferences', () => { expect(matches[0].name).toBe('server.url'); }); + it('finds $flow. references to a hyphenated flow name', () => { + const matches = findWalkerOSReferences('"$flow.tracking-server.url"'); + expect(matches).toHaveLength(1); + expect(matches[0].name).toBe('tracking-server.url'); + }); + it('finds $flow. prefix only', () => { const refs = findWalkerOSReferences('"$flow."'); expect(refs).toHaveLength(1); diff --git a/apps/explorer/src/utils/monaco-walkeros-decorations.ts b/apps/explorer/src/utils/monaco-walkeros-decorations.ts index 124186185..8bdd1d97d 100644 --- a/apps/explorer/src/utils/monaco-walkeros-decorations.ts +++ b/apps/explorer/src/utils/monaco-walkeros-decorations.ts @@ -1,4 +1,5 @@ import type { editor } from 'monaco-editor'; +import { REF_FLOW_NAME } from '@walkeros/core'; import { syntaxFunction, syntaxKeyword, @@ -27,7 +28,8 @@ export interface WalkerOSReference { // in @walkeros/core, but relaxed here because the decorator scans free-form // substrings inside quoted JSON/TS strings rather than whole-string values. // Name regex allows trailing dot + optional name/path so that prefix-only -// typing (e.g. `"$var."`) still lights up for IntelliSense. +// typing (e.g. `"$var."`) still lights up for IntelliSense. The `$flow` name +// is REF_FLOW_NAME itself, made optional. export const REFERENCE_PATTERNS: Array<{ type: ReferenceType; regex: RegExp; @@ -52,7 +54,7 @@ export const REFERENCE_PATTERNS: Array<{ { type: 'store', regex: /\$store\.(\w*)/g, className: 'elb-ref-store' }, { type: 'flow', - regex: /\$flow\.(\w*(?:\.[\w.]*)?)/g, + regex: new RegExp(`\\$flow\\.((?:${REF_FLOW_NAME})?(?:\\.[\\w.]*)?)`, 'g'), className: 'elb-ref-flow', }, { type: 'code', regex: /\$code:/g, className: 'elb-ref-code' }, diff --git a/package-lock.json b/package-lock.json index 8dbdabecd..8b43da433 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29760,18 +29760,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -37144,26 +37132,6 @@ "regex-recursion": "^6.0.2" } }, - "node_modules/open": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.4.0", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -39686,18 +39654,6 @@ } } }, - "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/preact": { "version": "10.29.2", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", @@ -47269,22 +47225,6 @@ } } }, - "node_modules/wsl-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/xdg-basedir": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", @@ -47523,7 +47463,6 @@ "fs-extra": "^11.3.2", "jsdom": "^29.1.1", "npm-package-arg": "^13.0.2", - "open": "^11.0.0", "pacote": "^21.0.4", "picomatch": "^4.0.4", "semver": "^7.7.4", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2a155e3a7..a5f340a44 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -66,7 +66,6 @@ "fs-extra": "^11.3.2", "jsdom": "^29.1.1", "npm-package-arg": "^13.0.2", - "open": "^11.0.0", "pacote": "^21.0.4", "picomatch": "^4.0.4", "semver": "^7.7.4", diff --git a/packages/cli/src/__tests__/unit/commands/auth-token.test.ts b/packages/cli/src/__tests__/unit/commands/auth-token.test.ts deleted file mode 100644 index bc43ea323..000000000 --- a/packages/cli/src/__tests__/unit/commands/auth-token.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import '../../helpers/setup-msw.js'; -import { server } from '../../helpers/msw-server.js'; -import { http, HttpResponse } from 'msw'; -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { Writable } from 'node:stream'; -import { authTokenCommand } from '../../../commands/auth/index.js'; -import { EXIT } from '../../../commands/api/index.js'; - -const APP = 'https://app.example.test'; - -function sink() { - const chunks: string[] = []; - return { - stream: new Writable({ - write(chunk: Buffer, _e, done) { - chunks.push(chunk.toString('utf-8')); - done(); - }, - }), - text: () => chunks.join(''), - }; -} - -/** A config dir holding an expired session that must be refreshed. */ -function expiredSession(): string { - const home = mkdtempSync(join(tmpdir(), 'walkeros-token-')); - mkdirSync(join(home, 'walkeros')); - writeFileSync( - join(home, 'walkeros', 'config.json'), - JSON.stringify({ - accessToken: 'old', - accessTokenExpiresAt: '2000-01-01T00:00:00.000Z', - refreshToken: 'rt_1', - appUrl: APP, - }), - ); - return home; -} - -const env = process.env; -afterEach(() => { - process.env = env; -}); - -describe('walkeros auth token', () => { - it('prints WALKEROS_TOKEN', async () => { - process.env = { - ...env, - WALKEROS_TOKEN: 'wos_pat_env', - XDG_CONFIG_HOME: mkdtempSync(join(tmpdir(), 'walkeros-token-')), - }; - const out = sink(); - expect( - await authTokenCommand({ stdout: out.stream, stderr: sink().stream }), - ).toBe(EXIT.ok); - expect(out.text()).toBe('wos_pat_env\n'); - }); - - it('prints the refreshed session token', async () => { - process.env = { - ...env, - WALKEROS_APP_URL: APP, - XDG_CONFIG_HOME: expiredSession(), - }; - delete process.env.WALKEROS_TOKEN; - server.use( - http.post(`${APP}/api/oauth/token`, () => - HttpResponse.json({ - access_token: 'fresh', - expires_in: 3600, - refresh_token: 'rt_2', - }), - ), - ); - const out = sink(); - expect( - await authTokenCommand({ stdout: out.stream, stderr: sink().stream }), - ).toBe(EXIT.ok); - expect(out.text()).toBe('fresh\n'); - }); - - it('exits 3 without a credential', async () => { - process.env = { - ...env, - XDG_CONFIG_HOME: mkdtempSync(join(tmpdir(), 'walkeros-token-')), - }; - delete process.env.WALKEROS_TOKEN; - const err = sink(); - expect( - await authTokenCommand({ stdout: sink().stream, stderr: err.stream }), - ).toBe(EXIT.auth); - expect(err.text()).toMatch(/walkeros auth login/); - }); - - it('exits 4 when the refresh cannot reach the app', async () => { - process.env = { - ...env, - WALKEROS_APP_URL: APP, - XDG_CONFIG_HOME: expiredSession(), - }; - delete process.env.WALKEROS_TOKEN; - server.use(http.post(`${APP}/api/oauth/token`, () => HttpResponse.error())); - const err = sink(); - expect( - await authTokenCommand({ stdout: sink().stream, stderr: err.stream }), - ).toBe(EXIT.unreachable); - expect(err.text()).toMatch(/Could not reach/); - }); -}); diff --git a/packages/cli/src/__tests__/unit/commands/auth-whoami.test.ts b/packages/cli/src/__tests__/unit/commands/auth-whoami.test.ts index fb14dad03..47ee746aa 100644 --- a/packages/cli/src/__tests__/unit/commands/auth-whoami.test.ts +++ b/packages/cli/src/__tests__/unit/commands/auth-whoami.test.ts @@ -1,3 +1,4 @@ +import { NOT_SIGNED_IN } from '../../../commands/api/index.js'; import { whoami } from '../../../commands/auth/index.js'; describe('whoami', () => { @@ -24,18 +25,13 @@ describe('whoami', () => { auth: new Headers(init?.headers).get('Authorization'), }; return new Response( - JSON.stringify({ - userId: 'usr_1', - email: 'test@example.com', - projectId: null, - }), + JSON.stringify({ userId: 'usr_1', email: 'test@example.com' }), { status: 200 }, ); }; await expect(whoami()).resolves.toEqual({ userId: 'usr_1', email: 'test@example.com', - projectId: null, }); expect(seen).toEqual({ url: 'https://app.example.test/api/auth/whoami', @@ -53,4 +49,9 @@ describe('whoami', () => { ); await expect(whoami()).rejects.toThrow('Token expired'); }); + + it('refuses without WALKEROS_TOKEN, with the line the api command prints', async () => { + delete process.env.WALKEROS_TOKEN; + await expect(whoami()).rejects.toThrow(NOT_SIGNED_IN); + }); }); diff --git a/packages/cli/src/__tests__/unit/commands/logout.test.ts b/packages/cli/src/__tests__/unit/commands/logout.test.ts deleted file mode 100644 index 47631340f..000000000 --- a/packages/cli/src/__tests__/unit/commands/logout.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { logout } from '../../../commands/logout/index.js'; -import { revokeRefreshToken } from '../../../core/oauth-client.js'; -import { readConfig, writeConfig } from '../../../lib/config-file.js'; - -jest.mock('../../../core/oauth-client.js', () => ({ - revokeRefreshToken: jest.fn(async () => undefined), -})); - -const mockRevoke = jest.mocked(revokeRefreshToken); - -describe('logout', () => { - let dir: string; - const originalEnv = process.env; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'walkeros-logout-')); - process.env = { ...originalEnv }; - process.env.XDG_CONFIG_HOME = dir; - delete process.env.WALKEROS_APP_URL; - mockRevoke.mockImplementation(async () => undefined); - }); - - afterEach(() => { - process.env = originalEnv; - rmSync(dir, { recursive: true, force: true }); - jest.clearAllMocks(); - }); - - it('revokes the stored refresh token, then deletes the config', async () => { - writeConfig({ accessToken: 'at_1', refreshToken: 'rt_1' }); - - await expect(logout()).resolves.toEqual({ - deleted: true, - superseded: false, - }); - expect(mockRevoke).toHaveBeenCalledWith('https://app.walkeros.io', 'rt_1'); - expect(readConfig()).toBeNull(); - }); - - it('reports nothing to delete when no config exists', async () => { - await expect(logout()).resolves.toEqual({ - deleted: false, - superseded: false, - }); - expect(mockRevoke).not.toHaveBeenCalled(); - }); - - it('keeps a session that was stored while the revocation was in flight', async () => { - writeConfig({ accessToken: 'at_1', refreshToken: 'rt_1' }); - // A login finishing inside the revocation round trip. Deleting the file - // afterwards would take a session this logout never saw. - mockRevoke.mockImplementation(async () => { - writeConfig({ accessToken: 'at_2', refreshToken: 'rt_2' }); - }); - - await expect(logout()).resolves.toEqual({ - deleted: false, - superseded: true, - }); - expect(readConfig()?.refreshToken).toBe('rt_2'); - }); - - it('deletes when the config came back unchanged', async () => { - writeConfig({ accessToken: 'at_1', refreshToken: 'rt_1' }); - // A write that touches something other than the session is not a login. - mockRevoke.mockImplementation(async () => { - writeConfig({ defaultProjectId: 'proj_1' }); - }); - - await expect(logout()).resolves.toEqual({ - deleted: true, - superseded: false, - }); - expect(readConfig()).toBeNull(); - }); -}); diff --git a/packages/cli/src/__tests__/unit/core/auth.test.ts b/packages/cli/src/__tests__/unit/core/auth.test.ts index 13e41e785..ad756aa68 100644 --- a/packages/cli/src/__tests__/unit/core/auth.test.ts +++ b/packages/cli/src/__tests__/unit/core/auth.test.ts @@ -1,343 +1,44 @@ -import { mkdtempSync, rmSync } from 'fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { - resolveAccessToken, - getAuthHeaders, - credentialSource, - resetLegacyTokenNotice, -} from '../../../core/auth.js'; -import { readConfig, writeConfig } from '../../../lib/config-file.js'; -import { withConfigLock } from '../../../lib/config-lock.js'; +import { resolveAccessToken } from '../../../core/auth.js'; -const APP_URL = 'https://app.example.test'; -const NOW = Date.parse('2026-09-07T12:00:00.000Z'); - -function iso(ms: number): string { - return new Date(ms).toISOString(); -} - -function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * A fetch double that fails the test if it is called at all, while recording - * the attempt so the assertion names what happened rather than the throw. - */ -function forbiddenFetch() { - const calls: string[] = []; - const fetchFn: typeof fetch = async (input) => { - calls.push(String(input)); - throw new Error(`unexpected network call to ${String(input)}`); - }; - return { fetchFn, calls }; -} - -function tokenResponse(body: Record, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }); -} - -describe('core/auth', () => { +describe('resolveAccessToken', () => { let dir: string; const originalEnv = process.env; + /** Writes raw JSON where the CLI keeps its config, as an older CLI left it. */ + function writeLegacyConfig(content: Record): void { + mkdirSync(join(dir, 'walkeros'), { recursive: true }); + writeFileSync( + join(dir, 'walkeros', 'config.json'), + JSON.stringify(content), + ); + } + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'walkeros-auth-')); - process.env = { ...originalEnv }; - process.env.XDG_CONFIG_HOME = dir; + process.env = { ...originalEnv, XDG_CONFIG_HOME: dir }; delete process.env.WALKEROS_TOKEN; - delete process.env.WALKEROS_DEPLOY_TOKEN; - delete process.env.WALKEROS_PROJECT_ID; - process.env.WALKEROS_APP_URL = APP_URL; - resetLegacyTokenNotice(); }); afterEach(() => { process.env = originalEnv; rmSync(dir, { recursive: true, force: true }); - jest.restoreAllMocks(); - }); - - describe('resolveAccessToken', () => { - it('returns null when nothing is configured', async () => { - await expect(resolveAccessToken()).resolves.toBeNull(); - }); - - it('prefers WALKEROS_TOKEN over a stored session', async () => { - writeConfig({ - accessToken: 'at_config', - accessTokenExpiresAt: iso(NOW + 3600_000), - refreshToken: 'rt_1', - }); - process.env.WALKEROS_TOKEN = 'env-token'; - - await expect(resolveAccessToken({ now: () => NOW })).resolves.toBe( - 'env-token', - ); - }); - - it('returns the stored session when WALKEROS_TOKEN is absent', async () => { - // The control for the precedence test above: proves "env wins" is about - // priority, not about the stored session being unreadable. - writeConfig({ - accessToken: 'at_config', - accessTokenExpiresAt: iso(NOW + 3600_000), - refreshToken: 'rt_1', - }); - - await expect(resolveAccessToken({ now: () => NOW })).resolves.toBe( - 'at_config', - ); - }); - - it('honors a legacy static token', async () => { - writeConfig({ token: 'legacy-token' }); - jest.spyOn(process.stderr, 'write').mockReturnValue(true); - - await expect(resolveAccessToken()).resolves.toBe('legacy-token'); - }); - - it('logs the legacy deprecation notice exactly once per process', async () => { - writeConfig({ token: 'legacy-token' }); - const write = jest.spyOn(process.stderr, 'write').mockReturnValue(true); - - await resolveAccessToken(); - await resolveAccessToken(); - - const notices = write.mock.calls.filter((call) => - String(call[0]).includes('walkeros auth login'), - ); - expect(notices).toHaveLength(1); - expect(String(notices[0]?.[0])).toContain('walkeros auth login'); - }); - - it('returns a fresh access token without any network call', async () => { - writeConfig({ - accessToken: 'at_fresh', - accessTokenExpiresAt: iso(NOW + 3600_000), - refreshToken: 'rt_1', - }); - const { fetchFn, calls } = forbiddenFetch(); - - await expect( - resolveAccessToken({ fetch: fetchFn, now: () => NOW }), - ).resolves.toBe('at_fresh'); - expect(calls).toEqual([]); - }); - - it('refreshes an access token inside the sixty second window', async () => { - // 30 s of life left is inside the 60 s skew window, so it counts as stale - // even though it has not formally expired. - writeConfig({ - accessToken: 'at_stale', - accessTokenExpiresAt: iso(NOW + 30_000), - refreshToken: 'rt_old', - }); - let hits = 0; - const fetchFn: typeof fetch = async () => { - hits += 1; - return tokenResponse({ - access_token: 'at_rotated', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: 'rt_rotated', - }); - }; - - await expect( - resolveAccessToken({ fetch: fetchFn, now: () => NOW }), - ).resolves.toBe('at_rotated'); - - expect(hits).toBe(1); - const stored = readConfig(); - expect(stored?.accessToken).toBe('at_rotated'); - expect(stored?.refreshToken).toBe('rt_rotated'); - expect(Date.parse(stored?.accessTokenExpiresAt ?? '')).toBeGreaterThan( - NOW + 3000_000, - ); - }); - - it('keeps the existing refresh token when the server rotates none', async () => { - writeConfig({ - accessToken: 'at_stale', - accessTokenExpiresAt: iso(NOW - 1000), - refreshToken: 'rt_keep', - }); - const fetchFn: typeof fetch = async () => - tokenResponse({ - access_token: 'at_rotated', - token_type: 'Bearer', - expires_in: 3600, - }); - - await resolveAccessToken({ fetch: fetchFn, now: () => NOW }); - - expect(readConfig()?.refreshToken).toBe('rt_keep'); - }); - - it('reuses a session another process refreshed while we waited for the lock, without a network call', async () => { - writeConfig({ - accessToken: 'at_stale', - accessTokenExpiresAt: iso(NOW + 10_000), - refreshToken: 'rt_old', - }); - const { fetchFn, calls } = forbiddenFetch(); - - let release!: () => void; - const held = new Promise((resolve) => { - release = resolve; - }); - let acquired!: () => void; - const isHeld = new Promise((resolve) => { - acquired = resolve; - }); - - // Stand in for a second walkerOS process that holds the lock and - // refreshes the session while this one waits behind it. - const holder = withConfigLock(async () => { - acquired(); - await held; - writeConfig({ - accessToken: 'at_by_other_process', - accessTokenExpiresAt: iso(NOW + 3600_000), - refreshToken: 'rt_by_other_process', - }); - }); - await isHeld; - - const pending = resolveAccessToken({ fetch: fetchFn, now: () => NOW }); - await wait(150); - release(); - await holder; - - await expect(pending).resolves.toBe('at_by_other_process'); - expect(calls).toEqual([]); - }); - - it('clears the stored session and returns null when the refresh token is rejected', async () => { - writeConfig({ - email: 'user@example.test', - accessToken: 'at_stale', - accessTokenExpiresAt: iso(NOW - 1000), - refreshToken: 'rt_dead', - defaultProjectId: 'proj_keep', - }); - const fetchFn: typeof fetch = async () => - tokenResponse( - { error: 'invalid_grant', error_description: 'dead' }, - 400, - ); - - await expect( - resolveAccessToken({ fetch: fetchFn, now: () => NOW }), - ).resolves.toBeNull(); - - const stored = readConfig(); - expect(stored?.accessToken).toBeUndefined(); - expect(stored?.refreshToken).toBeUndefined(); - expect(stored?.email).toBeUndefined(); - expect(stored?.defaultProjectId).toBe('proj_keep'); - }); - - it('keeps the stored session when the refresh fails for a transient reason', async () => { - // The control for the clearing test: a network blip must not log the - // person out, only a server verdict on the refresh token itself may. - writeConfig({ - accessToken: 'at_stale', - accessTokenExpiresAt: iso(NOW - 1000), - refreshToken: 'rt_alive', - }); - const fetchFn: typeof fetch = async () => { - throw new Error('network unreachable'); - }; - - await expect( - resolveAccessToken({ fetch: fetchFn, now: () => NOW }), - ).rejects.toThrow('network unreachable'); - - expect(readConfig()?.refreshToken).toBe('rt_alive'); - }); - - it('names the connection, not the session, when the refresh cannot be sent', async () => { - // Null is how this function says "no session", which makes callers tell - // the person to log in and send the request unauthenticated. An - // unreachable server must not be reported that way. - writeConfig({ - accessToken: 'at_stale', - accessTokenExpiresAt: iso(NOW - 1000), - refreshToken: 'rt_alive', - }); - const fetchFn: typeof fetch = async () => { - throw new Error('network unreachable'); - }; - - const failure = await resolveAccessToken({ - fetch: fetchFn, - now: () => NOW, - }).catch((error: unknown) => - error instanceof Error ? error.message : String(error), - ); - - expect(failure).toContain('Could not reach'); - expect(failure).toContain(APP_URL); - expect(failure).toContain('session was kept'); - expect(failure).not.toContain('walkeros auth login'); - }); - - it('returns null for an expired session with no refresh token', async () => { - writeConfig({ - accessToken: 'at_stale', - accessTokenExpiresAt: iso(NOW - 1000), - }); - const { fetchFn, calls } = forbiddenFetch(); - - await expect( - resolveAccessToken({ fetch: fetchFn, now: () => NOW }), - ).resolves.toBeNull(); - expect(calls).toEqual([]); - }); }); - describe('getAuthHeaders', () => { - it('returns an empty object when nothing is configured', async () => { - await expect(getAuthHeaders()).resolves.toEqual({}); - }); - - it('returns a bearer header for the resolved token', async () => { - process.env.WALKEROS_TOKEN = 'sk-walkeros-test'; - await expect(getAuthHeaders()).resolves.toEqual({ - Authorization: 'Bearer sk-walkeros-test', - }); - }); + it('returns WALKEROS_TOKEN', () => { + process.env.WALKEROS_TOKEN = 'tok'; + expect(resolveAccessToken()).toBe('tok'); }); - describe('credentialSource', () => { - it('returns null when nothing is configured', () => { - expect(credentialSource()).toBeNull(); - }); - - it('returns env for WALKEROS_TOKEN', () => { - process.env.WALKEROS_TOKEN = 'env-token'; - expect(credentialSource()).toBe('env'); - }); - - it('returns config for a stored session', () => { - writeConfig({ accessToken: 'at', accessTokenExpiresAt: iso(NOW) }); - expect(credentialSource()).toBe('config'); - }); - - it('returns config for a legacy static token', () => { - writeConfig({ token: 'legacy' }); - expect(credentialSource()).toBe('config'); - }); - - it('returns null for a config that holds no credential at all', () => { - writeConfig({ defaultProjectId: 'proj_1' }); - expect(credentialSource()).toBeNull(); + it('ignores a stored session and a legacy token in the config file', () => { + writeLegacyConfig({ + token: 'legacy', + accessToken: 'a', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + refreshToken: 'r', }); + expect(resolveAccessToken()).toBeNull(); }); }); diff --git a/packages/cli/src/__tests__/unit/core/oauth-client.test.ts b/packages/cli/src/__tests__/unit/core/oauth-client.test.ts deleted file mode 100644 index 448a4f839..000000000 --- a/packages/cli/src/__tests__/unit/core/oauth-client.test.ts +++ /dev/null @@ -1,487 +0,0 @@ -import { - CLI_CLIENT_ID, - CLI_SCOPE, - startDeviceAuthorization, - pollDeviceToken, - refreshTokens, - revokeRefreshToken, -} from '../../../core/oauth-client.js'; -import { - resetClientContext, - setClientContext, -} from '../../../core/client-context.js'; - -const APP_URL = 'https://app.example.test'; - -/** The same app, reached over plain http: a remote host, so never allowed. */ -const HTTP_URL = 'http://app.example.test'; - -/** A fetch that must never be called; every case here refuses before sending. */ -const F = (): typeof fetch => recorder(() => jsonResponse({})).fetchFn; -const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; - -/** `withConfigLock` treats a lock held longer than this as abandoned. */ -const LOCK_STALE_MS = 15_000; - -interface RecordedCall { - url: string; - method: string | undefined; - contentType: string | undefined; - form: Record; - signal: AbortSignal | null | undefined; - redirect: RequestRedirect | undefined; -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }); -} - -function oauthError(error: string, status = 400): Response { - return jsonResponse( - { error, error_description: `${error} happened` }, - status, - ); -} - -/** - * A fetch double that records the request line of every call and answers with - * the next queued response factory. Factories, not responses, because a - * `Response` body can only be read once. - */ -function recorder(...responses: Array<() => Response>) { - const calls: RecordedCall[] = []; - let index = 0; - - const fetchFn: typeof fetch = async (input, init) => { - const headers: Record = {}; - const raw = init?.headers; - if (raw instanceof Headers) Object.assign(headers, Object.fromEntries(raw)); - else if (Array.isArray(raw)) - Object.assign(headers, Object.fromEntries(raw)); - else if (raw) Object.assign(headers, raw); - - const body = typeof init?.body === 'string' ? init.body : ''; - calls.push({ - url: String(input), - method: init?.method, - contentType: headers['Content-Type'] ?? headers['content-type'], - form: Object.fromEntries(new URLSearchParams(body)), - signal: init?.signal, - redirect: init?.redirect, - }); - - const next = responses[index] ?? responses[responses.length - 1]; - index += 1; - if (!next) throw new Error('recorder has no configured response'); - return next(); - }; - - return { fetchFn, calls }; -} - -function tokenBody(overrides: Record = {}) { - return { - access_token: 'at_new', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: 'rt_new', - ...overrides, - }; -} - -describe('oauth-client', () => { - it('sends the client context on every OAuth request', async () => { - setClientContext({ type: 'cli', version: '9.9.9' }); - const seen: Headers[] = []; - const fetchFn: typeof fetch = async (_input, init) => { - seen.push(new Headers(init?.headers)); - return jsonResponse({ error: 'authorization_pending' }, 400); - }; - await pollDeviceToken(APP_URL, 'dev_1', fetchFn); - await refreshTokens(APP_URL, 'rt_1', fetchFn).catch(() => undefined); - await revokeRefreshToken(APP_URL, 'rt_1', fetchFn); - expect(seen.map((h) => h.get('X-WalkerOS-Client'))).toEqual([ - 'cli', - 'cli', - 'cli', - ]); - resetClientContext(); - }); - - it('names the upgrade an outdated client needs', async () => { - const fetchFn: typeof fetch = async () => - jsonResponse( - { - error: { - code: 'CLIENT_OUTDATED', - message: - 'This endpoint requires @walkeros/cli >= 4.10.0 (you are on 4.9.0).', - }, - }, - 426, - ); - await expect(startDeviceAuthorization(APP_URL, fetchFn)).rejects.toThrow( - '>= 4.10.0', - ); - }); - - describe('startDeviceAuthorization', () => { - it('posts a form-encoded client_id, scope and api resource', async () => { - const { fetchFn, calls } = recorder(() => - jsonResponse({ - device_code: 'dc_1', - user_code: 'ABCD-EFGH', - verification_uri: `${APP_URL}/oauth/device`, - verification_uri_complete: `${APP_URL}/oauth/device?user_code=ABCD-EFGH`, - expires_in: 600, - interval: 5, - }), - ); - - await startDeviceAuthorization(APP_URL, fetchFn); - - expect(calls).toHaveLength(1); - const call = calls[0]; - expect(call?.url).toBe(`${APP_URL}/api/oauth/device_authorization`); - expect(call?.method).toBe('POST'); - expect(call?.contentType).toBe('application/x-www-form-urlencoded'); - expect(call?.form).toEqual({ - client_id: CLI_CLIENT_ID, - scope: CLI_SCOPE, - resource: `${APP_URL}/api`, - }); - }); - - it('bounds the request with a signal, so a silent server cannot hold it', async () => { - const { fetchFn, calls } = recorder(() => - jsonResponse({ - device_code: 'dc_1', - user_code: 'ABCD-EFGH', - verification_uri: `${APP_URL}/oauth/device`, - expires_in: 600, - interval: 5, - }), - ); - - await startDeviceAuthorization(APP_URL, fetchFn); - - expect(calls[0]?.signal).toBeInstanceOf(AbortSignal); - }); - - it('maps the snake_case response onto the camelCase result', async () => { - const { fetchFn } = recorder(() => - jsonResponse({ - device_code: 'dc_1', - user_code: 'ABCD-EFGH', - verification_uri: `${APP_URL}/oauth/device`, - verification_uri_complete: `${APP_URL}/oauth/device?user_code=ABCD-EFGH`, - expires_in: 600, - interval: 5, - }), - ); - - await expect(startDeviceAuthorization(APP_URL, fetchFn)).resolves.toEqual( - { - deviceCode: 'dc_1', - userCode: 'ABCD-EFGH', - verificationUri: `${APP_URL}/oauth/device`, - verificationUriComplete: `${APP_URL}/oauth/device?user_code=ABCD-EFGH`, - expiresIn: 600, - interval: 5, - }, - ); - }); - - it('falls back to verification_uri when the server omits the complete form', async () => { - const { fetchFn } = recorder(() => - jsonResponse({ - device_code: 'dc_1', - user_code: 'ABCD-EFGH', - verification_uri: `${APP_URL}/oauth/device`, - expires_in: 600, - interval: 5, - }), - ); - - const result = await startDeviceAuthorization(APP_URL, fetchFn); - expect(result.verificationUriComplete).toBe(`${APP_URL}/oauth/device`); - }); - - it('throws on an error status', async () => { - const { fetchFn } = recorder(() => oauthError('invalid_client', 401)); - await expect(startDeviceAuthorization(APP_URL, fetchFn)).rejects.toThrow( - /invalid_client/, - ); - }); - - it('throws when the response is missing device_code', async () => { - const { fetchFn } = recorder(() => - jsonResponse({ - user_code: 'ABCD-EFGH', - verification_uri: `${APP_URL}/oauth/device`, - expires_in: 600, - interval: 5, - }), - ); - - await expect(startDeviceAuthorization(APP_URL, fetchFn)).rejects.toThrow( - /device authorization response/i, - ); - }); - }); - - describe('pollDeviceToken', () => { - it('posts the device grant with the device code and client id', async () => { - const { fetchFn, calls } = recorder(() => jsonResponse(tokenBody())); - - await pollDeviceToken(APP_URL, 'dc_1', fetchFn); - - const call = calls[0]; - expect(call?.url).toBe(`${APP_URL}/api/oauth/token`); - expect(call?.method).toBe('POST'); - expect(call?.contentType).toBe('application/x-www-form-urlencoded'); - expect(call?.form).toEqual({ - grant_type: DEVICE_GRANT, - device_code: 'dc_1', - client_id: CLI_CLIENT_ID, - }); - }); - - it('returns the token set with an absolute expiry on success', async () => { - const { fetchFn } = recorder(() => jsonResponse(tokenBody())); - const before = Date.now(); - - const result = await pollDeviceToken(APP_URL, 'dc_1', fetchFn); - - expect(result.status).toBe('ok'); - if (result.status !== 'ok') throw new Error('expected ok'); - expect(result.tokens.accessToken).toBe('at_new'); - expect(result.tokens.refreshToken).toBe('rt_new'); - const expiresAt = Date.parse(result.tokens.accessTokenExpiresAt); - expect(expiresAt).toBeGreaterThanOrEqual(before + 3600_000); - expect(expiresAt).toBeLessThanOrEqual(Date.now() + 3600_000); - }); - - it.each([ - ['authorization_pending', 'pending'], - ['slow_down', 'slow_down'], - ['access_denied', 'denied'], - ['expired_token', 'expired'], - ])('maps the %s error onto status %s', async (code, status) => { - const { fetchFn } = recorder(() => oauthError(code)); - const result = await pollDeviceToken(APP_URL, 'dc_1', fetchFn); - expect(result.status).toBe(status); - }); - - it('reports an unrecognized error code as an error rather than pending', async () => { - // The control for the mapping table: an unknown code must not silently - // fall into one of the retryable buckets and poll forever. - const { fetchFn } = recorder(() => oauthError('invalid_client', 401)); - const result = await pollDeviceToken(APP_URL, 'dc_1', fetchFn); - - expect(result.status).toBe('error'); - if (result.status !== 'error') throw new Error('expected error'); - // Both halves reach the caller: the code names the fault, the - // description is the only part that tells the person what to do. - expect(result.error).toBe('invalid_client: invalid_client happened'); - }); - - it('reports a non-JSON error body as an error', async () => { - const { fetchFn } = recorder( - () => new Response('gateway', { status: 502 }), - ); - const result = await pollDeviceToken(APP_URL, 'dc_1', fetchFn); - expect(result.status).toBe('error'); - }); - }); - - describe('refreshTokens', () => { - it('posts the refresh grant with the refresh token and client id', async () => { - const { fetchFn, calls } = recorder(() => jsonResponse(tokenBody())); - - await refreshTokens(APP_URL, 'rt_old', fetchFn); - - const call = calls[0]; - expect(call?.url).toBe(`${APP_URL}/api/oauth/token`); - expect(call?.contentType).toBe('application/x-www-form-urlencoded'); - expect(call?.form).toEqual({ - grant_type: 'refresh_token', - refresh_token: 'rt_old', - client_id: CLI_CLIENT_ID, - }); - }); - - it('bounds the request with a timeout below the config lock stale window', async () => { - // A refresh runs while holding the config lock, which is treated as - // abandoned after 15 s. A request allowed to outlive that would have its - // own lock broken out from under it. - // - // The deadline itself is asserted, not merely that some signal is - // attached: a signal built from a 60 s timeout is an AbortSignal too, so - // the shape alone would pass for a value that outlives the lock. - const timeout = jest.spyOn(AbortSignal, 'timeout'); - const { fetchFn, calls } = recorder(() => jsonResponse(tokenBody())); - - await refreshTokens(APP_URL, 'rt_old', fetchFn); - - expect(timeout).toHaveBeenCalledTimes(1); - const [ms] = timeout.mock.calls[0] ?? []; - expect(ms).toBe(10_000); - expect(ms).toBeLessThan(LOCK_STALE_MS); - expect(calls[0]?.signal).toBeInstanceOf(AbortSignal); - - timeout.mockRestore(); - }); - - it('returns the rotated token set', async () => { - const { fetchFn } = recorder(() => jsonResponse(tokenBody())); - const result = await refreshTokens(APP_URL, 'rt_old', fetchFn); - - expect(result).not.toBeNull(); - expect(result?.accessToken).toBe('at_new'); - expect(result?.refreshToken).toBe('rt_new'); - }); - - it('reports a null refresh token when the server rotates none', async () => { - const { fetchFn } = recorder(() => - jsonResponse(tokenBody({ refresh_token: undefined })), - ); - const result = await refreshTokens(APP_URL, 'rt_old', fetchFn); - expect(result?.refreshToken).toBeNull(); - }); - - it('returns null on invalid_grant', async () => { - const { fetchFn } = recorder(() => oauthError('invalid_grant')); - await expect( - refreshTokens(APP_URL, 'rt_old', fetchFn), - ).resolves.toBeNull(); - }); - - it('throws on an error that is not invalid_grant', async () => { - // The control for the null result: null must mean "this refresh token is - // dead, re-login", not "any refresh failure". A transient server fault - // must stay distinguishable so it does not delete the stored session. - const { fetchFn } = recorder(() => oauthError('server_error', 500)); - await expect(refreshTokens(APP_URL, 'rt_old', fetchFn)).rejects.toThrow( - /server_error/, - ); - }); - - it('throws when the transport fails', async () => { - const fetchFn: typeof fetch = async () => { - throw new Error('network unreachable'); - }; - await expect(refreshTokens(APP_URL, 'rt_old', fetchFn)).rejects.toThrow( - 'network unreachable', - ); - }); - }); - - describe('revokeRefreshToken', () => { - it('posts the token with a refresh_token hint and the client id', async () => { - const { fetchFn, calls } = recorder( - () => new Response(null, { status: 200 }), - ); - - await revokeRefreshToken(APP_URL, 'rt_old', fetchFn); - - const call = calls[0]; - expect(call?.url).toBe(`${APP_URL}/api/oauth/revoke`); - expect(call?.method).toBe('POST'); - expect(call?.contentType).toBe('application/x-www-form-urlencoded'); - expect(call?.form).toEqual({ - token: 'rt_old', - token_type_hint: 'refresh_token', - client_id: CLI_CLIENT_ID, - }); - }); - - it('bounds the request so a stalled revoke cannot delay the local clear', async () => { - // Logout clears the machine whether or not the server answers, so the - // only thing a stalled revoke can cost is the wait before that clear. - const timeout = jest.spyOn(AbortSignal, 'timeout'); - const { fetchFn, calls } = recorder( - () => new Response(null, { status: 200 }), - ); - - await revokeRefreshToken(APP_URL, 'rt_old', fetchFn); - - // Pinned to the revoke call specifically: one request went out, to the - // revoke endpoint, and one deadline was set for it. - expect(calls).toHaveLength(1); - expect(calls[0]?.url).toBe(`${APP_URL}/api/oauth/revoke`); - expect(timeout).toHaveBeenCalledTimes(1); - expect(timeout.mock.calls[0]?.[0]).toBe(5_000); - expect(calls[0]?.signal).toBeInstanceOf(AbortSignal); - - timeout.mockRestore(); - }); - - it('resolves despite an error status', async () => { - const { fetchFn } = recorder(() => oauthError('invalid_client', 401)); - await expect( - revokeRefreshToken(APP_URL, 'rt_old', fetchFn), - ).resolves.toBeUndefined(); - }); - - it('resolves despite a transport failure', async () => { - const fetchFn: typeof fetch = async () => { - throw new Error('network unreachable'); - }; - await expect( - revokeRefreshToken(APP_URL, 'rt_old', fetchFn), - ).resolves.toBeUndefined(); - }); - }); - describe('credential transport', () => { - const deviceOk = () => - jsonResponse({ - device_code: 'dc_1', - user_code: 'ABCD-EFGH', - verification_uri: `${APP_URL}/oauth/device`, - expires_in: 600, - interval: 5, - }); - - it('treats a redirect as an error rather than a hop', async () => { - const { fetchFn, calls } = recorder(deviceOk); - await startDeviceAuthorization(APP_URL, fetchFn); - expect(calls[0]?.redirect).toBe('error'); - }); - - it.each([ - ['a device authorization', () => startDeviceAuthorization(HTTP_URL, F())], - ['a token poll', () => pollDeviceToken(HTTP_URL, 'dc_1', F())], - ['a refresh', () => refreshTokens(HTTP_URL, 'rt_1', F())], - ])('refuses %s over plain http off the local machine', async (_l, run) => { - await expect(run()).rejects.toThrow(/plain http/); - }); - - it('sends nothing at all when it refuses', async () => { - const { fetchFn, calls } = recorder(deviceOk); - await expect( - startDeviceAuthorization(HTTP_URL, fetchFn), - ).rejects.toThrow(); - expect(calls).toHaveLength(0); - }); - - it('swallows the refusal on revocation, which must never block a logout', async () => { - const { fetchFn, calls } = recorder(() => jsonResponse({})); - await expect( - revokeRefreshToken(HTTP_URL, 'rt_1', fetchFn), - ).resolves.toBeUndefined(); - expect(calls).toHaveLength(0); - }); - - it('allows plain http to loopback, the documented local flow', async () => { - const { fetchFn, calls } = recorder(deviceOk); - await startDeviceAuthorization('http://localhost:3000', fetchFn); - expect(calls[0]?.url).toBe( - 'http://localhost:3000/api/oauth/device_authorization', - ); - }); - }); -}); diff --git a/packages/cli/src/__tests__/unit/lib/config-file.test.ts b/packages/cli/src/__tests__/unit/lib/config-file.test.ts index c16020018..391ed648b 100644 --- a/packages/cli/src/__tests__/unit/lib/config-file.test.ts +++ b/packages/cli/src/__tests__/unit/lib/config-file.test.ts @@ -12,25 +12,11 @@ import { readConfig, writeConfig, writeTelemetryOnlyConfig, - hasStoredCredential, getConfigPath, - type WalkerOSConfig, } from '../../../lib/config-file.js'; const testDir = join(tmpdir(), `config-file-test-${Date.now()}`); -describe('hasStoredCredential', () => { - const cases: Array<[string, WalkerOSConfig | null, boolean]> = [ - ['a legacy static token', { token: 'sk' }, true], - ['an OAuth access token', { accessToken: 'at' }, true], - ['a telemetry-only config', { telemetryEnabled: true }, false], - ['no config', null, false], - ]; - it.each(cases)('%s', (_label, config, expected) => { - expect(hasStoredCredential(config)).toBe(expected); - }); -}); - describe('WalkerOSConfig telemetry fields', () => { const originalEnv = process.env; @@ -46,8 +32,6 @@ describe('WalkerOSConfig telemetry fields', () => { it('persists installationId and telemetryEnabled through write/read', () => { writeConfig({ - token: 't', - email: 'e', appUrl: 'u', installationId: 'abc-123', telemetryEnabled: false, @@ -57,11 +41,9 @@ describe('WalkerOSConfig telemetry fields', () => { expect(cfg?.telemetryEnabled).toBe(false); }); - it('allows telemetry-only config without token/email (anonymous install)', () => { + it('allows a telemetry-only config (anonymous install)', () => { writeTelemetryOnlyConfig({ installationId: 'xyz-789' }); - const cfg = readConfig(); - expect(cfg?.installationId).toBe('xyz-789'); - expect(cfg?.token).toBeUndefined(); + expect(readConfig()).toEqual({ installationId: 'xyz-789' }); }); }); @@ -87,35 +69,35 @@ describe('config-file permissions', () => { } it('writes the config readable only by its owner', () => { - writeConfig({ token: 'sk-test-123' }); + writeConfig({ appUrl: 'https://a.example.test' }); expect(modeOf(getConfigPath())).toBe(0o600); }); it('creates the config directory traversable only by its owner', () => { - writeConfig({ token: 'sk-test-123' }); + writeConfig({ appUrl: 'https://a.example.test' }); expect(modeOf(join(modeDir, 'walkeros'))).toBe(0o700); }); it('writes through a temp path of its own, leaving another writer\u2019s alone', () => { - // Only the token refresh takes the config lock, so two ordinary writers - // (a login and a `telemetry enable`, say) can be in here at once. On one - // shared name they would write over each other's temp file and rename it - // twice, and the slower one would fail outright when the faster renamed - // the file out from under its `chmod`. + // Nothing locks the config, so two writers (two `telemetry` commands, + // say) can be in here at once. On one shared name they would write over + // each other's temp file and rename it twice, and the slower one would + // fail outright when the faster renamed the file out from under its + // `chmod`. mkdirSync(join(modeDir, 'walkeros'), { recursive: true }); const otherWriterTemp = `${getConfigPath()}.tmp`; writeFileSync(otherWriterTemp, 'another writer'); - writeConfig({ token: 'sk-test-123' }); + writeConfig({ appUrl: 'https://a.example.test' }); expect(readFileSync(otherWriterTemp, 'utf-8')).toBe('another writer'); expect(modeOf(getConfigPath())).toBe(0o600); - expect(readConfig()?.token).toBe('sk-test-123'); + expect(readConfig()?.appUrl).toBe('https://a.example.test'); }); it('leaves no temp file behind', () => { - writeConfig({ token: 'sk-test-123' }); - writeConfig({ token: 'sk-test-456' }); + writeConfig({ appUrl: 'https://a.example.test' }); + writeConfig({ appUrl: 'https://b.example.test' }); expect(readdirSync(join(modeDir, 'walkeros'))).toEqual(['config.json']); }); diff --git a/packages/cli/src/__tests__/unit/lib/config-lock.test.ts b/packages/cli/src/__tests__/unit/lib/config-lock.test.ts deleted file mode 100644 index 0febdf040..000000000 --- a/packages/cli/src/__tests__/unit/lib/config-lock.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, - utimesSync, - existsSync, -} from 'fs'; -import { tmpdir } from 'os'; -import { dirname, join } from 'path'; -import { withConfigLock, getConfigLockPath } from '../../../lib/config-lock.js'; - -/** Let pending timers and microtasks run for `ms`. */ -function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -describe('withConfigLock', () => { - let dir: string; - const originalXdg = process.env.XDG_CONFIG_HOME; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'walkeros-config-lock-')); - process.env.XDG_CONFIG_HOME = dir; - }); - - afterEach(() => { - if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME; - else process.env.XDG_CONFIG_HOME = originalXdg; - rmSync(dir, { recursive: true, force: true }); - }); - - it('serializes two concurrent holders instead of interleaving them', async () => { - const order: string[] = []; - let releaseFirst!: () => void; - const firstHeld = new Promise((resolve) => { - releaseFirst = resolve; - }); - let firstEntered!: () => void; - const firstIsInside = new Promise((resolve) => { - firstEntered = resolve; - }); - - const first = withConfigLock(async () => { - order.push('first:enter'); - firstEntered(); - await firstHeld; - order.push('first:exit'); - }); - - await firstIsInside; - - const second = withConfigLock(async () => { - order.push('second:enter'); - order.push('second:exit'); - }); - - // Long enough for several 100 ms acquire retries. Without a real lock the - // second callback would have run to completion by now. - await wait(300); - expect(order).toEqual(['first:enter']); - - releaseFirst(); - await Promise.all([first, second]); - - expect(order).toEqual([ - 'first:enter', - 'first:exit', - 'second:enter', - 'second:exit', - ]); - }); - - it('breaks a lock whose file is older than the stale window', async () => { - const lockPath = getConfigLockPath(); - mkdirSync(dirname(lockPath), { recursive: true }); - writeFileSync(lockPath, ''); - // 20 s of age, past the 15 s stale window. - const past = Date.now() / 1000 - 20; - utimesSync(lockPath, past, past); - - await expect(withConfigLock(async () => 'ran')).resolves.toBe('ran'); - }); - - it('waits for a lock that is not yet stale rather than breaking it', async () => { - // The control for the stale-breaking test above: proves the lock is - // honored when it is fresh, so breaking it there is attributable to its - // age and not to the lock being ignored. - const lockPath = getConfigLockPath(); - mkdirSync(dirname(lockPath), { recursive: true }); - writeFileSync(lockPath, ''); - - let ran = false; - const pending = withConfigLock(async () => { - ran = true; - }); - - await wait(300); - expect(ran).toBe(false); - - rmSync(lockPath); - await pending; - expect(ran).toBe(true); - }); - - it('removes the lock file after the callback resolves', async () => { - await withConfigLock(async () => undefined); - expect(existsSync(getConfigLockPath())).toBe(false); - }); - - it('removes the lock file when the callback throws', async () => { - await expect( - withConfigLock(async () => { - throw new Error('callback failed'); - }), - ).rejects.toThrow('callback failed'); - - expect(existsSync(getConfigLockPath())).toBe(false); - }); - - it('returns the callback result', async () => { - await expect(withConfigLock(async () => 42)).resolves.toBe(42); - }); -}); diff --git a/packages/cli/src/__tests__/unit/login/complete-device-login.test.ts b/packages/cli/src/__tests__/unit/login/complete-device-login.test.ts deleted file mode 100644 index cbbb13882..000000000 --- a/packages/cli/src/__tests__/unit/login/complete-device-login.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { mkdtempSync, rmSync, existsSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { completeDeviceLogin } from '../../../commands/login/index.js'; -import { readConfig, getConfigPath } from '../../../lib/config-file.js'; - -const APP_URL = 'https://app.example.test'; -const DEVICE_CODE = 'dc_resume'; - -/** Long enough for many 1 ms polls, short enough to keep the suite fast. */ -const WINDOW_MS = 200; - -interface RouteState { - /** Queued answers for the token endpoint, consumed in order. */ - tokenAnswers: Array<() => Response>; - whoamiAnswer: () => Response; - tokenCalls: number; -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }); -} - -function oauthError(error: string, status = 400): () => Response { - return () => jsonResponse({ error, error_description: error }, status); -} - -function tokenOk(): () => Response { - return () => - jsonResponse({ - access_token: 'at_1', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: 'rt_1', - }); -} - -function whoamiOk(email = 'user@example.test'): () => Response { - return () => jsonResponse({ userId: 'user_1', email, projectId: null }); -} - -function router(state: RouteState): typeof fetch { - return async (input) => { - const url = String(input); - if (url.endsWith('/api/oauth/token')) { - const answer = - state.tokenAnswers[state.tokenCalls] ?? - state.tokenAnswers[state.tokenAnswers.length - 1]; - state.tokenCalls += 1; - if (!answer) throw new Error('no token answer configured'); - return answer(); - } - if (url.endsWith('/api/auth/whoami')) return state.whoamiAnswer(); - throw new Error(`unexpected request to ${url}`); - }; -} - -function makeState(overrides: Partial = {}): RouteState { - return { - tokenAnswers: [tokenOk()], - whoamiAnswer: whoamiOk(), - tokenCalls: 0, - ...overrides, - }; -} - -function run(state: RouteState) { - return completeDeviceLogin(DEVICE_CODE, { - url: APP_URL, - fetch: router(state), - intervalMs: 1, - timeoutMs: WINDOW_MS, - }); -} - -describe('completeDeviceLogin', () => { - let dir: string; - const originalEnv = process.env; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'walkeros-resume-')); - process.env = { ...originalEnv }; - process.env.XDG_CONFIG_HOME = dir; - delete process.env.WALKEROS_TOKEN; - process.env.WALKEROS_APP_URL = APP_URL; - }); - - afterEach(() => { - process.env = originalEnv; - rmSync(dir, { recursive: true, force: true }); - }); - - it('stores the session and reports ok, carrying no token material back', async () => { - const state = makeState(); - - const result = await run(state); - - // Exact equality, not a status check: the caller must never receive token - // material, because the credential file has exactly one writer. - expect(result).toEqual({ status: 'ok' }); - const stored = readConfig(); - expect(stored?.accessToken).toBe('at_1'); - expect(stored?.refreshToken).toBe('rt_1'); - expect(stored?.email).toBe('user@example.test'); - }); - - it('reports pending and writes nothing when the window closes on an outstanding approval', async () => { - const state = makeState({ - tokenAnswers: [oauthError('authorization_pending')], - }); - - const result = await run(state); - - expect(result).toEqual({ status: 'pending' }); - expect(existsSync(getConfigPath())).toBe(false); - // The window was spent polling, not waiting out one long interval. - expect(state.tokenCalls).toBeGreaterThan(1); - }); - - it('reports slow_down and widens the interval when the server asks for a wider gap', async () => { - // The control for the pending case: same window, same starting interval, - // only the server's answer differs, so both the status and the single poll - // are attributable to `slow_down` rather than to the timing. - const state = makeState({ tokenAnswers: [oauthError('slow_down')] }); - - const result = await run(state); - - expect(result).toEqual({ status: 'slow_down' }); - expect(state.tokenCalls).toBe(1); - expect(existsSync(getConfigPath())).toBe(false); - }); - - it.each([ - ['access_denied', 'denied'], - ['expired_token', 'expired'], - ])('returns %s as status %s and stops polling', async (code, status) => { - const state = makeState({ tokenAnswers: [oauthError(code)] }); - - const result = await run(state); - - expect(result).toEqual({ status }); - expect(state.tokenCalls).toBe(1); - expect(existsSync(getConfigPath())).toBe(false); - }); - - it('returns the server error on an unrecognized failure', async () => { - const state = makeState({ - tokenAnswers: [oauthError('invalid_client', 401)], - }); - - const result = await run(state); - - expect(result.status).toBe('error'); - expect(result).toHaveProperty( - 'error', - expect.stringContaining('invalid_client'), - ); - expect(state.tokenCalls).toBe(1); - expect(existsSync(getConfigPath())).toBe(false); - }); - - it('runs at the interval its caller states, without a floor of its own', async () => { - // The clamp on a server-stated interval belongs at the command's call - // site, not here. Moving it into this helper would make the whole window - // below smaller than one interval and produce zero polls, which is what - // this pins: a caller that states its own pace keeps it. - const state = makeState({ - tokenAnswers: [oauthError('authorization_pending')], - }); - - const result = await run(state); - - expect(result).toEqual({ status: 'pending' }); - expect(state.tokenCalls).toBeGreaterThan(10); - }); - - it('aborts a stalled poll at the deadline instead of waiting out the socket', async () => { - // The double never settles on its own: it settles only when the request's - // OWN signal fires. So the loop running out of attempts cannot rescue this - // case, and without a per-request bound the call would hang until jest's - // timeout rather than return. That is what makes the result attributable - // to the signal. - const seen: Array = []; - const stalling: typeof fetch = (_input, init) => - new Promise((_resolve, reject) => { - seen.push(init?.signal); - init?.signal?.addEventListener('abort', () => - reject(init.signal?.reason), - ); - }); - - const started = Date.now(); - const result = await completeDeviceLogin(DEVICE_CODE, { - url: APP_URL, - fetch: stalling, - intervalMs: 1, - timeoutMs: 100, - }); - - expect(result).toEqual({ status: 'pending' }); - expect(seen).toHaveLength(1); - expect(seen[0]).toBeInstanceOf(AbortSignal); - expect(Date.now() - started).toBeLessThan(5000); - expect(existsSync(getConfigPath())).toBe(false); - }); - - it('stops at the abort even when the clock is still short of the deadline', async () => { - // The request's timer and Date.now() are separate clocks: the timer can - // fire a millisecond before Date.now() reaches the deadline, which leaves - // room for one more interval. Pinning the clock 1 ms short of the - // deadline after the first read makes that race deterministic; the - // attempt cap only bounds the loop if the abort were ignored. - const timeoutMs = 100; - const start = 1_700_000_000_000; - const now = jest - .spyOn(Date, 'now') - .mockReturnValueOnce(start) - .mockReturnValue(start + timeoutMs - 1); - const seen: Array = []; - const stalling: typeof fetch = (_input, init) => - new Promise((_resolve, reject) => { - seen.push(init?.signal); - init?.signal?.addEventListener('abort', () => - reject(init.signal?.reason), - ); - }); - - try { - const result = await completeDeviceLogin(DEVICE_CODE, { - url: APP_URL, - fetch: stalling, - intervalMs: 1, - timeoutMs, - maxPollAttempts: 3, - }); - - expect(result).toEqual({ status: 'pending' }); - expect(seen).toHaveLength(1); - } finally { - now.mockRestore(); - } - }); - - it('lets a real transport failure through rather than reporting it as pending', async () => { - // The control for the abort case: both end the poll without a response, so - // the abort branch must key on the signal and not on "the request threw". - const failing: typeof fetch = async () => { - throw new Error('network unreachable'); - }; - - await expect( - completeDeviceLogin(DEVICE_CODE, { - url: APP_URL, - fetch: failing, - intervalMs: 1, - timeoutMs: 100, - }), - ).rejects.toThrow('network unreachable'); - }); - - it('keeps the session when the identity lookup fails, without an email', async () => { - const state = makeState({ - whoamiAnswer: () => jsonResponse({ error: 'nope' }, 500), - }); - - const result = await run(state); - - expect(result).toEqual({ status: 'ok' }); - expect(readConfig()?.accessToken).toBe('at_1'); - expect(readConfig()?.email).toBeUndefined(); - }); -}); diff --git a/packages/cli/src/__tests__/unit/login/login.test.ts b/packages/cli/src/__tests__/unit/login/login.test.ts deleted file mode 100644 index a9c408a88..000000000 --- a/packages/cli/src/__tests__/unit/login/login.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { login } from '../../../commands/login/index.js'; -import { - readConfig, - writeConfig, - getConfigPath, -} from '../../../lib/config-file.js'; - -const APP_URL = 'https://app.example.test'; - -/** Mirrors `MIN_SERVER_POLL_INTERVAL_MS` in the command under test. */ -const MIN_SERVER_POLL_INTERVAL_MS = 1000; - -const noopOpen = async () => {}; - -interface RouteState { - /** Queued answers for the token endpoint, consumed in order. */ - tokenAnswers: Array<() => Response>; - deviceAnswer: () => Response; - whoamiAnswer: () => Response; - tokenCalls: number; - /** When each token poll went out, so pacing can be asserted. */ - tokenCallTimes: number[]; -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }); -} - -function oauthError(error: string, status = 400): () => Response { - return () => jsonResponse({ error, error_description: error }, status); -} - -function deviceOk(interval = 0): () => Response { - return () => - jsonResponse({ - device_code: 'dc_1', - user_code: 'ABCD-EFGH', - verification_uri: `${APP_URL}/oauth/device`, - verification_uri_complete: `${APP_URL}/oauth/device?user_code=ABCD-EFGH`, - expires_in: 600, - interval, - }); -} - -function tokenOk(): () => Response { - return () => - jsonResponse({ - access_token: 'at_1', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: 'rt_1', - }); -} - -function whoamiOk(email = 'user@example.test'): () => Response { - return () => jsonResponse({ userId: 'user_1', email, projectId: null }); -} - -function router(state: RouteState): typeof fetch { - return async (input) => { - const url = String(input); - if (url.endsWith('/api/oauth/device_authorization')) - return state.deviceAnswer(); - if (url.endsWith('/api/oauth/token')) { - const answer = - state.tokenAnswers[state.tokenCalls] ?? - state.tokenAnswers[state.tokenAnswers.length - 1]; - state.tokenCalls += 1; - state.tokenCallTimes.push(Date.now()); - if (!answer) throw new Error('no token answer configured'); - return answer(); - } - if (url.endsWith('/api/auth/whoami')) return state.whoamiAnswer(); - throw new Error(`unexpected request to ${url}`); - }; -} - -function makeState(overrides: Partial = {}): RouteState { - return { - deviceAnswer: deviceOk(), - tokenAnswers: [tokenOk()], - whoamiAnswer: whoamiOk(), - tokenCalls: 0, - tokenCallTimes: [], - ...overrides, - }; -} - -describe('login (device authorization grant)', () => { - let dir: string; - const originalEnv = process.env; - let stderr: jest.SpyInstance; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'walkeros-login-')); - process.env = { ...originalEnv }; - process.env.XDG_CONFIG_HOME = dir; - delete process.env.WALKEROS_TOKEN; - process.env.WALKEROS_APP_URL = APP_URL; - stderr = jest.spyOn(process.stderr, 'write').mockReturnValue(true); - }); - - afterEach(() => { - process.env = originalEnv; - rmSync(dir, { recursive: true, force: true }); - jest.restoreAllMocks(); - }); - - function stderrText(): string { - return stderr.mock.calls.map((call) => String(call[0])).join(''); - } - - it('polls through pending and slow_down, then stores the session', async () => { - const state = makeState({ - tokenAnswers: [ - oauthError('authorization_pending'), - oauthError('slow_down'), - tokenOk(), - ], - }); - - const result = await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(state), - openUrl: noopOpen, - }); - - expect(result).toEqual({ - success: true, - email: 'user@example.test', - configPath: getConfigPath(), - }); - expect(state.tokenCalls).toBe(3); - }); - - it('paces polling at the clamped minimum when a server states an interval of zero', async () => { - // `WALKEROS_APP_URL` is user-settable, so this number comes from whichever - // host the person is pointed at. Unclamped, a stated zero would let a - // remote value turn this loop into an unthrottled POST flood for the whole - // life of the device code. No `pollIntervalMs` override here: the pacing - // under test is exactly the one a real login would get. - const state = makeState({ - tokenAnswers: [oauthError('authorization_pending'), tokenOk()], - }); - - const result = await login({ - url: APP_URL, - fetch: router(state), - openUrl: noopOpen, - maxPollAttempts: 2, - }); - - expect(result.success).toBe(true); - // Two polls really happened, so the elapsed time is the loop waiting and - // not one slow request: the fetch double answers instantly. - expect(state.tokenCalls).toBe(2); - const [first, second] = state.tokenCallTimes; - expect(first).toBeDefined(); - expect(second).toBeDefined(); - // The gap between them is the clamp doing its work. A hot loop puts these - // microseconds apart. - expect(second! - first!).toBeGreaterThanOrEqual( - MIN_SERVER_POLL_INTERVAL_MS - 50, - ); - }); - - it('keeps a server interval that is already above the minimum', async () => { - // The control for the clamp: it is a floor, not a replacement. Flattening - // it to the minimum would pass the zero case above and fail here, so the - // pair is what pins `Math.max` semantics rather than either test alone. - const state = makeState({ - deviceAnswer: deviceOk(2), - tokenAnswers: [oauthError('authorization_pending')], - }); - - const started = Date.now(); - await login({ - url: APP_URL, - fetch: router(state), - openUrl: noopOpen, - maxPollAttempts: 1, - }); - - expect(state.tokenCalls).toBe(1); - expect(Date.now() - started).toBeGreaterThanOrEqual(1900); - }); - - it('writes the three session fields and no legacy token', async () => { - writeConfig({ token: 'legacy-static-token' }); - - await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(makeState()), - openUrl: noopOpen, - }); - - const stored = readConfig(); - expect(stored?.accessToken).toBe('at_1'); - expect(stored?.refreshToken).toBe('rt_1'); - expect(Date.parse(stored?.accessTokenExpiresAt ?? '')).toBeGreaterThan( - Date.now(), - ); - expect(stored?.token).toBeUndefined(); - - // The on-disk file must not carry the key at all, not merely an undefined - // value, or a later read would resurrect the legacy path. - const raw: unknown = JSON.parse(readFileSync(getConfigPath(), 'utf-8')); - expect(raw).not.toHaveProperty('token'); - }); - - it('drops a previous refresh token when the server rotates none', async () => { - writeConfig({ - accessToken: 'at_old', - accessTokenExpiresAt: new Date(Date.now() + 1000).toISOString(), - refreshToken: 'rt_previous_session', - }); - const state = makeState({ - tokenAnswers: [ - () => - jsonResponse({ - access_token: 'at_1', - token_type: 'Bearer', - expires_in: 3600, - }), - ], - }); - - await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(state), - openUrl: noopOpen, - }); - - expect(readConfig()?.refreshToken).toBeUndefined(); - }); - - it('drops a previous email when the identity lookup fails', async () => { - // `walkeros feedback` sends the stored address as the reporter's identity, - // so a leftover one attributes this session to the previous account. - writeConfig({ email: 'previous@example.test' }); - const state = makeState({ - whoamiAnswer: () => jsonResponse({ error: 'nope' }, 500), - }); - - await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(state), - openUrl: noopOpen, - }); - - expect(readConfig()?.email).toBeUndefined(); - }); - - it('preserves unrelated config that login does not own', async () => { - writeConfig({ - defaultProjectId: 'proj_keep', - installationId: 'install_keep', - telemetryEnabled: true, - anonymousFeedback: false, - }); - - await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(makeState()), - openUrl: noopOpen, - }); - - const stored = readConfig(); - expect(stored?.defaultProjectId).toBe('proj_keep'); - expect(stored?.installationId).toBe('install_keep'); - expect(stored?.telemetryEnabled).toBe(true); - expect(stored?.anonymousFeedback).toBe(false); - expect(stored?.accessToken).toBe('at_1'); - }); - - it('reports a timeout when the device code expires', async () => { - const state = makeState({ tokenAnswers: [oauthError('expired_token')] }); - - await expect( - login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(state), - openUrl: noopOpen, - }), - ).resolves.toEqual({ - success: false, - error: 'Authorization timed out. Please try again.', - }); - expect(existsSync(getConfigPath())).toBe(false); - }); - - it('reports a denial distinctly from a timeout', async () => { - // The control for the expiry test: two different terminal outcomes must - // not collapse into the same message. - const state = makeState({ tokenAnswers: [oauthError('access_denied')] }); - - const result = await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(state), - openUrl: noopOpen, - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Authorization was denied.'); - }); - - it('stops polling and reports the error on an unrecognized failure', async () => { - const state = makeState({ - tokenAnswers: [oauthError('invalid_client', 401)], - }); - - const result = await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(state), - openUrl: noopOpen, - }); - - expect(result.success).toBe(false); - expect(result.error).toContain('invalid_client'); - expect(state.tokenCalls).toBe(1); - }); - - it('gives up after maxPollAttempts', async () => { - const state = makeState({ - tokenAnswers: [oauthError('authorization_pending')], - }); - - const result = await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(state), - openUrl: noopOpen, - maxPollAttempts: 3, - }); - - expect(result).toEqual({ - success: false, - error: 'Authorization timed out. Please try again.', - }); - expect(state.tokenCalls).toBe(3); - }); - - it('returns an error when the device authorization request fails', async () => { - const state = makeState({ - deviceAnswer: oauthError('invalid_client', 401), - }); - - await expect( - login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(state), - openUrl: noopOpen, - }), - ).resolves.toEqual({ - success: false, - error: 'Failed to request device code', - }); - }); - - it('shows the user code and opens the complete verification URL', async () => { - const opened: string[] = []; - - await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(makeState()), - openUrl: async (url) => { - opened.push(url); - }, - }); - - expect(opened).toEqual([`${APP_URL}/oauth/device?user_code=ABCD-EFGH`]); - const text = stderrText(); - expect(text).toContain('ABCD-EFGH'); - expect(text).toContain(`${APP_URL}/oauth/device?user_code=ABCD-EFGH`); - }); - - it('falls back to the plain verification URL when no complete form is given', async () => { - const state = makeState({ - deviceAnswer: () => - jsonResponse({ - device_code: 'dc_1', - user_code: 'ABCD-EFGH', - verification_uri: `${APP_URL}/oauth/device`, - expires_in: 600, - interval: 0, - }), - }); - const opened: string[] = []; - - await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(state), - openUrl: async (url) => { - opened.push(url); - }, - }); - - expect(opened).toEqual([`${APP_URL}/oauth/device`]); - }); - - it('still succeeds when the identity lookup fails, without an email', async () => { - const state = makeState({ - whoamiAnswer: () => jsonResponse({ error: 'nope' }, 500), - }); - - const result = await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(state), - openUrl: noopOpen, - }); - - expect(result.success).toBe(true); - expect(result.email).toBeUndefined(); - expect(readConfig()?.accessToken).toBe('at_1'); - }); - - it('keeps a browser that will not open from failing the login', async () => { - const result = await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: router(makeState()), - openUrl: async () => { - throw new Error('no display'); - }, - }); - - expect(result.success).toBe(true); - expect(stderrText()).toContain('Could not open browser'); - }); - it('bounds the identity lookup, which runs after the session is stored', async () => { - const route = router(makeState()); - let whoamiSignal: AbortSignal | null | undefined; - const fetchFn: typeof fetch = async (input, init) => { - if (String(input).endsWith('/api/auth/whoami')) - whoamiSignal = init?.signal; - return route(input, init); - }; - - const result = await login({ - url: APP_URL, - pollIntervalMs: 1, - fetch: fetchFn, - openUrl: noopOpen, - }); - - expect(result.success).toBe(true); - expect(whoamiSignal).toBeInstanceOf(AbortSignal); - }); - - it('refuses an app URL that would carry the session over plain http', async () => { - const state = makeState(); - - await expect( - login({ - url: 'http://app.example.test', - pollIntervalMs: 1, - fetch: router(state), - openUrl: noopOpen, - }), - ).rejects.toThrow(/plain http/); - - expect(state.tokenCalls).toBe(0); - expect(readConfig()).toBeNull(); - }); - - it('accepts a loopback app URL over plain http', async () => { - const result = await login({ - url: 'http://127.0.0.1:3000', - pollIntervalMs: 1, - fetch: router(makeState()), - openUrl: noopOpen, - }); - - expect(result.success).toBe(true); - expect(readConfig()?.appUrl).toBe('http://127.0.0.1:3000'); - }); -}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index db1c3fe27..0ef746bf0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,9 +13,7 @@ import { pushCommand } from './commands/push/index.js'; import { setupCommand } from './commands/setup/index.js'; import { validateCommand } from './commands/validate/index.js'; import { registerCacheCommand } from './commands/cache.js'; -import { loginCommand } from './commands/login/index.js'; -import { logoutCommand } from './commands/logout/index.js'; -import { authTokenCommand, whoamiCommand } from './commands/auth/index.js'; +import { whoamiCommand } from './commands/auth/index.js'; import { registerApiCommand } from './commands/api/index.js'; import { diagnosticsCommand } from './commands/diagnostics/index.js'; import { @@ -263,36 +261,6 @@ const authCmd = program .command('auth') .description('Authentication and identity'); -authCmd - .command('login') - .description('Log in to walkerOS via browser') - .option('--url ', 'custom app URL') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (options) => { - await loginCommand({ - url: options.url, - json: options.json, - verbose: options.verbose, - silent: options.silent, - }); - }); - -authCmd - .command('logout') - .description('Remove stored credentials') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (options) => { - await logoutCommand({ - json: options.json, - verbose: options.verbose, - silent: options.silent, - }); - }); - authCmd .command('whoami') .description('Show current user identity') @@ -309,13 +277,6 @@ authCmd }); }); -authCmd - .command('token') - .description('Print the current access token, for any HTTP client') - .action(async () => { - process.exitCode = await authTokenCommand(); - }); - // Any app API operation, by method and path registerApiCommand(program); @@ -356,7 +317,7 @@ registerCacheCommand(program); // Telemetry hooks - populate cmdPath/cmdStart for action-bound commands. program.hook('preAction', (_thisCmd, actionCmd) => { cmdStart = Date.now(); - // Space-joined command path: `walkeros auth login` → "auth login". + // Space-joined command path: `walkeros auth whoami` → "auth whoami". const parts: string[] = []; let cur: Command | null = actionCmd; while (cur && cur.name() !== 'walkeros') { diff --git a/packages/cli/src/commands/api/__tests__/api.test.ts b/packages/cli/src/commands/api/__tests__/api.test.ts index 589c24932..b9b1e2430 100644 --- a/packages/cli/src/commands/api/__tests__/api.test.ts +++ b/packages/cli/src/commands/api/__tests__/api.test.ts @@ -134,7 +134,9 @@ describe('walkeros api: exit codes by answer', () => { await runApi({ method: 'GET', path: '/api/x', header: [] }, io), ).toBe(EXIT.auth); expect(calls()).toBe(0); - expect(err.text()).toMatch(/walkeros auth login|WALKEROS_TOKEN/); + expect(err.text()).toBe( + 'walkeros api: no token: set WALKEROS_TOKEN to a token from Account > Automation tokens in the walkerOS app\n', + ); }); it('prints a non-JSON error body exactly as it came', async () => { @@ -256,6 +258,22 @@ describe('walkeros api: usage errors exit 2 and send nothing', () => { expect(calls()).toBe(0); expect(err.text()).toMatch(/plain http/); }); + + it.each(['Authorization: Bearer x', 'authorization: Bearer x'])( + 'a token in a header: %s', + async (header) => { + delete process.env.WALKEROS_TOKEN; + const calls = countRequests(); + const { io, err } = streams(); + expect( + await runApi({ method: 'GET', path: '/api/x', header: [header] }, io), + ).toBe(EXIT.usage); + expect(calls()).toBe(0); + expect(err.text()).toBe( + 'walkeros api: set the token with WALKEROS_TOKEN, not with an Authorization header\n', + ); + }, + ); }); describe('walkeros api: the request', () => { diff --git a/packages/cli/src/commands/api/index.ts b/packages/cli/src/commands/api/index.ts index bc886338b..656cec483 100644 --- a/packages/cli/src/commands/api/index.ts +++ b/packages/cli/src/commands/api/index.ts @@ -46,7 +46,7 @@ export interface ApiRequestOptions { /** Shown when there is no credential to send. */ export const NOT_SIGNED_IN = - 'not signed in: run walkeros auth login, or set WALKEROS_TOKEN'; + 'no token: set WALKEROS_TOKEN to a token from Account > Automation tokens in the walkerOS app'; const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); /** Error bodies are read back for the stderr line; a bigger one is not parsed. */ @@ -109,7 +109,12 @@ function parseHeaders(values: readonly string[]): Record { for (const value of values) { const colon = value.indexOf(':'); if (colon <= 0) throw new UsageError(`a header is 'Name: value': ${value}`); - headers[value.slice(0, colon).trim()] = value.slice(colon + 1).trim(); + const name = value.slice(0, colon).trim(); + if (name.toLowerCase() === 'authorization') + throw new UsageError( + 'set the token with WALKEROS_TOKEN, not with an Authorization header', + ); + headers[name] = value.slice(colon + 1).trim(); } return headers; } @@ -230,14 +235,8 @@ export async function runApi( return EXIT.usage; } - let token: string | null; - try { - token = await resolveAccessToken(); - } catch (error) { - line(io.stderr, messageOf(error)); - return EXIT.unreachable; - } - if (!token && !hasHeader(request.headers, 'Authorization')) { + const token = resolveAccessToken(); + if (!token) { line(io.stderr, NOT_SIGNED_IN); return EXIT.auth; } diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts index ec4ca85d1..411f102f1 100644 --- a/packages/cli/src/commands/auth/index.ts +++ b/packages/cli/src/commands/auth/index.ts @@ -6,12 +6,7 @@ import { resolveAccessToken } from '../../core/auth.js'; import { createCLILogger } from '../../core/cli-logger.js'; import { writeResult } from '../../core/output.js'; import type { GlobalOptions } from '../../types/global.js'; -import { - EXIT, - NOT_SIGNED_IN, - type ApiCommandIO, - type ApiExit, -} from '../api/index.js'; +import { NOT_SIGNED_IN } from '../api/index.js'; // === Programmatic API === @@ -26,9 +21,8 @@ async function readJson(response: Response): Promise { /** Who the current credential belongs to, as the app answers it. */ export async function whoami(): Promise { - const token = await resolveAccessToken(); - if (!token) - throw new Error('Not authenticated. Run `walkeros auth login` first.'); + const token = resolveAccessToken(); + if (!token) throw new Error(NOT_SIGNED_IN); const response = await appFetch(APP_PATHS.whoami, { token }); const body = await readJson(response); if (!response.ok) @@ -36,39 +30,11 @@ export async function whoami(): Promise { return body; } -/** - * Print the current access token and a newline on stdout, so any HTTP client - * can call the app (`gh auth token` pattern). Exit 3 without a credential, 4 - * when a refresh cannot reach the app. - */ -export async function authTokenCommand( - io: Pick = { - stdout: process.stdout, - stderr: process.stderr, - }, -): Promise { - let token: string | null; - try { - token = await resolveAccessToken(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - io.stderr.write(`walkeros auth token: ${message}\n`); - return EXIT.unreachable; - } - if (!token) { - io.stderr.write(`walkeros auth token: ${NOT_SIGNED_IN}\n`); - return EXIT.auth; - } - io.stdout.write(`${token}\n`); - return EXIT.ok; -} - // === CLI Command Handler === const IdentitySchema = z.object({ email: z.string().optional(), userId: z.string().optional(), - projectId: z.string().nullish(), }); export interface WhoamiCommandOptions extends GlobalOptions { @@ -93,7 +59,6 @@ export async function whoamiCommand( const data = identity.data; if (data.email) logger.info(data.email); if (data.userId) logger.info(`User: ${data.userId}`); - if (data.projectId) logger.info(`Project: ${data.projectId}`); } } catch (error) { handleCliError(error); diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts deleted file mode 100644 index e1931b7ca..000000000 --- a/packages/cli/src/commands/login/index.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { z } from 'zod'; -import { createCLILogger } from '../../core/cli-logger.js'; -import { - startDeviceAuthorization, - pollDeviceToken, - type TokenSet, -} from '../../core/oauth-client.js'; -import { - readConfig, - writeConfig, - resolveAppUrl, - getConfigPath, -} from '../../lib/config-file.js'; -import { requireSecureUrl } from '../../lib/secure-url.js'; -import { appFetch } from '../../core/app-fetch.js'; -import { APP_PATHS } from '../../core/app-paths.js'; -import type { GlobalOptions } from '../../types/global.js'; - -/** - * `walkeros auth login` on the RFC 8628 device authorization grant. - * - * The CLI never sees a password and never runs a local callback server: it - * shows a code, the person approves it in a browser they already trust, and - * the CLI polls until the approval lands. - */ - -/** Grace added to the server's stated lifetime before we stop polling. */ -const POLL_TIMEOUT_BUFFER_MS = 5000; - -/** Interval to use when the caller states none. */ -const DEFAULT_POLL_INTERVAL_MS = 5000; - -/** - * Floor on an interval that came from a SERVER. - * - * RFC 8628 lets a server state any minimum gap between polls, including none, - * but `WALKEROS_APP_URL` is user-settable, so that number arrives from - * whichever host the person is pointed at. A stated zero would otherwise set - * this loop's pace to "as fast as the network allows" for the whole life of - * the device code. A remote value may slow the loop down, never off its leash. - */ -const MIN_SERVER_POLL_INTERVAL_MS = 1000; - -/** - * Window to poll for when the caller states none. Sized to the lifetime a - * device code is typically issued with, so a resume that was given no window - * cannot outlive the code it is polling for. - */ -const DEFAULT_POLL_TIMEOUT_MS = 900_000; - -/** RFC 8628 section 3.5: each `slow_down` adds five seconds. */ -const SLOW_DOWN_STEP_MS = 5000; - -/** - * Ceiling on the identity lookup. It runs AFTER the session is on disk, so a - * server that accepts the connection and then says nothing would otherwise - * hold `walkeros auth login` open long past its last useful work. - */ -const WHOAMI_TIMEOUT_MS = 10_000; - -const TIMED_OUT = 'Authorization timed out. Please try again.'; - -/** - * The identity endpoint's response. Validated because a malformed body must - * not put a non-string into the config where the email belongs. - */ -const WhoamiSchema = z.object({ email: z.string().min(1) }); - -export interface LoginCommandOptions extends GlobalOptions { - url?: string; - json?: boolean; -} - -export interface LoginResult { - success: boolean; - email?: string; - configPath?: string; - error?: string; -} - -export interface LoginOptions { - url?: string; - /** Override browser opener for testing */ - openUrl?: (url: string) => Promise; - /** Override fetch for testing */ - fetch?: typeof globalThis.fetch; - /** Max poll attempts before giving up (for testing) */ - maxPollAttempts?: number; - /** Poll interval, replacing the server's stated one (for testing) */ - pollIntervalMs?: number; -} - -/** - * The outcome of finishing a device authorization. - * - * It carries no token material. `completeDeviceLogin` stores the session - * itself, so the credential file keeps exactly one writer and a caller can - * neither persist nor leak what came back. - * - * `pending` and `slow_down` both mean the window closed with the approval - * still outstanding: the device code is untouched, so the same code can be - * handed back in. `slow_down` is that same situation with the server asking - * for a wider gap before the next attempt. - */ -export type DeviceLoginResult = - | { status: 'ok' } - | { status: 'pending' } - | { status: 'slow_down' } - | { status: 'denied' } - | { status: 'expired' } - | { status: 'error'; error: string }; - -export interface CompleteDeviceLoginOptions { - /** App to poll. Defaults to the resolved app URL. */ - url?: string; - /** Stop polling after this long. */ - timeoutMs?: number; - /** Wait between polls, before any `slow_down` widens it. */ - intervalMs?: number; - /** Override fetch for testing */ - fetch?: typeof globalThis.fetch; - /** Max poll attempts before giving up (for testing) */ - maxPollAttempts?: number; -} - -async function openInBrowser(url: string): Promise { - const { default: open } = await import('open'); - await open(url); -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function loginCommand( - options: LoginCommandOptions, -): Promise { - const logger = createCLILogger(options); - - try { - const result = await login({ url: options.url }); - - if (options.json) { - logger.json(result); - } else if (result.success) { - if (result.email) logger.info(`Logged in as ${result.email}`); - else logger.info('Logged in.'); - logger.info(`Session stored in ${result.configPath}`); - } else if (result.error) { - logger.error(result.error); - } - - process.exit(result.success ? 0 : 1); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - - if (options.json) { - logger.json({ success: false, error: message }); - } else { - logger.error(message); - } - - process.exit(1); - } -} - -/** - * Ask the API who the freshly issued token belongs to. - * - * Non-fatal: the session is already stored and usable, and an address is only - * there so the CLI can name the account it logged into. - */ -async function fetchEmail( - appUrl: string, - accessToken: string, - fetchFn: typeof globalThis.fetch, -): Promise { - try { - const response = await appFetch(APP_PATHS.whoami, { - baseUrl: appUrl, - token: accessToken, - fetch: fetchFn, - signal: AbortSignal.timeout(WHOAMI_TIMEOUT_MS), - }); - if (!response.ok) return undefined; - const parsed = WhoamiSchema.safeParse(await response.json()); - return parsed.success ? parsed.data.email : undefined; - } catch { - return undefined; - } -} - -/** Store the session a device authorization yielded, and name its account. */ -async function persistSession( - appUrl: string, - tokens: TokenSet, - fetchFn: typeof globalThis.fetch, -): Promise { - // Every credential key is written explicitly, including the ones that may - // be absent: `writeConfig` merges, so a key left out would keep the - // PREVIOUS session's value and quietly outlive the login that replaced it. - writeConfig({ - accessToken: tokens.accessToken, - accessTokenExpiresAt: tokens.accessTokenExpiresAt, - refreshToken: tokens.refreshToken ?? undefined, - appUrl, - // Drop the static token this session replaces, so the deprecated path - // cannot outlive the login that retired it. - token: undefined, - }); - - // Unconditional: a stale address is worse than none, since it names the - // account this session belongs to. - const email = await fetchEmail(appUrl, tokens.accessToken, fetchFn); - writeConfig({ email }); -} - -/** - * Poll a device authorization to its end and store the session it yields. - * - * Split out from `login` so a caller holding only a device code can finish an - * authorization that is already under way. `login` cannot serve that: it - * starts a fresh authorization on every call, which would strand the code the - * person is looking at. - */ -export async function completeDeviceLogin( - deviceCode: string, - options: CompleteDeviceLoginOptions = {}, -): Promise { - const fetchFn = options.fetch ?? globalThis.fetch; - const appUrl = requireSecureUrl(options.url || resolveAppUrl()); - const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS); - - let intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; - let attempts = 0; - let waiting: 'pending' | 'slow_down' = 'pending'; - - // Waiting out the interval is part of an attempt, so there is only room for - // another poll while a whole interval still fits inside the window. - while (Date.now() + intervalMs <= deadline) { - if ( - options.maxPollAttempts !== undefined && - attempts >= options.maxPollAttempts - ) - break; - attempts += 1; - - await delay(intervalMs); - - // The window bounds the request too, not just how many are started: a - // stalled response would otherwise run past the deadline on the HTTP - // client's own clock. - const signal = AbortSignal.timeout(Math.max(1, deadline - Date.now())); - const poll = await pollDeviceToken(appUrl, deviceCode, fetchFn, signal); - - if (poll.status === 'pending') { - waiting = 'pending'; - // An aborted request means the window is spent, even when its timer - // fired a moment before Date.now() reached the deadline. - if (signal.aborted) break; - continue; - } - if (poll.status === 'slow_down') { - waiting = 'slow_down'; - intervalMs += SLOW_DOWN_STEP_MS; - continue; - } - if (poll.status !== 'ok') return poll; - - await persistSession(appUrl, poll.tokens, fetchFn); - return { status: 'ok' }; - } - - return { status: waiting }; -} - -export async function login(options: LoginOptions = {}): Promise { - const fetchFn = options.fetch ?? globalThis.fetch; - const appUrl = requireSecureUrl(options.url || resolveAppUrl()); - - let authorization; - try { - authorization = await startDeviceAuthorization(appUrl, fetchFn); - } catch { - return { success: false, error: 'Failed to request device code' }; - } - - const target = authorization.verificationUriComplete; - - const prompt = (message: string) => process.stderr.write(message + '\n'); - prompt(`\n! Your one-time code: ${authorization.userCode}`); - prompt(` Authorize here: ${target}\n`); - - const opener = options.openUrl ?? openInBrowser; - try { - await opener(target); - prompt(' Opening browser...'); - } catch { - prompt(' Could not open browser. Visit the URL manually.'); - } - - prompt(' Waiting for authorization... (press Ctrl+C to cancel)\n'); - - const outcome = await completeDeviceLogin(authorization.deviceCode, { - url: appUrl, - fetch: fetchFn, - timeoutMs: authorization.expiresIn * 1000 + POLL_TIMEOUT_BUFFER_MS, - // Clamped here, at the one place a server's number enters the loop. The - // helper takes its caller's interval as stated, which is what keeps a - // stated 0 from becoming a poll flood without making the helper's own - // option untestably slow. - intervalMs: - options.pollIntervalMs ?? - Math.max(MIN_SERVER_POLL_INTERVAL_MS, authorization.interval * 1000), - ...(options.maxPollAttempts !== undefined - ? { maxPollAttempts: options.maxPollAttempts } - : {}), - }); - - switch (outcome.status) { - case 'ok': { - // Read back rather than returned: `completeDeviceLogin` owns the config - // file, so what is on disk is the only account this session belongs to. - const email = readConfig()?.email; - return { - success: true, - ...(email ? { email } : {}), - configPath: getConfigPath(), - }; - } - case 'denied': - return { success: false, error: 'Authorization was denied.' }; - case 'error': - return { success: false, error: outcome.error }; - default: - return { success: false, error: TIMED_OUT }; - } -} diff --git a/packages/cli/src/commands/logout/index.ts b/packages/cli/src/commands/logout/index.ts deleted file mode 100644 index d6825e292..000000000 --- a/packages/cli/src/commands/logout/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { createCLILogger } from '../../core/cli-logger.js'; -import { revokeRefreshToken } from '../../core/oauth-client.js'; -import { - deleteConfig, - getConfigPath, - readConfig, - resolveAppUrl, - type WalkerOSConfig, -} from '../../lib/config-file.js'; -import type { GlobalOptions } from '../../types/global.js'; - -export interface LogoutCommandOptions extends GlobalOptions { - json?: boolean; -} - -export async function logoutCommand( - options: LogoutCommandOptions, -): Promise { - const logger = createCLILogger(options); - - const { deleted, superseded } = await logout(); - const configPath = getConfigPath(); - - if (options.json) { - logger.json({ success: true, deleted, superseded }); - } else if (superseded) { - logger.info( - 'A newer session was stored while logging out, and was kept. ' + - 'Run `walkeros auth logout` again to remove it.', - ); - } else if (deleted) { - logger.info(`Logged out. Session removed from ${configPath}`); - } else { - logger.info('No stored credentials found.'); - } - - process.exit(0); -} - -export interface LogoutResult { - deleted: boolean; - /** A different session reached the config while the revocation was in flight. */ - superseded: boolean; -} - -/** Whether two reads of the config carry the same session. */ -function sameSession( - before: WalkerOSConfig | null, - after: WalkerOSConfig, -): boolean { - return ( - before?.accessToken === after.accessToken && - before?.refreshToken === after.refreshToken && - before?.token === after.token - ); -} - -/** - * Revoke the stored refresh token, then drop the local config. - * - * Revocation first, because deleting the file alone would leave a credential - * alive on the server that nothing can ever reach to retire. It is best - * effort: a logout on a plane still has to clear the machine. - */ -export async function logout(): Promise { - const before = readConfig(); - - if (before?.refreshToken) { - await revokeRefreshToken(resolveAppUrl(), before.refreshToken); - } - - // Re-read: revocation is a network round trip, and a login that finished - // inside it stored a session this logout never saw. Deleting the file would - // take that session with it, so the newer credential wins. - // - // It narrows the window rather than closing it. Only the token refresh takes - // the config lock, and holding it across the revocation would be a protocol - // change, not a check. - const after = readConfig(); - if (after && !sameSession(before, after)) - return { deleted: false, superseded: true }; - - return { deleted: deleteConfig(), superseded: false }; -} diff --git a/packages/cli/src/commands/telemetry/__tests__/index.test.ts b/packages/cli/src/commands/telemetry/__tests__/index.test.ts index 0fa1808a5..a21605ee5 100644 --- a/packages/cli/src/commands/telemetry/__tests__/index.test.ts +++ b/packages/cli/src/commands/telemetry/__tests__/index.test.ts @@ -48,7 +48,7 @@ describe('telemetry commands', () => { }); it('reports "not yet chosen" when config exists but field is absent', () => { - writeConfig({ token: 'x' }); + writeConfig({ appUrl: 'https://app.example.test' }); telemetryStatusCommand(); expect(stdoutText()).toMatch(/not yet/i); }); diff --git a/packages/cli/src/core/app-paths.ts b/packages/cli/src/core/app-paths.ts index 6ecba27a3..58dec400d 100644 --- a/packages/cli/src/core/app-paths.ts +++ b/packages/cli/src/core/app-paths.ts @@ -1,13 +1,10 @@ /** - * The app operations the CLI itself calls: the OAuth device flow, identity, - * health, telemetry and the public API document. The app keeps every one in - * its public API set, so its change rule protects them. The only file in the - * CLI that names an app path; `walkeros api` takes its path from the person. + * The app operations the CLI itself calls: identity, health, telemetry and + * the public API document. The app keeps every one in its public API set, so + * its change rule protects them. The only file in the CLI that names an app + * path; `walkeros api` takes its path from the person. */ export const APP_PATHS = { - deviceAuthorization: '/api/oauth/device_authorization', - token: '/api/oauth/token', - revoke: '/api/oauth/revoke', whoami: '/api/auth/whoami', health: '/api/health', telemetry: '/api/telemetry', diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index b821178f9..4fc5cbdf4 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -1,152 +1,5 @@ -import { - readConfig, - writeConfig, - clearAuthFields, - resolveAppUrl, - hasStoredCredential, - type WalkerOSConfig, -} from '../lib/config-file.js'; -import { withConfigLock } from '../lib/config-lock.js'; -import { refreshTokens } from './oauth-client.js'; - -/** - * Refresh this far ahead of the stated expiry. It absorbs clock skew between - * the machine and the server plus the flight time of the request the token is - * about to be spent on, so a token is never handed out with seconds to live. - */ -const REFRESH_SKEW_MS = 60_000; - -let legacyNoticeShown = false; - -/** Test-only: reset the once-per-process guard on the legacy token notice. */ -export function resetLegacyTokenNotice(): void { - legacyNoticeShown = false; -} - -/** - * Announce the legacy static token once, on first use. - * - * Once per process, not once per call: a single command can resolve a token - * many times, and a notice printed on each would be noise the person learns - * to scroll past. It is printed at all because a migrated token can carry a - * year of expiry, so nothing else would ever prompt the switch. - */ -function noticeLegacyToken(): void { - if (legacyNoticeShown) return; - legacyNoticeShown = true; - process.stderr.write( - 'walkerOS: using a static token from your config. ' + - 'Run `walkeros auth login` to switch to a session that refreshes automatically.\n', - ); -} - -/** Whether the stored access token has enough life left to be worth using. */ -function isFresh(config: WalkerOSConfig, nowMs: number): boolean { - if (!config.accessToken || !config.accessTokenExpiresAt) return false; - const expiresAt = Date.parse(config.accessTokenExpiresAt); - if (Number.isNaN(expiresAt)) return false; - return expiresAt - nowMs > REFRESH_SKEW_MS; -} - -/** - * Resolve a bearer for an API call, refreshing the stored session when needed. - * - * Priority: `WALKEROS_TOKEN`, then a legacy static token, then the OAuth - * session. Returns null when nothing can be resolved, which callers render as - * "run `walkeros auth login`". - * - * Throws when a refresh was needed but could not be carried out, which is a - * different problem from having no session and must not be reported as one. - */ -export async function resolveAccessToken(opts?: { - fetch?: typeof fetch; - now?: () => number; -}): Promise { - const envToken = process.env.WALKEROS_TOKEN; - if (envToken) return envToken; - - const now = opts?.now ?? Date.now; - const config = readConfig(); - if (!config) return null; - - if (config.token) { - noticeLegacyToken(); - return config.token; - } - - if (!config.refreshToken) { - return isFresh(config, now()) ? (config.accessToken ?? null) : null; - } - - if (isFresh(config, now())) return config.accessToken ?? null; - - return withConfigLock(async () => { - // Re-read under the lock. Another walkerOS process may have refreshed - // while we queued, and spending our copy of a single-use refresh token - // would invalidate the session it just established. - const current = readConfig(); - if (!current?.refreshToken) return current?.accessToken ?? null; - if (isFresh(current, now())) return current.accessToken ?? null; - - let rotated; - try { - rotated = await refreshTokens( - resolveAppUrl(), - current.refreshToken, - opts?.fetch, - ); - } catch (error) { - // Transient: the server was unreachable or faulted. The refresh token - // may well still be good, so it stays on disk and the next command - // tries again rather than forcing a browser round trip. - // - // Raised rather than returned as null, because null is how this function - // says "there is no session", which sends callers down the wrong path: - // they tell the person to run `walkeros auth login` and send the request - // unauthenticated, when the session is fine and only the network was not. - const reason = error instanceof Error ? error.message : String(error); - throw new Error( - `Could not reach ${resolveAppUrl()} to refresh your session: ${reason}. ` + - 'Your saved session was kept, so try again once the connection is back.', - ); - } - - if (rotated === null) { - // The server rejected the refresh token itself. Nothing local can - // recover it, so drop the dead session. - clearAuthFields(); - return null; - } - - writeConfig({ - accessToken: rotated.accessToken, - accessTokenExpiresAt: rotated.accessTokenExpiresAt, - // A server that rotates no new refresh token leaves the current one in - // force; overwriting it with null would end the session on the next call. - refreshToken: rotated.refreshToken ?? current.refreshToken, - }); - - return rotated.accessToken; - }); -} - -/** - * Authorization header for the resolved credential, or an empty object when - * there is none. Async because resolving may have to refresh the session. - */ -export async function getAuthHeaders(): Promise> { - const token = await resolveAccessToken(); - if (!token) return {}; - return { Authorization: `Bearer ${token}` }; -} - -/** - * Where a credential would come from, without resolving or refreshing it. - * For commands that want to send someone to `walkeros auth login` before spending - * a network round trip. - */ -export function credentialSource(): 'env' | 'config' | null { - if (process.env.WALKEROS_TOKEN) return 'env'; - if (hasStoredCredential(readConfig())) return 'config'; - return null; +/** The one credential the CLI sends: WALKEROS_TOKEN, a token from Account > Automation tokens. */ +export function resolveAccessToken(): string | null { + const token = process.env.WALKEROS_TOKEN; + return token ? token : null; } diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index 9815622a0..80be76cff 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -7,11 +7,7 @@ export * from './utils.js'; export * from './local-packages.js'; export * from './input-detector.js'; export * from './stdin.js'; -export { - resolveAccessToken, - getAuthHeaders, - credentialSource, -} from './auth.js'; +export { resolveAccessToken } from './auth.js'; export * from './event-validation.js'; export * from './package-path.js'; export * from './import-specifier.js'; diff --git a/packages/cli/src/core/oauth-client.ts b/packages/cli/src/core/oauth-client.ts deleted file mode 100644 index 95cd6ce79..000000000 --- a/packages/cli/src/core/oauth-client.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { z } from 'zod'; -import { requireSecureUrl } from '../lib/secure-url.js'; -import { appErrorMessage, appFetch } from './app-fetch.js'; -import { APP_PATHS } from './app-paths.js'; - -/** - * OAuth 2.1 client for the walkerOS authorization server. - * - * Pure HTTP with `fetch` injected: nothing here reads the config file or the - * environment, so the flows can be exercised without a machine state. - */ - -/** Public client id seeded for the CLI. It has no secret. */ -export const CLI_CLIENT_ID = 'walkeros-cli'; - -/** - * `offline_access` is what buys the refresh token; without it every command - * would send the person back to the browser once the access token expired. - */ -export const CLI_SCOPE = 'read write offline_access'; - -const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; - -/** - * Ceiling on a refresh request. It is deliberately below the 15 s after which - * `withConfigLock` treats a lock as abandoned: a refresh runs while holding - * that lock, so a request allowed to hang longer would have its own lock - * broken out from under it and race the process that took it next. - */ -const REFRESH_TIMEOUT_MS = 10_000; - -/** - * Ceiling on a revocation. Nothing waits on its answer, so this only bounds - * how long a logout stands still before clearing the machine. - */ -const REVOKE_TIMEOUT_MS = 5_000; - -/** - * Ceiling on the request that opens a device authorization. Nothing has been - * issued yet, so this only bounds how long `walkeros auth login` stands still - * before it can show a code, but a server that accepts the connection and then - * says nothing would otherwise hold it for the HTTP client's own default. - */ -const DEVICE_AUTHORIZATION_TIMEOUT_MS = 10_000; - -const DeviceAuthorizationSchema = z.object({ - device_code: z.string().min(1), - user_code: z.string().min(1), - verification_uri: z.string().min(1), - verification_uri_complete: z.string().min(1).optional(), - expires_in: z.number().int().nonnegative(), - interval: z.number().int().nonnegative(), -}); - -const TokenSchema = z.object({ - access_token: z.string().min(1), - expires_in: z.number().int().nonnegative(), - refresh_token: z.string().min(1).optional(), -}); - -const ErrorSchema = z.object({ - error: z.string().min(1), - error_description: z.string().optional(), -}); - -export interface DeviceAuthorization { - deviceCode: string; - userCode: string; - verificationUri: string; - verificationUriComplete: string; - expiresIn: number; - interval: number; -} - -export interface TokenSet { - accessToken: string; - accessTokenExpiresAt: string; - refreshToken: string | null; -} - -export type DevicePoll = - | { status: 'ok'; tokens: TokenSet } - | { status: 'pending' } - | { status: 'slow_down' } - | { status: 'denied' } - | { status: 'expired' } - | { status: 'error'; error: string }; - -/** Parse a response body as JSON, or null when it is not JSON at all. */ -async function readJson(response: Response): Promise { - try { - return await response.json(); - } catch { - return null; - } -} - -/** - * The RFC 6749 section 5.2 error code carried by a failed response, or null - * when the body is not an OAuth error (a proxy's HTML, say). - */ -function errorCode(body: unknown): string | null { - const parsed = ErrorSchema.safeParse(body); - return parsed.success ? parsed.data.error : null; -} - -/** - * The OAuth error first, then the app's own error envelope (a version floor - * answers with one), then the bare status. - */ -function describe(response: Response, body: unknown): string { - const parsed = ErrorSchema.safeParse(body); - if (parsed.success) { - return parsed.data.error_description - ? `${parsed.data.error}: ${parsed.data.error_description}` - : parsed.data.error; - } - return appErrorMessage(body) ?? `HTTP ${response.status}`; -} - -/** - * Every request in this module either carries a credential or mints one, so - * the two rules that protect one are applied here rather than per call: - * plain http is refused off the local machine, and a redirect is an error - * rather than a hop, because following one would hand the token (or the code - * that buys it) to whichever host the answer named. - */ -function postForm( - appUrl: string, - path: string, - form: Record, - fetchFn: typeof fetch, - signal?: AbortSignal, -): Promise { - return appFetch(path, { - method: 'POST', - baseUrl: requireSecureUrl(appUrl), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams(form).toString(), - redirect: 'error', - fetch: fetchFn, - signal, - }); -} - -function toTokenSet(body: unknown): TokenSet { - const parsed = TokenSchema.safeParse(body); - if (!parsed.success) throw new Error('Malformed token response'); - - return { - accessToken: parsed.data.access_token, - accessTokenExpiresAt: new Date( - Date.now() + parsed.data.expires_in * 1000, - ).toISOString(), - refreshToken: parsed.data.refresh_token ?? null, - }; -} - -/** - * RFC 8628 section 3.1. Ask for a device code and the URL to send the person - * to. Unauthenticated: the code is worth nothing until somebody approves it. - */ -export async function startDeviceAuthorization( - appUrl: string, - fetchFn: typeof fetch = globalThis.fetch, -): Promise { - const response = await postForm( - appUrl, - APP_PATHS.deviceAuthorization, - { - client_id: CLI_CLIENT_ID, - scope: CLI_SCOPE, - // RFC 8707. The token comes back bound to the API, so a leaked CLI token - // cannot be replayed against the MCP resource. - resource: `${appUrl}/api`, - }, - fetchFn, - AbortSignal.timeout(DEVICE_AUTHORIZATION_TIMEOUT_MS), - ); - - const body = await readJson(response); - if (!response.ok) throw new Error(describe(response, body)); - - const parsed = DeviceAuthorizationSchema.safeParse(body); - if (!parsed.success) - throw new Error('Malformed device authorization response'); - - return { - deviceCode: parsed.data.device_code, - userCode: parsed.data.user_code, - verificationUri: parsed.data.verification_uri, - verificationUriComplete: - parsed.data.verification_uri_complete ?? parsed.data.verification_uri, - expiresIn: parsed.data.expires_in, - interval: parsed.data.interval, - }; -} - -/** - * One poll of RFC 8628 section 3.4. Every outcome is a returned status rather - * than a throw, because four of them are ordinary steps of a flow that is - * still running. - * - * `signal` is how a caller keeps its own deadline: without one, a server that - * accepts the connection and then says nothing holds this call for the HTTP - * client's default, which is minutes. - */ -export async function pollDeviceToken( - appUrl: string, - deviceCode: string, - fetchFn: typeof fetch = globalThis.fetch, - signal?: AbortSignal, -): Promise { - let response: Response; - try { - response = await postForm( - appUrl, - APP_PATHS.token, - { - grant_type: DEVICE_CODE_GRANT, - device_code: deviceCode, - client_id: CLI_CLIENT_ID, - }, - fetchFn, - signal, - ); - } catch (error) { - // Only an abort of the caller's own signal is an outcome rather than a - // fault: the authorization is untouched, so it is still pending and the - // caller's loop decides whether there is room for another attempt. Any - // other transport failure is a real error and stays one. - if (signal?.aborted) return { status: 'pending' }; - throw error; - } - - const body = await readJson(response); - - if (response.ok) { - try { - return { status: 'ok', tokens: toTokenSet(body) }; - } catch (error) { - return { - status: 'error', - error: error instanceof Error ? error.message : String(error), - }; - } - } - - switch (errorCode(body)) { - case 'authorization_pending': - return { status: 'pending' }; - case 'slow_down': - return { status: 'slow_down' }; - case 'access_denied': - return { status: 'denied' }; - case 'expired_token': - return { status: 'expired' }; - default: - return { status: 'error', error: describe(response, body) }; - } -} - -/** - * Spend a refresh token for a new pair. - * - * `null` means the server rejected the token itself and only a fresh login - * can recover. Every other failure throws, so a transient fault stays - * distinguishable from a dead session and never costs the person their login. - */ -export async function refreshTokens( - appUrl: string, - refreshToken: string, - fetchFn: typeof fetch = globalThis.fetch, -): Promise { - const response = await postForm( - appUrl, - APP_PATHS.token, - { - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: CLI_CLIENT_ID, - }, - fetchFn, - AbortSignal.timeout(REFRESH_TIMEOUT_MS), - ); - - const body = await readJson(response); - - if (!response.ok) { - if (errorCode(body) === 'invalid_grant') return null; - throw new Error(describe(response, body)); - } - - return toTokenSet(body); -} - -/** - * RFC 7009. Best effort by design: logout must clear the local config whether - * or not the server could be reached, and the server answers a revocation it - * cannot act on with 200 anyway. - */ -export async function revokeRefreshToken( - appUrl: string, - refreshToken: string, - fetchFn: typeof fetch = globalThis.fetch, -): Promise { - try { - await postForm( - appUrl, - APP_PATHS.revoke, - { - token: refreshToken, - token_type_hint: 'refresh_token', - client_id: CLI_CLIENT_ID, - }, - fetchFn, - AbortSignal.timeout(REVOKE_TIMEOUT_MS), - ); - } catch { - // Offline, or the server is down. The local credential is still cleared. - } -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 15ac9f0ec..5b379af99 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,18 +9,6 @@ declare module '@walkeros/core' { export { bundleCommand } from './commands/bundle/index.js'; export { pushCommand } from './commands/push/index.js'; export { validateCommand } from './commands/validate/index.js'; -export { - loginCommand, - login, - completeDeviceLogin, -} from './commands/login/index.js'; -export type { - LoginOptions, - LoginResult, - CompleteDeviceLoginOptions, - DeviceLoginResult, -} from './commands/login/index.js'; -export { logoutCommand, logout } from './commands/logout/index.js'; export { whoamiCommand } from './commands/auth/index.js'; export { telemetryStatusCommand, @@ -78,13 +66,7 @@ export type { SimulateCollectorOptions, } from './commands/push/index.js'; export { validate } from './commands/validate/index.js'; -export { - resolveAccessToken, - getAuthHeaders, - credentialSource, -} from './core/auth.js'; -export { startDeviceAuthorization } from './core/oauth-client.js'; -export type { DeviceAuthorization } from './core/oauth-client.js'; +export { resolveAccessToken } from './core/auth.js'; export { ApiError } from './core/api-error.js'; export type { ApiErrorDetail } from './core/api-error.js'; export { @@ -98,14 +80,7 @@ export { appFetch, appErrorMessage } from './core/app-fetch.js'; export type { AppFetchInit } from './core/app-fetch.js'; export { APP_PATHS } from './core/app-paths.js'; export { whoami } from './commands/auth/index.js'; -export { - readConfig, - writeConfig, - clearAuthFields, - deleteConfig, - resolveToken, - resolveAppUrl, -} from './lib/config-file.js'; +export { readConfig, writeConfig, resolveAppUrl } from './lib/config-file.js'; export { VERSION } from './version.js'; export type { WalkerOSConfig } from './lib/config-file.js'; diff --git a/packages/cli/src/lib/config-file.ts b/packages/cli/src/lib/config-file.ts index 0797b4029..2b0aeb49e 100644 --- a/packages/cli/src/lib/config-file.ts +++ b/packages/cli/src/lib/config-file.ts @@ -3,7 +3,6 @@ import { writeFileSync, mkdirSync, unlinkSync, - existsSync, chmodSync, renameSync, } from 'fs'; @@ -11,20 +10,12 @@ import { randomBytes } from 'crypto'; import { join } from 'path'; import { homedir } from 'os'; +/** + * The CLI's own settings. It holds no credential: the CLI authenticates with + * WALKEROS_TOKEN only, and sign-in fields an older CLI left in the file are + * never read. + */ export interface WalkerOSConfig { - /** - * Static bearer written by the pre-OAuth CLI. Honored until it expires, and - * the first use in a process prints a one-line notice naming - * `walkeros auth login`, which replaces it with a refreshable session. - */ - token?: string; - /** Short-lived bearer from the device authorization grant. */ - accessToken?: string; - /** ISO 8601 instant at which `accessToken` stops being accepted. */ - accessTokenExpiresAt?: string; - /** Single-use credential that buys a new `accessToken`. */ - refreshToken?: string; - email?: string; appUrl?: string; anonymousFeedback?: boolean; defaultProjectId?: string; @@ -75,14 +66,14 @@ export function readConfig(): WalkerOSConfig | null { * Replace the config file wholesale, atomically and with 0600 permissions. * * A reader that catches the file mid-write would see truncated JSON and treat - * the person as logged out, so the content is written to a temp file and - * renamed, which is atomic within a directory. + * the file as missing, so the content is written to a temp file and renamed, + * which is atomic within a directory. * - * The temp path is unique per write. Only the token refresh holds the config - * lock, so two ordinary writers (a login and a `telemetry enable`, say) can be - * in here at once: on one shared name they would write over each other's temp - * file and rename it twice, and the slower one would fail outright when the - * faster renamed the file out from under its `chmod`. + * The temp path is unique per write. Nothing locks the file, so two writers + * (two `telemetry` commands, say) can be in here at once: on one shared name + * they would write over each other's temp file and rename it twice, and the + * slower one would fail outright when the faster renamed the file out from + * under its `chmod`. */ function replaceConfigFile(config: WalkerOSConfig): void { const dir = getConfigDir(); @@ -112,37 +103,15 @@ function replaceConfigFile(config: WalkerOSConfig): void { * Merge `config` into the stored config and write the result. * * Merging rather than replacing, because the file holds fields owned by - * unrelated commands: a writer that knows only about tokens would otherwise - * drop `defaultProjectId`, `installationId`, `telemetryEnabled` and - * `anonymousFeedback` every time somebody logs in. + * unrelated commands: a writer that knows only about telemetry would otherwise + * drop `appUrl`, `defaultProjectId` and `anonymousFeedback`. * - * A key passed explicitly as `undefined` is removed from the written file, - * which is how login drops the legacy static token it replaces. + * A key passed explicitly as `undefined` is removed from the written file. */ export function writeConfig(config: WalkerOSConfig): void { replaceConfigFile({ ...(readConfig() ?? {}), ...config }); } -/** - * Remove every credential field, keeping the rest of the config. - * - * Used when the stored session is known to be dead, so the next command can - * say "run `walkeros auth login`" instead of failing against the API. - */ -export function clearAuthFields(): void { - const config = readConfig(); - if (!config) return; - const { - token: _token, - accessToken: _accessToken, - accessTokenExpiresAt: _accessTokenExpiresAt, - refreshToken: _refreshToken, - email: _email, - ...rest - } = config; - replaceConfigFile(rest); -} - /** * Persist telemetry-relevant fields without touching unrelated config. * Reads the existing config (if any) and merges in the provided fields. @@ -157,46 +126,6 @@ export function writeTelemetryOnlyConfig(partial: { writeConfig(partial); } -/** - * Delete the config file (logout) - */ -export function deleteConfig(): boolean { - const configPath = getConfigPath(); - if (existsSync(configPath)) { - unlinkSync(configPath); - return true; - } - return false; -} - -/** - * Whether a config holds a login: a legacy static `token` or an OAuth - * `accessToken`. The one rule for a stored credential, used by - * `credentialSource()`. - */ -export function hasStoredCredential(config: WalkerOSConfig | null): boolean { - return Boolean(config?.token || config?.accessToken); -} - -/** - * Resolve the API token using priority order: - * 1. WALKEROS_TOKEN env var - * 2. Config file (~/.config/walkeros/config.json) - * 3. null (not authenticated) - */ -export function resolveToken(): { - token: string; - source: 'env' | 'config'; -} | null { - const envToken = process.env.WALKEROS_TOKEN; - if (envToken) return { token: envToken, source: 'env' }; - - const config = readConfig(); - if (config?.token) return { token: config.token, source: 'config' }; - - return null; -} - /** * Resolve the app URL. * WALKEROS_APP_URL env var > config file > default. diff --git a/packages/cli/src/lib/config-lock.ts b/packages/cli/src/lib/config-lock.ts deleted file mode 100644 index 567c1cca4..000000000 --- a/packages/cli/src/lib/config-lock.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { closeSync, mkdirSync, openSync, statSync, unlinkSync } from 'fs'; -import { getConfigDir, getConfigPath } from './config-file.js'; - -/** - * A lock older than this is treated as abandoned. It bounds the damage a - * process killed mid-refresh can do: without it, one crash leaves every later - * command waiting for a holder that will never return. - */ -const STALE_MS = 15_000; - -/** Gap between acquire attempts. */ -const RETRY_MS = 100; - -/** How long to keep trying before giving up on a lock somebody else holds. */ -const TIMEOUT_MS = 10_000; - -/** Path of the lock guarding the config file. */ -export function getConfigLockPath(): string { - return `${getConfigPath()}.lock`; -} - -function hasCode(value: unknown): value is { code: unknown } { - return typeof value === 'object' && value !== null && 'code' in value; -} - -/** - * Matched on the `code` property rather than on `instanceof Error`, which is - * unreliable across realm boundaries (a test runner's module sandbox is one). - */ -function isAlreadyLocked(error: unknown): boolean { - return hasCode(error) && error.code === 'EEXIST'; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Remove the lock when its holder has clearly gone away. Returns whether the - * lock was removed, so the caller can retry immediately rather than sleeping. - */ -function breakIfStale(lockPath: string): boolean { - try { - const age = Date.now() - statSync(lockPath).mtimeMs; - if (age < STALE_MS) return false; - unlinkSync(lockPath); - return true; - } catch { - // Gone between the stat and the unlink, or never there: either way the - // next acquire attempt is the answer. - return false; - } -} - -/** - * Run `fn` while holding an exclusive lock on the config file. - * - * Several walkerOS processes can share one config (a shell, an editor's MCP - * server, a watch loop). Without a lock, two of them noticing an expired - * access token at the same moment would both spend the single-use refresh - * token, and the loser's rotation would invalidate the winner's session. - * - * The lock is a file created with `O_EXCL`, which is atomic on every platform - * the CLI runs on and needs no daemon. - */ -export async function withConfigLock(fn: () => Promise): Promise { - const lockPath = getConfigLockPath(); - mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); - - const deadline = Date.now() + TIMEOUT_MS; - - for (;;) { - let handle: number; - try { - handle = openSync(lockPath, 'wx', 0o600); - } catch (error) { - if (!isAlreadyLocked(error)) throw error; - if (breakIfStale(lockPath)) continue; - if (Date.now() >= deadline) { - throw new Error( - `Timed out waiting for the walkerOS config lock at ${lockPath}. ` + - 'Remove the file if no other walkeros process is running.', - ); - } - await delay(RETRY_MS); - continue; - } - - closeSync(handle); - try { - return await fn(); - } finally { - try { - unlinkSync(lockPath); - } catch { - // Another process broke the lock as stale while we held it. Nothing to - // release, and failing here would mask the callback's own result. - } - } - } -} diff --git a/packages/cli/src/telemetry/__tests__/consent.test.ts b/packages/cli/src/telemetry/__tests__/consent.test.ts index 96f7397c5..9bc85a128 100644 --- a/packages/cli/src/telemetry/__tests__/consent.test.ts +++ b/packages/cli/src/telemetry/__tests__/consent.test.ts @@ -26,7 +26,7 @@ describe('isTelemetryEnabled', () => { }); it('is disabled when config exists but telemetryEnabled is absent', () => { - writeConfig({ token: 'x' }); + writeConfig({ appUrl: 'https://app.example.test' }); expect(isTelemetryEnabled()).toBe(false); }); diff --git a/packages/cli/src/telemetry/__tests__/first-run-notice.test.ts b/packages/cli/src/telemetry/__tests__/first-run-notice.test.ts index 7fb4ba14c..d8467014c 100644 --- a/packages/cli/src/telemetry/__tests__/first-run-notice.test.ts +++ b/packages/cli/src/telemetry/__tests__/first-run-notice.test.ts @@ -32,7 +32,7 @@ describe('maybePrintFirstRunNotice (opt-in)', () => { }); it('does not print a second time if a config exists', () => { - writeConfig({ token: 'x' }); + writeConfig({ appUrl: 'https://app.example.test' }); const errSpy = jest .spyOn(process.stderr, 'write') .mockImplementation(() => true); diff --git a/packages/cli/src/telemetry/__tests__/install-id.test.ts b/packages/cli/src/telemetry/__tests__/install-id.test.ts index 1f6269442..f6e71a05c 100644 --- a/packages/cli/src/telemetry/__tests__/install-id.test.ts +++ b/packages/cli/src/telemetry/__tests__/install-id.test.ts @@ -30,7 +30,7 @@ describe('getInstallationId (read-only)', () => { }); it('returns undefined when config has no installationId', () => { - writeConfig({ token: 'x' }); + writeConfig({ appUrl: 'https://app.example.test' }); expect(getInstallationId()).toBeUndefined(); }); diff --git a/packages/cli/src/telemetry/emitter.ts b/packages/cli/src/telemetry/emitter.ts index 2e240e5e8..72f3c33cc 100644 --- a/packages/cli/src/telemetry/emitter.ts +++ b/packages/cli/src/telemetry/emitter.ts @@ -179,10 +179,10 @@ export async function createEmitter(opts: EmitterOptions): Promise { /** * Resolve the telemetry ingest endpoint. * - * Reuses `resolveAppUrl()` from `lib/config-file` so a single - * `walkeros auth login` (which writes `appUrl` to the user config) wires - * telemetry alongside auth. Order: env (`WALKEROS_APP_URL`) > user config - * (`~/.config/walkeros/config.json:appUrl`) > undefined (telemetry no-ops). + * Reuses `resolveAppUrl()` from `lib/config-file`, so telemetry goes to the + * same app as every other CLI request. Order: env (`WALKEROS_APP_URL`) > user + * config (`~/.config/walkeros/config.json:appUrl`) > undefined (telemetry + * no-ops). * * Hard-cut: `TELEMETRY_ENDPOINT` is no longer recognized. */ diff --git a/packages/cli/src/telemetry/first-run-notice.ts b/packages/cli/src/telemetry/first-run-notice.ts index 47dba8eff..ab9fae4d5 100644 --- a/packages/cli/src/telemetry/first-run-notice.ts +++ b/packages/cli/src/telemetry/first-run-notice.ts @@ -3,9 +3,9 @@ import { readConfig } from '../lib/config-file.js'; /** * One-time informational notice inviting the user to opt in to anonymous * telemetry. Non-blocking. Writes nothing to disk. Suppressed on subsequent - * runs by the presence of a config file (created by login or an explicit - * `walkeros telemetry enable`). If the user never creates a config for any - * other reason, the notice keeps appearing. That is acceptable: the notice + * runs by the presence of a config file (created by an explicit + * `walkeros telemetry enable` or `disable`). If the user never creates a + * config for any other reason, the notice keeps appearing. That is acceptable: the notice * is a one-line informational message, not a prompt. */ export function maybePrintFirstRunNotice(): void { diff --git a/packages/cli/src/telemetry/flow.json b/packages/cli/src/telemetry/flow.json index 153006b4f..60749008b 100644 --- a/packages/cli/src/telemetry/flow.json +++ b/packages/cli/src/telemetry/flow.json @@ -66,7 +66,7 @@ "properties": { "command": { "type": "string", - "description": "CLI command, space-joined for subcommands (e.g. 'bundle', 'projects list', 'auth login'). Set on source.command when source.type = 'cli'." + "description": "CLI command, space-joined for subcommands (e.g. 'bundle', 'projects list', 'api'). Set on source.command when source.type = 'cli'." }, "tool": { "type": "string", diff --git a/packages/core/src/__tests__/flow.test.ts b/packages/core/src/__tests__/flow.test.ts index fc938c86a..5d659c57d 100644 --- a/packages/core/src/__tests__/flow.test.ts +++ b/packages/core/src/__tests__/flow.test.ts @@ -2560,6 +2560,29 @@ describe('$flow references', () => { } }); + test('resolves a flow name with a hyphen', () => { + const config: Flow.Json = { + version: 4, + flows: { + 'tracking-server': { + config: { platform: 'server', url: 'https://collect.example.com' }, + }, + web: { + config: { platform: 'web' }, + destinations: { + api: { + config: { settings: { url: '$flow.tracking-server.url' } }, + }, + }, + }, + }, + }; + const web = getFlowSettings(config, 'web'); + expect(web.destinations?.api.config).toEqual({ + settings: { url: 'https://collect.example.com' }, + }); + }); + test('throws on unknown flow name', () => { const config: Flow.Json = { version: 4, @@ -2657,8 +2680,9 @@ describe('$flow references', () => { strictFlowRefs: false, onWarning: (msg) => warnings.push(msg), }); - expect(warnings.length).toBe(1); - expect(warnings[0]).toMatch(/\$flow\.server\.url/); + expect(warnings).toEqual([ + '$flow.server.url is empty. Set flows.server.config.url.', + ]); // Resolved value remains the original $flow string in soft mode expect(web.destinations?.api.config).toEqual({ settings: { url: '$flow.server.url' }, diff --git a/packages/core/src/__tests__/moin.test.ts b/packages/core/src/__tests__/moin.test.ts index 6c53855ef..e0cd3540e 100644 --- a/packages/core/src/__tests__/moin.test.ts +++ b/packages/core/src/__tests__/moin.test.ts @@ -50,7 +50,7 @@ afterEach(() => { // Listeners outlive a test on the shared jsdom window: drop every one the // loader added, so a later ping reaches only the loader under test. for (const [type, listener] of listen.mock.calls) - if (type === 'message') window.removeEventListener(type, listener); + if (type === 'message') window.removeEventListener(type, listener, true); jest.restoreAllMocks(); }); @@ -158,6 +158,7 @@ describe('arming', () => { expect(frame('self')).toHaveBeenCalledWith( 'message', expect.any(Function), + true, ); }); }); @@ -229,13 +230,30 @@ describe('the ping', () => { expect(script.parentNode).toBe(document.head); }); + it('hears the ping on a page that stops every message from an origin it does not know', () => { + // A site that screens messages by origin, added before the loader is. + const firewall = (event: MessageEvent) => { + if (event.origin !== window.location.origin) + event.stopImmediatePropagation(); + }; + window.addEventListener('message', firewall); + try { + setSearch('?elbMoin'); + moin(T); + ping(T.app, { elbMoin: 1, file: FILE }); + expect(scripts()).toHaveLength(1); + } finally { + window.removeEventListener('message', firewall); + } + }); + it('accepts only the first ping', () => { const drop = jest.spyOn(window, 'removeEventListener'); moin(T); ping(T.app, { elbMoin: 1, file: FILE }); ping(T.app, { elbMoin: 1, file: 'B'.repeat(64) }); expect(scripts()).toHaveLength(1); - expect(drop).toHaveBeenCalledWith('message', messageListeners()[0]); + expect(drop).toHaveBeenCalledWith('message', messageListeners()[0], true); }); it('trusts the production target by default', () => { diff --git a/packages/core/src/__tests__/references.test.ts b/packages/core/src/__tests__/references.test.ts index 85ae5fe2a..9ccf97a99 100644 --- a/packages/core/src/__tests__/references.test.ts +++ b/packages/core/src/__tests__/references.test.ts @@ -5,6 +5,7 @@ import { REF_ENV, REF_CONTRACT, REF_FLOW, + REF_FLOW_INLINE, REF_STORE, REF_SECRET, REF_CODE_PREFIX, @@ -111,6 +112,35 @@ describe('REF_FLOW', () => { expect('prefix$flow.server.url'.match(REF_FLOW)).toBeNull(); expect('$flow.server.url suffix'.match(REF_FLOW)).toBeNull(); }); + + test('a hyphen belongs to the flow name, the first dot starts the path', () => { + const m = '$flow.tracking-server.url'.match(REF_FLOW)!; + expect(m[1]).toBe('tracking-server'); + expect(m[2]).toBe('url'); + + const short = '$flow.a-b.url'.match(REF_FLOW)!; + expect(short[1]).toBe('a-b'); + expect(short[2]).toBe('url'); + + expect('$flow.a-b'.match(REF_FLOW)?.[1]).toBe('a-b'); + }); + + test('a name starts with a letter or _', () => { + expect('$flow.-server.url'.match(REF_FLOW)).toBeNull(); + expect('$flow.1server.url'.match(REF_FLOW)).toBeNull(); + }); +}); + +describe('REF_FLOW_INLINE', () => { + test('captures name and path of each reference inside a longer string', () => { + const matches = [ + ...'to $flow.tracking-server.url or $flow.web'.matchAll(REF_FLOW_INLINE), + ]; + expect(matches.map((m) => [m[1], m[2]])).toEqual([ + ['tracking-server', 'url'], + ['web', undefined], + ]); + }); }); describe('scanFlowRefs', () => { @@ -136,11 +166,11 @@ describe('scanFlowRefs', () => { expect(refs.has('c')).toBe(true); }); - it('matches the resolver name grammar (hyphen is a boundary)', () => { - // hyphen ends the name: $flow.a-2 captures `a`, not `a-2` + it('matches the resolver name grammar (hyphen is part of the name)', () => { + // $flow.a-2 captures `a-2`, not `a` const hyphen = scanFlowRefs('$flow.a-2'); - expect(hyphen.has('a')).toBe(true); - expect(hyphen.has('a-2')).toBe(false); + expect(hyphen.has('a-2')).toBe(true); + expect(hyphen.has('a')).toBe(false); // leading digit is not a valid name: no capture const leadingDigit = scanFlowRefs('$flow.1abc'); @@ -191,6 +221,8 @@ describe('core barrel re-exports', () => { ['REF_ENV'], ['REF_CONTRACT'], ['REF_FLOW'], + ['REF_FLOW_INLINE'], + ['REF_FLOW_NAME'], ['REF_STORE'], ['REF_SECRET'], ['REF_CODE_PREFIX'], diff --git a/packages/core/src/__tests__/schemas/validate-flow-config.test.ts b/packages/core/src/__tests__/schemas/validate-flow-config.test.ts index b39944715..9343c6b88 100644 --- a/packages/core/src/__tests__/schemas/validate-flow-config.test.ts +++ b/packages/core/src/__tests__/schemas/validate-flow-config.test.ts @@ -688,6 +688,43 @@ describe('validateFlowConfig', () => { ), ).toHaveLength(0); }); + + // A hyphenated flow name, referenced from the web flow. + const hyphenated = (flowName: string) => + JSON.stringify( + { + version: 4, + flows: { + web: { + config: { + platform: 'web', + settings: { backend: '$flow.tracking-server.url' }, + }, + }, + [flowName]: { + config: { platform: 'server', url: 'https://api.test' }, + }, + }, + }, + null, + 2, + ); + + it('accepts a reference to an existing hyphenated flow', () => { + const result = validateFlowConfig(hyphenated('tracking-server')); + expect( + result.warnings.filter((w) => w.message.includes('$flow')), + ).toEqual([]); + }); + + it('names a missing hyphenated flow in full', () => { + const result = validateFlowConfig(hyphenated('server')); + const messages = result.warnings.map((w) => w.message); + expect(messages).toContain( + 'Unknown flow "$flow.tracking-server". Defined: web, server', + ); + expect(messages.some((m) => m.includes('"$flow.tracking"'))).toBe(false); + }); }); // --- Colon-instead-of-dot typo detection --- @@ -774,6 +811,31 @@ describe('validateFlowConfig', () => { expect(warning.message).toMatch(/\$flow\.server/); }); + it('suggests a hyphenated $flow name in full', () => { + const json = JSON.stringify( + { + version: 4, + flows: { + web: { + config: { + platform: 'web', + settings: { url: '$flow:tracking-server' }, + }, + }, + 'tracking-server': { + config: { platform: 'server', url: 'https://api.test' }, + }, + }, + }, + null, + 2, + ); + const result = validateFlowConfig(json); + expect(result.warnings.map((w) => w.message)).toContain( + 'Malformed reference "$flow:tracking-server", use a dot, not a colon: "$flow.tracking-server".', + ); + }); + it('warns on $secret:NAME and suggests $secret.NAME', () => { const json = JSON.stringify( { diff --git a/packages/core/src/flow.ts b/packages/core/src/flow.ts index 3f7203c2c..4cf766c33 100644 --- a/packages/core/src/flow.ts +++ b/packages/core/src/flow.ts @@ -120,7 +120,7 @@ function formatUnresolvedFlowMessage( const target = path ? `flows.${flowName}.config.${path}` : `flows.${flowName}.config`; - return `${ref} is empty. Set ${target}, or run \`walkeros deploy ${flowName}\` first.`; + return `${ref} is empty. Set ${target}.`; } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d841a39a4..9bf25c27e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -108,6 +108,8 @@ export { REF_ENV, REF_CONTRACT, REF_FLOW, + REF_FLOW_INLINE, + REF_FLOW_NAME, REF_STORE, REF_SECRET, REF_CODE_PREFIX, diff --git a/packages/core/src/moin.ts b/packages/core/src/moin.ts index 609f8b729..9067e1e73 100644 --- a/packages/core/src/moin.ts +++ b/packages/core/src/moin.ts @@ -41,10 +41,12 @@ export interface MoinGlobals { addEventListener( type: 'message', listener: (event: MoinMessage) => void, + capture?: boolean, ): void; removeEventListener( type: 'message', listener: (event: MoinMessage) => void, + capture?: boolean, ): void; }; URLSearchParams?: new (init: string) => { has(name: string): boolean }; @@ -87,13 +89,17 @@ export function runMoin(g: MoinGlobals, target: MoinTarget): void { if (Reflect.get(data, 'elbMoin') !== 1) return; const file: unknown = Reflect.get(data, 'file'); if (typeof file !== 'string' || !FILE.test(file)) return; - win.removeEventListener('message', onMessage); + win.removeEventListener('message', onMessage, true); void injectScript( `${target.base}${file}.js`, `sha384-${file.replace(/-/g, '+').replace(/_/g, '/')}`, ); }; - win.addEventListener('message', onMessage); + // In the capture phase: a site that screens messages by origin and stops + // the ones it does not know would otherwise keep the app's ping from the + // loader. On the window, a capture listener runs before every listener of + // the bubble phase. + win.addEventListener('message', onMessage, true); } catch { // A broken page global leaves the loader off. } diff --git a/packages/core/src/references.ts b/packages/core/src/references.ts index 49827fce3..205d8bd31 100644 --- a/packages/core/src/references.ts +++ b/packages/core/src/references.ts @@ -20,15 +20,21 @@ export const REF_VAR_INLINE = /\$var\.([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)/g; export const REF_ENV = /\$env\.([a-zA-Z_][a-zA-Z0-9_]*)(?::([^"}\s]*))?/g; export const REF_CONTRACT = /^\$contract\.([a-zA-Z_][a-zA-Z0-9_]*)(?:\.(.+))?$/; +/** + * Regex source (a string, not a RegExp) of the flow name a `$flow` + * reference can carry: a letter or `_`, then letters, digits, `_` or `-` + * (`tracking-server`). Every `$flow` pattern is built from it. + */ +export const REF_FLOW_NAME = '[a-zA-Z_][a-zA-Z0-9_-]*'; +const REF_FLOW_BODY = `\\$flow\\.(${REF_FLOW_NAME})(?:\\.([a-zA-Z0-9_.]+))?`; /** Whole-string `$flow.(.)?`: cross-flow value reference. */ -export const REF_FLOW = - /^\$flow\.([a-zA-Z_][a-zA-Z0-9_]*)(?:\.([a-zA-Z0-9_.]+))?$/; +export const REF_FLOW = new RegExp(`^${REF_FLOW_BODY}$`); +/** Global inline: every `$flow.(.)?` inside a larger string. */ +export const REF_FLOW_INLINE = new RegExp(REF_FLOW_BODY, 'g'); export const REF_STORE = /^\$store\.([a-zA-Z_][a-zA-Z0-9_]*)$/; export const REF_SECRET = /^\$secret\.([A-Z0-9_]+)$/; export const REF_CODE_PREFIX = '$code:'; -const FLOW_REF_PATTERN = /\$flow\.([a-zA-Z_][a-zA-Z0-9_]*)/g; - /** * Canonical scanner for the `$flow.` reference grammar. Walks strings, * arrays, and objects and returns every referenced flow name. The returned @@ -38,7 +44,7 @@ const FLOW_REF_PATTERN = /\$flow\.([a-zA-Z_][a-zA-Z0-9_]*)/g; export function scanFlowRefs(value: unknown, into?: Set): Set { const refs = into ?? new Set(); if (typeof value === 'string') { - for (const m of value.matchAll(FLOW_REF_PATTERN)) refs.add(m[1]); + for (const m of value.matchAll(REF_FLOW_INLINE)) refs.add(m[1]); return refs; } if (value && typeof value === 'object') { diff --git a/packages/core/src/schemas/validate-flow-config.ts b/packages/core/src/schemas/validate-flow-config.ts index 4560d8e57..5eb36a677 100644 --- a/packages/core/src/schemas/validate-flow-config.ts +++ b/packages/core/src/schemas/validate-flow-config.ts @@ -9,6 +9,8 @@ import { REF_CODE_PREFIX, REF_CONTRACT, REF_FLOW, + REF_FLOW_INLINE, + REF_FLOW_NAME, REF_SECRET, REF_STORE, } from '../references'; @@ -229,15 +231,14 @@ const STORE_INLINE_REGEX = /\$store\.([a-zA-Z_][a-zA-Z0-9_]*)/g; // so we use a looser pattern here to detect malformed cases too. const ENV_LOOSE_REGEX = /\$env\.([A-Za-z_]\w*)(=[^"}\s]*)?/g; -// Inline variant of REF_FLOW. Captures $flow.NAME(.path)? anywhere in text. -// Path portion stops at JSON delimiters (quote, brace, whitespace). -const FLOW_INLINE_REGEX = - /\$flow\.([a-zA-Z_][a-zA-Z0-9_]*)(?:\.([a-zA-Z0-9_.]+))?/g; - // Scans for $: where is a reference type that uses a dot. +// A flow name follows REF_FLOW_NAME, so a hyphenated one is suggested whole. // Excludes $code: (legitimate code-payload prefix) and $env.NAME:default // (which has the dot before the colon, so it doesn't match this pattern). -const COLON_TYPO_REGEX = /\$(var|store|flow|secret):([a-zA-Z_][a-zA-Z0-9_]*)/g; +const COLON_TYPO_REGEX = new RegExp( + `\\$(?:(var|store|secret):([a-zA-Z_][a-zA-Z0-9_]*)|(flow):(${REF_FLOW_NAME}))`, + 'g', +); // Inline variant of REF_SECRET for scanning anywhere in JSON text. // Source-of-truth REF_SECRET in references.ts is anchored (^...$) and @@ -428,7 +429,8 @@ const WHOLE_VALUE_REFERENCES: ReadonlyArray<{ { prefix: '$flow.', regex: REF_FLOW, - grammar: 'a flow name, then an optional dot path of letters, digits and _', + grammar: + 'a flow name of letters, digits, _ and -, then an optional dot path of letters, digits and _', }, { prefix: '$store.', @@ -729,8 +731,8 @@ function checkColonTypos( ): void { forEachMatch(strings, COLON_TYPO_REGEX, (match, at, offset) => { const full = match[0]; - const prefix = match[1]; - const name = match[2]; + const prefix = match[1] ?? match[3]; + const name = match[2] ?? match[4]; issues.push({ message: `Malformed reference "${full}", use a dot, not a colon: "$${prefix}.${name}".`, severity: 'warning', @@ -768,7 +770,7 @@ function checkFlowReferences( const flowNames = context.flowNames ?? []; if (flowNames.length === 0) return; - forEachMatch(strings, FLOW_INLINE_REGEX, (match, at, offset) => { + forEachMatch(strings, REF_FLOW_INLINE, (match, at, offset) => { const full = match[0]; const name = match[1]; if (flowNames.includes(name)) return; diff --git a/packages/runner/eslint.config.js b/packages/runner/eslint.config.js index 8b953bf61..3e9847f37 100644 --- a/packages/runner/eslint.config.js +++ b/packages/runner/eslint.config.js @@ -9,7 +9,7 @@ const baseSyntax = baseConfig.flatMap((block) => { }); // The runner names app paths in src/api-paths.ts only: its fixed interface, -// which the app keeps in its public API set. +// which the app serves to runner tokens only. const apiPathMessage = 'Build app URLs in src/api-paths.ts, which names the operation.'; const apiPathSyntax = [ diff --git a/packages/runner/src/api-paths.ts b/packages/runner/src/api-paths.ts index 9795dc212..4213a8bf1 100644 --- a/packages/runner/src/api-paths.ts +++ b/packages/runner/src/api-paths.ts @@ -1,7 +1,7 @@ /** * The app operations the runner calls, as `" "`. They - * are the runner's fixed interface: the app keeps both in its public API set - * for runner and automation tokens alike. + * are the runner's fixed interface: the app serves both to runner tokens only, + * on its runner door, outside its public API set. * * The only file in the runner that names an app path. */ diff --git a/skills/INDEX.json b/skills/INDEX.json index 4de0b21be..fdcc28b3d 100644 --- a/skills/INDEX.json +++ b/skills/INDEX.json @@ -1,6 +1,6 @@ { "$generated": "Generated file. Do not edit by hand. Regenerate via: npm run generate:skills-index", - "version": "0.2.0", + "version": "0.3.0", "count": 22, "skills": [ { diff --git a/skills/walkeros-understanding-flow/SKILL.md b/skills/walkeros-understanding-flow/SKILL.md index ee66dc7f6..202f99afc 100644 --- a/skills/walkeros-understanding-flow/SKILL.md +++ b/skills/walkeros-understanding-flow/SKILL.md @@ -143,7 +143,7 @@ no `next`, no `before`; they sit alongside the pipeline rather than inside it. - Implementations: `@walkeros/server-store-fs` (async, filesystem), `@walkeros/server-store-s3` (async, S3-compatible), `@walkeros/server-store-gcs`, `@walkeros/server-store-sheets`. The collector - ships a built-in in-memory cache tier — enable it on any store via + ships a built-in in-memory cache tier; enable it on any store via `Flow.Store.cache` instead of declaring a separate memory store. ```json @@ -732,10 +732,12 @@ every destination needs belongs in `collector.next`, not repeated in each - **Source-level:** `source.config.mapping` and `source.config.consent` - applied before the source chain; blocks event entirely -- **Destination-level:** `destination.config.mapping` and - `destination.config.consent` - applied after the destination chain; skips only - that destination, queues denied events (after `collector.next`, so a replay - never re-runs it) +- **Destination-level:** `destination.config.consent` is checked first, before + the destination chain (`destination.before`): it skips only that destination + and queues denied events. The queue sits after `collector.next`, so a replay + of a queued event never re-runs `collector.next`; it does run + `destination.before`. `destination.config.mapping` is applied after the + destination chain, so a rename in the chain changes which rule matches ### Canvas rendering rules (for UI graph visualization) diff --git a/skills/walkeros-using-cli/SKILL.md b/skills/walkeros-using-cli/SKILL.md index 28a4205ae..6a9a9c672 100644 --- a/skills/walkeros-using-cli/SKILL.md +++ b/skills/walkeros-using-cli/SKILL.md @@ -47,7 +47,7 @@ walkeros push flow.json -e '{"entity":"page","action":"view"}' | `setup` | Run a component's `setup()` to provision external resources | ⚠️ | | `validate` | Validate configs/events | ✅ | | `cache` | Manage caching | ✅ | -| `auth` | Log in, log out, check identity, print the access token | ✅ | +| `auth` | Check who the token in `WALKEROS_TOKEN` belongs to | ✅ | | `api` | Call the walkerOS app API, or list its operations | ⚠️ | For detailed command reference, see diff --git a/skills/walkeros-using-cli/commands-reference.md b/skills/walkeros-using-cli/commands-reference.md index e3f4784ee..ac18af37a 100644 --- a/skills/walkeros-using-cli/commands-reference.md +++ b/skills/walkeros-using-cli/commands-reference.md @@ -227,18 +227,18 @@ To deploy a saved flow from CI, see "Deploy from CI" in the ## auth -Manage the sign-in that `walkeros api` uses. - -| Subcommand | Purpose | -| ---------- | -------------------------------------------------------------------------- | -| `login` | Sign in through the browser (device authorization); stores a session | -| `logout` | Revoke the stored session and remove it from disk | -| `whoami` | Show the signed-in identity | -| `token` | Print the current access token (refreshed when needed) for any HTTP client | - -`walkeros auth token` exits 3 without a credential and 4 when a refresh cannot -reach the app. `WALKEROS_TOKEN` (an automation or service-account token, -`wos_pat_...`) overrides the stored session, which suits CI. +The CLI authenticates with `WALKEROS_TOKEN` only: a token from Account > +Automation tokens in the app (`wos_pat_...`). Read it without echoing it +(`read -rs WALKEROS_TOKEN; export WALKEROS_TOKEN`), never inline; in CI set it +from the secret store; set it only for the commands that call the app. Without +it, `walkeros api` exits 3 with +`no token: set WALKEROS_TOKEN to a token from Account > Automation tokens in the walkerOS app`. +Sign-in fields an older CLI left in `~/.config/walkeros/config.json` are no +longer read and can be deleted. + +| Subcommand | Purpose | +| ---------- | ----------------------------- | +| `whoami` | Show who the token belongs to | --- diff --git a/skills/walkeros-using-cli/server-deployment.md b/skills/walkeros-using-cli/server-deployment.md index 8abf9a585..ca8053c72 100644 --- a/skills/walkeros-using-cli/server-deployment.md +++ b/skills/walkeros-using-cli/server-deployment.md @@ -7,8 +7,8 @@ collection flow. - walkerOS CLI installed: `npm install -g @walkeros/cli` - A walkerOS project (create at app.walkeros.io or via API) -- Authentication: `walkeros auth login`, or `WALKEROS_TOKEN` set to an - automation token (`wos_pat_...`) from Account, Automation tokens +- Authentication: `WALKEROS_TOKEN` set to a token (`wos_pat_...`) from Account > + Automation tokens - `WALKEROS_PROJECT_ID` set, or `--project` on every `walkeros api` call ## 1. Create the Flow Config @@ -143,13 +143,13 @@ an ESM `flow.mjs` plus a `node_modules/` traced by `@vercel/nft`, all inside ## Troubleshooting -| Symptom | Cause | Fix | -| ----------------------------- | ---------------------------------------- | ------------------------------------------------------------ | -| Health check fails | Runner health server not starting | Check `PORT` env var is set and not conflicting | -| Container starts then crashes | No HTTP source configured | Add `@walkeros/server-source-express` as source | -| Bundle is tiny (<1KB) | Destinations nested inside `server` key | Move sources/destinations to flow level, alongside `server` | -| Port mismatch | Config port doesn't match container PORT | Ensure config has `port: 8080` — runtime overrides it | -| 403 on startup | Deployer token lacks permissions | Check deployer role on flow GET and runners heartbeat routes | +| Symptom | Cause | Fix | +| ----------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Health check fails | Runner health server not starting | Check `PORT` env var is set and not conflicting | +| Container starts then crashes | No HTTP source configured | Add `@walkeros/server-source-express` as source | +| Bundle is tiny (<1KB) | Destinations nested inside `server` key | Move sources/destinations to flow level, alongside `server` | +| Port mismatch | Config port doesn't match container PORT | Ensure config has `port: 8080` — runtime overrides it | +| 403 on startup | The runtime was started with a person's token, or its deploy token was regenerated or revoked | Use the current deploy token from the flow's self-hosted settings; runner routes accept runner tokens only | ## Related diff --git a/website/docs/apps/cli.mdx b/website/docs/apps/cli.mdx index 261cdc36c..526623089 100644 --- a/website/docs/apps/cli.mdx +++ b/website/docs/apps/cli.mdx @@ -44,7 +44,7 @@ npx walkeros --version`} | `setup` | Run the optional `setup()` lifecycle on one component | Provision external resources (BigQuery datasets, Pub/Sub topics, SQLite tables, webhook registrations) | | `validate` | Validate events, flows, mappings, contracts, or entries | Check configuration before bundling | | `cache` | Manage CLI package and build caches | Clear stale caches, view cache statistics | -| `auth` | Authentication and identity | Log in, log out, check identity, print the access token | +| `auth` | Authentication and identity | Check who the token in `WALKEROS_TOKEN` belongs to | | `api` | Send an authenticated request to the walkerOS app API, or list its operations | Manage projects, flows and deployments from scripts and CI | | `diagnostics` | Show the CLI version, the app URL and whether the app answers | See which app you are on and whether it is reachable | @@ -1530,62 +1530,26 @@ The entry validator: 3. Loads the package's settings schema at the pinned version (from the CDN, or from disk for a local `path`) 4. Checks the resolved `config.settings` against it, following the placeholder rule; `--path` runs only this check (`details.scope.checks` is `["entry:settings"]`) -## Auth commands +## Authentication -The `auth` command group manages your sign-in to the walkerOS app, which `walkeros api` uses. - -### Login - -Log in to walkerOS through the RFC 8628 device authorization grant. The CLI asks the app for a one-time code, opens your browser on the approval page, and polls until you approve. No password reaches the CLI and no local callback server is opened, so this works the same over SSH, inside Docker and in a cloud IDE. +The CLI authenticates to the walkerOS app with one credential: the `WALKEROS_TOKEN` environment variable. Create a token in the app under Account > Automation tokens. -Output: -``` -! Your one-time code: ABCD-1234 - Authorize here: https://app.walkeros.io/oauth/device?user_code=ABCD-1234 - - Opening browser... - Waiting for authorization... (press Ctrl+C to cancel) - -✓ Logged in as user@example.com - Session stored in ~/.config/walkeros/config.json -``` - -The session is stored in `~/.config/walkeros/config.json` with `0600` permissions: a short-lived access token, its expiry, and a refresh token the CLI rotates on its own, so a login lasts until you log out. Several walkerOS processes can share one config file, so a refresh runs under a lock beside it (`config.json.lock`) and exactly one of them spends the single-use refresh token. - -The connection appears in the app under Account, Connected apps, where disconnecting it stops the CLI on its next call. - -For a machine with nobody to approve anything, set `WALKEROS_TOKEN` to an automation token instead (Account, then Automation tokens). It is used as-is and never refreshed. - -| Option | Description | -|--------|-------------| -| `--url ` | Custom app URL (default: `https://app.walkeros.io`) | -| `--json` | Output as JSON | -| `-v, --verbose` | Verbose output | -| `-s, --silent` | Suppress output | - -### Logout - -Revoke the stored refresh token, then remove the credentials from disk. Revocation runs first, because deleting the file alone would leave a credential alive on the server that nothing can reach to retire; it is best effort, so a logout with no network still clears the machine. - - +- In a shell, read the token without echoing it, as above. Never type it inline: an inline value lands in your shell history. +- In CI, set `WALKEROS_TOKEN` to your automation token from the secret store. +- Set it only for the commands that call the app (`walkeros api`, `walkeros auth whoami`). `push` and `setup` run step package code in the same process. +- Without a token, a command that calls the app stops with `no token: set WALKEROS_TOKEN to a token from Account > Automation tokens in the walkerOS app`. -| Option | Description | -|--------|-------------| -| `--json` | Output as JSON | -| `-v, --verbose` | Verbose output | -| `-s, --silent` | Suppress output | +An older CLI kept a sign-in in `~/.config/walkeros/config.json` (`token`, `accessToken`, `accessTokenExpiresAt`, `refreshToken`, `email`). These fields are no longer read and can be deleted. ### Whoami -Show the current authenticated user's identity, including email, user ID, and project ID (if the token is project-scoped). +Show who the token belongs to: email and user ID. - -It exits 3 when there is no credential, and 4 when a refresh cannot reach the app. - -### Token resolution - -The CLI resolves a credential in this order, first match wins: - -1. `WALKEROS_TOKEN` environment variable. Used as-is, never refreshed. -2. A static `token` in the config file, written by an older CLI. Honored until it expires, with a one-line notice pointing at `walkeros auth login`. -3. The OAuth session in the config file, written by `auth login`. Refreshed automatically when the access token is close to expiry. -4. Nothing: `walkeros api` exits 3 and asks for `walkeros auth login` or `WALKEROS_TOKEN`. - -A refresh that cannot reach the app is reported as such and leaves the saved session in place, so the next command tries again rather than sending you back to the browser. - ## API command `walkeros api` sends one authenticated request to the walkerOS app API and prints the answer. It reaches every operation of the app's public API (projects, flows, deployments, previews, secrets, Observe sessions and more) without a command of its own for each: you name the method and the path. The app validates every request; the CLI never does. @@ -1642,7 +1583,7 @@ A refresh that cannot reach the app is reported as such and leaves the saved ses - Query parameters ride in the path: `'/api/projects/{projectId}/flows?limit=10'`. - `{projectId}` is filled from `--project`, else from `WALKEROS_PROJECT_ID`. Any other `{...}` left in the path is a usage error. - `DELETE` needs `--yes`, because a command run through a shell skips an agent host's per-tool approval. -- The request is sent with the credential from [Token resolution](#token-resolution). +- The request is sent with the token from `WALKEROS_TOKEN` (see [Authentication](#authentication)). | Option | Description | |--------|-------------| @@ -1663,7 +1604,7 @@ A refresh that cannot reach the app is reported as such and leaves the saved ses ### List operations -`walkeros api ls [filter] [--json]` lists the public operations from the app's live `/api/openapi.json`, one line per operation with its method, path and summary. The filter matches method, path and summary, ignoring case. `--json` prints each operation with its parameters and the schemas it references, ready for a script or an agent to build a request from. Listing needs no sign-in; it exits 0 (also when nothing matches), 1 when the app answers with an error or with no OpenAPI document, and 4 when the app cannot be reached. +`walkeros api ls [filter] [--json]` lists the public operations from the app's live `/api/openapi.json`, one line per operation with its method, path and summary. The filter matches method, path and summary, ignoring case. `--json` prints each operation with its parameters and the schemas it references, ready for a script or an agent to build a request from. Listing needs no token; it exits 0 (also when nothing matches), 1 when the app answers with an error or with no OpenAPI document, and 4 when the app cannot be reached. ### Examples @@ -1991,7 +1932,7 @@ These options work with all commands: | Variable | Purpose | |----------|---------| -| `WALKEROS_TOKEN` | Access token for `walkeros api` (an automation or service-account token, `wos_pat_...`). Overrides the `auth login` session | +| `WALKEROS_TOKEN` | The token for `walkeros api` and `walkeros auth whoami` (an automation token, `wos_pat_...`) | | `WALKEROS_PROJECT_ID` | Fills `{projectId}` in a `walkeros api` path | | `WALKEROS_APP_URL` | Base URL override (default: `https://app.walkeros.io`) | | `WALKEROS_OBSERVE_LEVEL` | Baseline telemetry level for `runneros start` (`off`, `standard`, or `trace`). Invalid values are warned about and ignored. | @@ -2004,7 +1945,7 @@ The runtime reads its own variables, see the [runtime environment variables](/do `walkeros diagnostics` reports the CLI version, the app URL and where it comes from (`env`, `config` or `default`), and whether the app answers its public -health route. It sends no credential, so it works logged out, and it exits 0 +health route. It sends no credential, so it works without a token, and it exits 0 whether or not the app answers. /dev/null && echo signed in - -# Check current identity + code={`# Check who the token belongs to walkeros auth whoami -# Re-authenticate -walkeros auth login - -# Or use an automation token from Account, Automation tokens -export WALKEROS_TOKEN=wos_pat_xxx`} +# Set a token from Account > Automation tokens, without echoing it +read -rs WALKEROS_TOKEN; export WALKEROS_TOKEN`} language="bash" /> diff --git a/website/docs/apps/runtime.mdx b/website/docs/apps/runtime.mdx index c39e8020e..259ad6463 100644 --- a/website/docs/apps/runtime.mdx +++ b/website/docs/apps/runtime.mdx @@ -176,7 +176,7 @@ fails to import with a syntax error. Either way nothing is bundled. | `WALKEROS_FLOW_RELEASE` | none | Release the loaded flow must carry, or the runtime refuses to start. The walkerOS app sets it when it deploys a flow | | `WALKEROS_FLOW_NAME` | none | Flow name the loaded flow must carry, checked only together with `WALKEROS_FLOW_RELEASE`. The walkerOS app sets it | | `WALKEROS_DEPLOY_TOKEN` | none | Runner token. Takes precedence over `WALKEROS_TOKEN` | -| `WALKEROS_TOKEN` | none | Runner token (`wos_run_...`) or automation token (`wos_pat_...`) | +| `WALKEROS_TOKEN` | none | Runner token (`wos_run_...`) from the flow's self-hosted settings in the app. A person's automation token is refused by the runner routes | | `WALKEROS_APP_URL` | `https://app.walkeros.io` | App API base URL | | `WALKEROS_HEARTBEAT_INTERVAL` | `60` | Seconds between heartbeats. Values below `10`, or not a number, are clamped to `10` | | `WALKEROS_CACHE_DIR` | `$XDG_CACHE_HOME/walkeros`, else `~/.cache/walkeros` | Where the runtime persists recent errors between restarts. `CACHE_DIR` is read as a fallback | diff --git a/website/docs/collector/commands.mdx b/website/docs/collector/commands.mdx index 90112c371..3d28c853c 100644 --- a/website/docs/collector/commands.mdx +++ b/website/docs/collector/commands.mdx @@ -8,9 +8,13 @@ package: '@walkeros/collector' # Collector commands -The collector provides a core event processing engine that manages destinations, -consent, user data, and custom properties. Commands are executed through the -`elb` function, which the collector also exposes as `collector.elb`. +Walker commands manage the collector's destinations, consent, user data and +custom properties. Commands are executed through the +collector's `elb` function, `collector.elb`. On a web page the browser source's +`elb` passes them on as well. The `elb` that `startFlow` returns is the +[primary source's](/docs/sources#primary-source) push: it handles commands +when that source does, as the browser source does, and it is `collector.elb` +when the flow has no sources. Unknown walker commands log a warning and return `{ ok: false }`. Commands like `walker init` are browser-specific and handled by the browser source before @@ -50,8 +54,9 @@ const { collector, elb } = await startFlow({ :::tip -For dynamic scenarios requiring runtime destination addition, use -`elb('walker destination')` command or `collector.addDestination()`. See destination-specific documentation for +To add a destination at runtime, use the `walker destination` command, +`elb('walker destination', { code: destinationGtag, config: {} })`, or +`collector.addDestination()`. See destination-specific documentation for configuration options. ::: @@ -113,8 +118,9 @@ The `context` object has the same shape for every action: ### run -With each `run`, the on-event will be called. Use `context.collector` to access -the running instance. +With each `run`, the on-event will be called. A handler registered after the +collector started is also called once right away. Use `context.collector` to +access the running instance. + console.log('run with', { instance: context.collector }), +}); // Output: run with { instance: { ... } } elb('walker run'); // Output: run with { instance: { ... } }`} @@ -186,7 +197,7 @@ User IDs are added to each event. + +## run {#run-command} + +Start the collector, or start a new run. `startFlow` sends the first run +unless it is called with `run: false`. A run accepts state to set first, any +of `consent`, `user`, `globals` and `custom`; its `globals` replace the current +globals on top of `globalsStatic`. + + + +Each run discards the events that wait in a destination's consent queue and the +collector's replay buffer, delivers events pushed before the first run (see +[Pre-run events](#pre-run-events)) and calls the `run` handlers. With +`history` on, the browser source starts a run on every route change +([browser source commands](/docs/sources/web/browser/commands#run)). + ## State delivery State commands (`consent`, `user`, `globals`, `custom`) set values on the @@ -268,8 +310,10 @@ This gives five practical guarantees: `require` only delays when its first `on` delivery lands. A dependent source reacts to state correctly whether or not it declares `require`. * **Before the first push.** A destination's `on` handler receives the current - state before any event is delivered to it. The collector waits for an async - handler to finish, up to the destination's `config.timeout`, before it + state before any event is delivered to it. This needs an `init` method on the + destination: without one, its `on` handler is never called, while the state + command still reports the destination under `done`. The collector waits for + an async handler to finish, up to the destination's `config.timeout`, before it delivers events. If the handler does not finish in that time, or fails, the destination's events stay in its queue instead of going out without the state, until a later delivery of the state finishes. Those held events are @@ -311,20 +355,23 @@ await elb('walker run'); // 'page view' is delivered now`} enrichment are applied when the event is replayed, not when it is held. * **Exactly-once.** A second `run` does not re-deliver events an earlier run already replayed. -* **Bounded.** The buffer holds at most `queueMax` events (default `1000`, - shared with the destination replay buffer) and drops the oldest entries on - overflow. Each hold and drop is logged at `debug` level. +* **Bounded.** The buffer holds at most `queueMax` events (default `1000`, the + same setting that caps the collector's replay buffer) and drops the oldest + entries on overflow. A drop is counted on + `collector.status.dropped['collector.preRun'].queue` and logged once per + overflow at `warn` level; each hold is logged at `debug` level. Commands are unaffected: they continue to apply immediately, as described above. ## hook -Hooks customize the default behavior of the collector. Available hooks include -`Push`, `DestinationInit`, `DestinationPush`, `StoreGet`, `StoreSet`, and -`StoreDelete`. Hooks allow for validation, manipulation, or cancellation of -default behavior. +Hooks run before and after collector operations, such as a push, a command or +a destination's push, and can validate, change or cancel them. Available hooks +include `Push`, +`DestinationInit`, `DestinationPush`, `StoreGet`, `StoreSet`, and +`StoreDelete`. -Add hooks to the collector to customize or enhance default processing. +Add a hook with the `hook` command: ', fn: hookFn });`} @@ -333,14 +380,17 @@ Add hooks to the collector to customize or enhance default processing. ### Moments -The overall function execution order is as follows: +A pre-hook runs before its function and a post-hook right after the function +returns. When the function is async, the post-hook runs before its work has +finished and `params.result` is the pending promise: await it to read the +outcome. For one event, the moments run in this order: 1. prePush -2. preDestinationInit -3. postDestinationInit -4. preDestinationPush or preDestinationPushBatch -5. postDestinationPush or postDestinationPushBatch -6. postPush +2. postPush (the push is still running) +3. preDestinationInit +4. postDestinationInit +5. preDestinationPush or preDestinationPushBatch +6. postDestinationPush or postDestinationPushBatch Others are: @@ -353,8 +403,8 @@ Others are: ### Function signatures -In general, `params` will be prefixed as a parameter, containing `fn` which is -the original function and `result` for the post-hooks. A post-hook's return +Every hook receives `params` as its first argument: `params.fn` is the +original function, and post-hooks also get `params.result`. A post-hook's return value replaces the result, so return `params.result` to keep it. Use the following function signatures: @@ -363,9 +413,10 @@ following function signatures: function prePush(params, event, options) { return params.fn(event, options); } -function postPush(params, event, options) { - console.log('default return result', params.result); - return params.result; +async function postPush(params, event, options) { + const result = await params.result; + console.log('default return result', result); + return result; } // DestinationInit @@ -437,9 +488,10 @@ const { collector } = await startFlow({ // Add hooks via command elb('walker hook', { name: 'postPush', - fn: function (params, ...args) { + fn: async function (params, ...args) { + const result = await params.result; console.log('walker exec time', Date.now() - window.elbTimer); - return params.result; + return result; }, }); @@ -449,3 +501,21 @@ elb({ name: 'page view' }); // walker exec time 1`} language="javascript" /> + +## shutdown + +Stop the collector: sources are destroyed first, then pending batches are +flushed (up to 5 seconds each) and destinations, transformers and stores are +destroyed, in that order. A second `walker shutdown` does nothing. The runner +sends it on `SIGTERM` and `SIGINT`; in the browser nothing sends it. Outside the +runner, send it with `collector.command` and await it before the process exits, +for example from a signal handler. Node's `exit` event cannot wait for the +flush, so pending batches sent from there are lost: + + { + await collector.command('shutdown'); + process.exit(0); +});`} + language="javascript" +/> diff --git a/website/docs/collector/index.mdx b/website/docs/collector/index.mdx index 8d6042b62..2b13bfeee 100644 --- a/website/docs/collector/index.mdx +++ b/website/docs/collector/index.mdx @@ -12,28 +12,16 @@ export const collectorSchema = schemas.CollectorSchemas.initConfigJsonSchema; # Collector -The collector is the central **processing engine** of walkerOS that receives -events from sources, **enriches** them with additional data, applies consent -rules, and **routes** them to destinations. It acts as the **intelligent -middleware** between event capture and event delivery. +The collector is the processing engine of walkerOS: it receives events from +sources, enriches them with additional data, applies consent rules and routes +them to destinations. #### What it does -The Collector transforms raw events into enriched, compliant data streams by: - -* **Event processing** - Validates, normalizes, and enriches incoming events -* **Consent management** - Applies privacy rules and user consent preferences -* **Data enrichment** - Adds session data, user context, and custom properties -* **Destination routing** - Sends processed events to configured analytics - platforms - -#### Key features - -* **Compatibility** - Works in both web browsers and server environments -* **Privacy-first** - Built-in consent management and data protection -* **Event validation** - Ensures data quality and consistency -* **Flexible routing** - Send events to multiple destinations simultaneously -* **Delivery status** - Built-in per-source and per-destination delivery tracking +The collector validates and normalizes incoming events and applies privacy +rules and the user's consent preferences. It adds its globals, user and consent +state to every event, then sends the processed events to the configured +destinations. #### Role in architecture @@ -126,7 +114,7 @@ const { collector, elb } = await startFlow({ }, }, logger: { - level: 'DEBUG', // Show all logs (ERROR, INFO, DEBUG) + level: 'DEBUG', // Show all logs (ERROR, WARN, INFO, DEBUG) }, });`} language="typescript" @@ -134,7 +122,7 @@ const { collector, elb } = await startFlow({ ## Event transformation -The collector works with **mapping** to transform events as they flow through the system. Mapping is configured at the destination level and controls how walkerOS events are converted to vendor-specific formats. +The collector uses mapping to transform events as they flow through the system. Sources and destinations each have their own mapping; a destination's mapping controls how walkerOS events are converted to vendor-specific formats. -[**Learn more about mapping →**](/docs/mapping) +[Learn more about mapping](/docs/mapping) ## Collector chain (`next`) @@ -229,8 +217,7 @@ completed event. What the chain does reaches every destination: ## Status -The collector tracks delivery metrics on `collector.status`, giving you real-time -visibility into event flow without external monitoring: +The collector tracks delivery metrics on `collector.status`: .rejected`. That keeps `failed` a @@ -291,7 +279,7 @@ measure of walkerOS faults rather than of whatever the public internet posts at a collector endpoint. User-supplied callback throws are visibility-only: they log at error -level but do NOT increment `status.failed`. This keeps the counter a +level but do not increment `status.failed`. This keeps the counter a clean pipeline-health signal. Sites in this group: * `on` subscriptions (`destination.on`, `source.on`, consent rules, @@ -301,12 +289,12 @@ clean pipeline-health signal. Sites in this group: In both cases the collector logs at error level: ').error('Push failed', { error, event }); -// collector-side boundary failure -logger.error('push failed', { event, ingest, error }); -logger.error('command failed', { command, data, error }); +// collector-side boundary failure (event: the event name) +logger.error('push failed', { error, event }); +logger.error('command failed', { error, command }); // pipeline-internal failures (counted) logger.error('mapping processing failed', { event, error }); @@ -320,7 +308,8 @@ logger .error('transformer init failed', { transformer, error }); logger.scope('').error('destination init failed', { error }); -// user-callback failures (logged, NOT counted) +// user-callback failures (logged, NOT counted; event: the whole event) +logger.error('mapping rule condition failed', { event, mappingKey, error }); logger.error('mapping condition failed', { event, error }); logger.error('mapping fn failed', { event, error }); logger.error('mapping validate failed', { event, error }); @@ -329,31 +318,42 @@ logger.scope('on').error('on callback failed', { kind, error });`} /> A source whose `init()` throws stays with `config.init === false` -instead of being marked initialized. Operators reading -`source.config.init` see the source visibly stuck, not falsely healthy. +instead of being marked initialized, so `source.config.init` shows that the +source did not start. Alarm on the ratio of `status.failed` to `status.in`. The log message text plus structured fields are enough to filter destination vs boundary failures downstream. -**PII note.** Boundary error logs include the full failing event payload so -operators can reproduce. If your event payloads carry sensitive data, -configure redaction at the logger layer; do not parse and rewrite at every -call site. +**Personal data in logs.** The push, command and destination failure logs +carry the event name and the error message, never the event's data. The +mapping logs (`mapping processing failed` and the callback failures) carry the +whole event, including `user` and `data`. If your events carry personal data, +redact it in a custom logger `handler`; do not parse and rewrite at every call +site. ### Fatal errors Throw `FatalError` (exported from `@walkeros/core`) for invariant violations or operator-initiated aborts that must crash the host process. Standard `Error` is absorbed by the boundary catch, logged, and counted. -`FatalError` bypasses the catch and propagates, so a supervisor (CLI -runner, Express server, container orchestrator) can terminate cleanly. +`FatalError` bypasses the boundary catch of `collector.push` and +`collector.command` and propagates, so a supervisor (CLI runner, Express +server, container orchestrator) can terminate cleanly. It propagates when +thrown in a source (its factory or `init`), in a transformer's `init` or in an +`on` handler. Thrown in a destination's `init` or `push`, or in a +transformer's `push`, it is handled like any other error thrown there and does +not propagate. Thrown in a mapping callback of a destination, it does not +propagate either, but it fails that destination's push and the event goes to +its dead-letter queue. An ordinary error there drops only the value, or, in a +rule `condition`, makes that rule not match. @@ -382,14 +382,16 @@ metaDrops?.dlq ?? 0; // monotonic count of evicted DLQ entries`} ## Buffer bounds -The collector keeps three internal buffers per process. Each is size-bounded +The collector keeps four internal buffers per process. Each is size-bounded with a configurable cap; on overflow the oldest entries are evicted (FIFO), -the corresponding `dropped` counter is incremented, and a warning is logged -once per overflow window. +the corresponding `dropped` counter is incremented, and a warning is logged. +The collector buffers and the DLQ warn once per overflow window; the consent +queue warns on every eviction. | Buffer | Purpose | Default cap | Config | | ----------------------- | ---------------------------------------------------- | ----------- | ----------------------------------- | | `collector.queue` | Replay buffer for late-registered destinations | 1000 | `Collector.Config.queueMax` | +| `collector.preRunQueue` | Events pushed before the collector runs | 1000 | `Collector.Config.queueMax` | | `destination.queuePush` | Per-destination consent-denied buffer | 1000 | `Destination.Config.queueMax` | | `destination.dlq` | Per-destination dead-letter queue of failed pushes | 100 | `Destination.Config.dlqMax` | @@ -425,6 +427,9 @@ key with `stepId()` from `@walkeros/core`): const collectorDrops = collector.status.dropped[stepId('collector')]?.queue ?? 0; +// Events dropped before the collector ran +const preRunDrops = collector.status.dropped['collector.preRun']?.queue ?? 0; + // Destination drops by buffer: // - queue: consent-denied events evicted from the destination's queuePush // - dlq: failed-push entries evicted from the destination's DLQ diff --git a/website/docs/destinations/server/customerio.mdx b/website/docs/destinations/server/customerio.mdx index 171aa34a8..0bc2915d7 100644 --- a/website/docs/destinations/server/customerio.mdx +++ b/website/docs/destinations/server/customerio.mdx @@ -98,7 +98,7 @@ Per-rule mapping settings control which lifecycle methods are called in addition | `deleteDevice` | `deleteDevice()` | Remove push token | | `merge` | `mergeCustomers()` | Consolidate duplicate profiles | -Use `skip: true` on the rule to fire only the lifecycle call without tracking a second `track()` event. +Use `silent: true` on the rule to fire only the lifecycle call without tracking a second `track()` event. ## Transactional messaging diff --git a/website/docs/getting-started/deploy.mdx b/website/docs/getting-started/deploy.mdx index c8a9c6dc4..fcac4711a 100644 --- a/website/docs/getting-started/deploy.mdx +++ b/website/docs/getting-started/deploy.mdx @@ -75,8 +75,6 @@ flow, bundle again and redeploy the new artifact. - Wire consent before real traffic, see the [consent guide](/docs/guides/consent). - Keep `flow.json` in git: deploys stay reviewable and reversible. -## Managed hosting - ## Next steps - [Runtime](/docs/apps/runtime): the image, inputs, environment variables, constraints diff --git a/website/docs/getting-started/flow/routing.mdx b/website/docs/getting-started/flow/routing.mdx index 354afa624..5d9ce40b3 100644 --- a/website/docs/getting-started/flow/routing.mdx +++ b/website/docs/getting-started/flow/routing.mdx @@ -13,7 +13,7 @@ the tested example flow, so each one is a real part of a working setup. ## Connection rules -Sources, transformers, collectors, and destinations connect in specific ways. This table summarizes every valid connection: +This table lists every valid connection between sources, transformers, the collector and destinations: | From | To | Field | Notes | |------|----|-------|-------| @@ -70,7 +70,8 @@ runs `dedup` before the collector: The collector chain runs once per event, for every destination: `fingerprint` -and `bot` always, `enrich` for everything but impressions. +and `bot` always, `enrich` for everything but events from the `impression` and +`visible` triggers. @@ -99,7 +100,7 @@ Every chain field (`source.before`, `source.next`, `transformer.before`, `transf type Route = string | Route[] | RouteConfig; ``` -A `string` names a transformer (or a path, see below). A `Route[]` runs each entry in order as a pipeline. A `RouteConfig` is a disjoint union: it sets **exactly one** of `next`, `one`, `many`, `stop`, or none of them (pure gate that only filters by `match`). `match` is optional on `next`, `one`, `many` and `stop`; omit it to always match. A pure gate requires `match`. +A `string` names a transformer (or a path, see below). A `Route[]` runs each entry in order as a pipeline. A `RouteConfig` is a disjoint union: it sets **exactly one** of `next`, `one`, `many`, `stop`, or none of them (a bare gate, `{ "match": ... }` alone). `match` is optional on `next`, `one`, `many` and `stop`; omit it to always match. A bare gate requires `match` and selects nothing, whether its match passes or fails: on its own it neither runs a step nor drops the event. Only `stop` drops. As a `one` entry, a matching bare gate ends the dispatch without running anything. At every position, `match` reads the same root: `{ ingest, event }`. Use `ingest.*` for request context and `event.*` for the event as it is at that point (at `source.before`, the source's raw output). Each route is evaluated only when the event reaches it. @@ -213,7 +214,7 @@ What a stop means depends on the position it is resolved in: |----------|-------------------------| | `source.before`, `source.next` | The event never reaches the collector. The push result is `{ ok: true, dropped: true }` | | `transformer.before`, `transformer.next`, a transformer result `{ next }` | The running copy ends; the position that started the chain applies its own meaning | -| `collector.next` | No destination receives the event | +| `collector.next` | No destination receives the event. The push result is `{ ok: true, dropped: true }` | | `destination.before` | This destination skips the event; other destinations are unaffected | | `destination.next` | The post-push chain ends; delivery already happened | @@ -232,8 +233,8 @@ Drop product impressions everywhere: Drop events for some vendors only: in the complete example, Meta, Piwik PRO and Data Manager name `eventFilter` as their `before`. Its own `next` is a `stop` -with an `or` match: impressions, likely bots and events that failed the -contract. Pub/Sub does not name it, so the warehouse keeps every event. +with an `or` match: events from the `impression` and `visible` triggers, likely +bots and events that failed the contract. Pub/Sub does not name it, so the warehouse keeps every event. diff --git a/website/docs/getting-started/observe.mdx b/website/docs/getting-started/observe.mdx index cb454ca18..6dea25415 100644 --- a/website/docs/getting-started/observe.mdx +++ b/website/docs/getting-started/observe.mdx @@ -245,8 +245,6 @@ Capture works whenever the destination reaches its callable through the env. Ser The batched poster stamps each record with a monotonic `seq` before sending. A dropped or rejected POST is never re-sequenced, so a gap in `seq` at the observer makes lost telemetry visible instead of silent. The poster also splits a batch by real UTF-8 byte size (`maxBodyBytes`, default 60000) rather than letting an oversized body fail with a 413. -## Starting an Observe session - ## Next steps - [Deploy](/docs/getting-started/deploy): take the flow to production first diff --git a/website/docs/guides/reference-syntax.mdx b/website/docs/guides/reference-syntax.mdx index 34ce8fd05..30e68d8a8 100644 --- a/website/docs/guides/reference-syntax.mdx +++ b/website/docs/guides/reference-syntax.mdx @@ -307,6 +307,10 @@ sends to the server flow's URL: +A flow name in a reference starts with a letter or `_`, followed by letters, +digits, `_` or `-`, so `$flow.tracking-server.url` reads the `url` of the flow +`tracking-server`. The first `.` after the name starts the path. + See [Cross-flow references](/docs/getting-started/flow#cross-flow-references-with-flow) for the rules. @@ -351,9 +355,10 @@ flow, a step or a step example are not checked. - [`@walkeros/core` reference exports](https://github.com/elbwalker/walkerOS/blob/main/packages/core/src/references.ts): `REF_VAR_FULL`, `REF_VAR_INLINE`, `REF_ENV`, `REF_CONTRACT`, `REF_FLOW`, - `REF_STORE`, `REF_SECRET`, `REF_CODE_PREFIX`. The single source of truth for - the regex patterns. Also exports `scanFlowRefs(value)` which walks any value - (string, array, object) and returns every `$flow.` reference found, - including refs nested inside `$code:` snippets. + `REF_FLOW_INLINE`, `REF_FLOW_NAME`, `REF_STORE`, `REF_SECRET`, + `REF_CODE_PREFIX`. The single source of truth for the regex patterns. Also + exports `scanFlowRefs(value)` which walks any value (string, array, object) + and returns every `$flow.` reference found, including refs nested + inside `$code:` snippets. - [Stores guide](../stores/index.mdx): wiring stores via `$store.`. - [Mapping guide](../mapping/index.mdx): `$code:` and mapping-value shortcuts. diff --git a/website/docs/guides/session.mdx b/website/docs/guides/session.mdx index 251cceef2..336ccf38e 100644 --- a/website/docs/guides/session.mdx +++ b/website/docs/guides/session.mdx @@ -9,8 +9,8 @@ sidebar_position: 4 import PackageLink from '@site/src/components/docs/package-link'; There are multiple ways to define and measure a session. Sessions represent a diff --git a/website/docs/mapping/index.mdx b/website/docs/mapping/index.mdx index c2b41be29..b259266be 100644 --- a/website/docs/mapping/index.mdx +++ b/website/docs/mapping/index.mdx @@ -44,9 +44,9 @@ Everything in walkerOS mapping is built from two building blocks: config field accepts a value (`data`, `map.*`, `policy.*`, `set[]`, …), you write a `Mapping.Value`. There are eight forms (path, constant, map, loop, set, fn, condition, consent). -- **[Mapping.Rule](./rule.mdx)**: the object on a destination's +- **[Mapping.Rule](./rule.mdx)**: the object on a source's or destination's `mapping[entity][action]`. It composes values into a complete rule - (`name`, `data`, `condition`, `consent`, `policy`, `batch`, `ignore`, `skip`, + (`name`, `data`, `condition`, `policy`, `batch`, `ignore`, `silent`, `settings`). When a destination doc shows `data: any`, the value you put there is a @@ -148,7 +148,9 @@ the collector: /> Both mappings are independent. The same event can be transformed differently at -each stage. +each stage. An event that matches no destination rule still reaches the +destination, under its own name; see +[how destinations consume a rule](./rule.mdx#how-destinations-consume-a-rule). ## See also diff --git a/website/docs/mapping/rule.mdx b/website/docs/mapping/rule.mdx index fd54dfce7..fc5cf6fab 100644 --- a/website/docs/mapping/rule.mdx +++ b/website/docs/mapping/rule.mdx @@ -37,14 +37,29 @@ that take values) accepts any form documented in ## How destinations consume a rule -When an event reaches a destination, the collector looks up -`mapping[event.entity][event.action]`. An array means "try each in order, first -matching rule wins". A wildcard `'*'` on either entity or action acts as a -fallback. - -:::info Mapping wildcards differ from contract wildcards -Mapping wildcards are **first-match**: once `entity.action` (or its `entity.*` / `*.action` / `*.*` fallback) hits, no other rule is consulted. [Contract wildcards](/docs/getting-started/flow/contract#wildcard-inheritance) are **additive**: every matching level merges in. The two systems intentionally pick different defaults: mapping selects a single transformation target, contract accumulates requirements. -::: +When an event reaches a destination, the collector picks one rule from +`mapping[entity][action]`, where entity and action are the two words of the +event name. An array means "try each in order, first matching rule wins". A +`'*'` key on the entity or the action level is a wildcard. The lookup runs in +this order: + +1. If the mapping has a key for the event's entity, only that entity is + searched: the rules under the exact action when that action has a key, + otherwise the rules under the entity's `'*'` action. +2. If the mapping has no key for the entity, the `'*'` entity is searched the + same way: the rules under the exact action when that action has a key, + otherwise the rules under `'*'`. +3. If no rule was found (no key matched, or no condition passed), the `'*'` → + `'*'` rule is tried. + +So a `'*'` → `click` rule is used only for entities that have no key of their +own: with `product: { view: ... }` in the mapping, a `product click` skips it +and falls through to `'*'` → `'*'`. And when every rule under an exact action +fails its condition, the lookup goes straight to `'*'` → `'*'` and skips the +entity's own `'*'` rule. + +In this example the entity's own key wins: `product *` is used and `* click` +is never consulted. +An event that matches no rule is still sent. The destination's `push` receives +it under its own name with no rule (`context.rule` is undefined), and +`context.data` holds only what the destination-level `data` and `include` +produce. What the destination sends for an unmapped event depends on the +destination; its page says. No setting sends only mapped events. To get that, +ignore everything with a catch-all rule and map the events you want: + +```typescript +{ + '*': { '*': { ignore: true } }, + order: { complete: { name: 'purchase' } }, +} +``` + +With the catch-all in place, an event whose specific rules all fail their +condition is ignored too (step 3). + +:::info Mapping wildcards differ from contract wildcards +Mapping wildcards are **first-match**: the lookup above picks one rule and no other rule is consulted. [Contract wildcards](/docs/getting-started/flow/contract#wildcard-inheritance) are **additive**: every matching level merges in. The two systems intentionally pick different defaults: mapping selects a single transformation target, contract accumulates requirements. +::: + A rule's `condition` is checked while matching: the first rule whose `condition` passes is used; if none passes, the `*` → `*` rule applies, if there is one. A condition may be async: conditions are awaited one by one, in rule order. A condition that throws is logged and counts as no match. Once a rule matches, the collector runs `policy` (event-level), resolves `data`, then hands the result to the destination's `push` (or buffers it when -`batch` is set). A rule-level `consent` is not checked: gate a destination with -`config.consent`, or a single value with a `consent` entry. +`batch` is set and the destination batches). A rule-level `consent` is not +checked: gate a destination with `config.consent`, or a single value with a +`consent` entry. A `policy` entry that resolves to nothing removes the field. That is how a consent-gated entry redacts a value when consent is denied. ### Batch scheduling -`batch` accepts a number (debounce `wait` window in ms, legacy form) or an -object `{ wait?, size?, age? }` for finer control: +`batch` accepts a number (the debounce `wait` window in ms) or an object +`{ wait?, size?, age? }` for finer control: - `wait` (ms): debounce window. The timer resets on every push. - `size`: hard count cap. Flushes immediately at this many events. @@ -100,6 +137,16 @@ batching is enabled: `size: 1000`, `age: 30000`. `wait` is a hint, not a hard delay: when events keep arriving faster than `wait`, the `size` and `age` caps are what force the flush. +Batching works only on a destination that implements `pushBatch`; its page +says whether it does. On any other destination `batch` has no effect and every +event goes to `push` right away. Pending batches are flushed when the collector +shuts down ([`walker shutdown`](/docs/collector/commands#shutdown), which the +runner sends on exit), waiting up to 5 +seconds per batch. In the browser nothing sends `walker shutdown`, so events +still waiting in a batch when the page unloads are not sent. The +[`pushBatch` contract](/docs/destinations/create-your-own#the-pushbatch-function-optional) +covers what the destination receives. + ## Conditional rules Use an array with conditions to branch by event contents; the first matching @@ -136,17 +183,24 @@ await getMappingEvent( fn={logEvent} /> -## Ignore vs skip +## Ignore vs silent {#ignore-vs-skip} Two flags control destination behaviour and they are not interchangeable: -- `ignore: true`: the rule matched but **nothing happens**. No data transform, - no destination call, no side effects. Use for suppression. -- `skip: true`: the rule matched and the destination's `push()` **is** called. - `settings.identify`, `settings.revenue`, `settings.group`, and other side - effects still run. Only the destination's default forwarding call (e.g. - `track()`, `capture()`, `event()`) is suppressed. Use for "identify without - an event" style flows. +- `ignore: true`: the rule matched and the destination's `push()` is **not** + called, so nothing is sent and the rule's `name` and `data` are not applied. + What runs before the rule is applied still runs: the destination's `init` on + its first event, its `before` chain, the destination-level `policy` and + `data`, and the rule's `policy`. Use for suppression. The push result + still lists the destination under `done`, and its status counts the event. +- `silent: true`: the rule matched and the destination's `push()` **is** called + with the rule. On a destination that reads `silent`, `settings.identify`, + `settings.revenue`, `settings.group`, and other side effects still run and + only the default forwarding call (e.g. `track()`, `capture()`, `event()`) is + suppressed. Use for "identify without an event" style flows. A destination + that does not read `silent`, such as gtag, Meta or the API destinations, + sends the event as usual; the destination's page says whether it supports + it. If both are set on the same rule, `ignore` wins. @@ -181,19 +235,20 @@ await getMappingEvent( { user: { login: { - skip: true, + silent: true, settings: { identify: { map: { user: 'data.id' } } }, }, }, } ``` -The example runs the destination's `identify()` side effect on `user login` -events but skips the default forwarding call. +On a destination that reads `silent`, such as Amplitude, the example runs the +`identify()` side effect on `user login` events but skips the default +forwarding call. ## API reference -The two functions below power mapping resolution. Destinations and sources call +The two functions below resolve mappings. Destinations and sources call them through the collector, but they are exported for direct use in custom transformers or tests. @@ -203,13 +258,19 @@ transformers or tests. getMappingEvent( event: WalkerOS.PartialEvent, mapping?: Mapping.Rules, + collector?: Collector.Instance, ): Promise ``` Resolves the matching rule for an event based on `event.name` (the -`"entity action"` string). Returns `{ eventMapping, mappingKey }` where -`eventMapping` is the matched `Mapping.Rule` and `mappingKey` is the lookup key -that matched (including wildcards). +`"entity action"` string), in the lookup order above. Returns +`{ eventMapping, mappingKey }` where `eventMapping` is the matched +`Mapping.Rule` and `mappingKey` is the lookup key that matched (including +wildcards). When no rule matches, `eventMapping` is undefined and `mappingKey` +is an empty string. When there is no mapping, or the event name is a single +word with no action, the result is `{}`. Pass the `collector` when rules have conditions: it is +the conditions' context, and without it a condition that throws is not logged +but rejects the call. ### getMappingValue @@ -237,7 +298,10 @@ await getMappingValue(event, 'data.id', { collector }); ### Source mapping -Normalize events before they reach the collector: +Normalize events before they reach the collector. At a source a rule renames +the event (`name`), edits it (`policy`) or drops it (`ignore`); `data`, +`include`, `batch`, `settings` and `silent` have no effect there, because only +the edited event moves on. A dropped event's push result is `{ ok: true }`. { # Mapping.Value `Mapping.Value` is what you put into any mapping field that expects a value: -`data`, every entry under `map`, entries in `set[]`, keys under `policy`, and +`data`, every entry under `map`, entries in `set[]`, each `policy` entry, and anywhere a destination rule field accepts `any`. It is polymorphic: a value can be a simple string path, an object with one of several transforming keys, or an array of those, which is a fallback chain: the first entry that resolves wins. @@ -50,7 +50,7 @@ as `Value = ValueType | Array`, where `ValueType` is either a string | [Set](#set) | `{ set: [Value, Value, …] }` | Builds an array from several values, in order. Use when the destination expects a list. | | [Fn](#fn) | `{ fn: (value, context) => … }` | Runs a custom function on the event. Use only when no declarative form fits. | | [Condition](#condition) | `{ condition, value }` | Gates the value on a predicate. Use for branching logic inside a single rule. | -| [Consent](#consent) | `{ consent: {…}, value }` | Gates the value on consent states. Use to make individual fields privacy-aware. | +| [Consent](#consent) | `{ key, consent: {…} }` | Gates the value on consent states. Use to make individual fields privacy-aware. | A `ValueConfig` uses one producer plus optional filters. `key`, `fn`, `map`, `loop` and `set` each produce the value, so set exactly one. If you set several, @@ -219,7 +219,7 @@ shape. | `logger` | `Logger.Instance` | yes | Use for `info`/`warn`/`error`/`debug` | | `consent` | `WalkerOS.Consent` (optional) | no | Resolved consent at this evaluation point | -One-arg callbacks like `(value) => value.toUpperCase()` continue to work, +One-argument callbacks like `(value) => value.toUpperCase()` work too; TypeScript ignores the unused `context` argument. + # DataLayer source