From 1ea232153cedda55b885194791ea5aa4200bfc12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 18 Jun 2026 21:16:29 +0000 Subject: [PATCH 1/3] Add package invocation token management --- docs/contributing/package-invocation-api.md | 69 +- packages/worker/client/app.tsx | 7 + .../account-package-invocation-tokens.tsx | 954 ++++++++++++++++++ packages/worker/client/routes/account.tsx | 18 +- packages/worker/client/routes/index.tsx | 5 + ...unt-package-invocation-tokens.node.test.ts | 279 +++++ .../account-package-invocation-tokens.ts | 409 ++++++++ packages/worker/src/app/router.ts | 16 + packages/worker/src/app/routes.ts | 6 + .../worker/src/package-invocations/repo.ts | 85 ++ .../package-invocations/service.node.test.ts | 67 +- .../worker/src/package-invocations/service.ts | 34 +- 12 files changed, 1936 insertions(+), 13 deletions(-) create mode 100644 packages/worker/client/routes/account-package-invocation-tokens.tsx create mode 100644 packages/worker/src/app/handlers/account-package-invocation-tokens.node.test.ts create mode 100644 packages/worker/src/app/handlers/account-package-invocation-tokens.ts diff --git a/docs/contributing/package-invocation-api.md b/docs/contributing/package-invocation-api.md index e4104874e7..c181a6893c 100644 --- a/docs/contributing/package-invocation-api.md +++ b/docs/contributing/package-invocation-api.md @@ -33,8 +33,8 @@ Authentication uses a private bearer token stored in Kody's database-backed package invocation token table. Kody stores only the token hash for request-time lookup. The raw bearer token is -generated or collected by the token-management surface, shown to the operator -once, and then sent by the trusted external service as: +generated by a trusted client or operator, pasted into the account token +creation flow once, and then sent by the trusted external service as: ```http Authorization: Bearer @@ -59,6 +59,55 @@ The token is not a global backdoor: - tokens can be revoked without deploys - execution uses normal package runtime machinery +### Creating and revoking tokens + +Signed-in users manage package invocation tokens from: + +- `/account/package-invocation-tokens` +- `/account/package-invocation-tokens/new` + +The creation form accepts the raw token exactly once, hashes it server-side, and +stores only the hash. Token lists never return the raw token or token hash. + +The backing same-origin JSON endpoint is: + +- `GET /account/package-invocation-tokens.json` — list owned packages and token + metadata +- `POST /account/package-invocation-tokens.json` with `action: "create"` — + create a token from a raw token and allowlists +- `POST /account/package-invocation-tokens.json` with `action: "revoke"` — + revoke a token by id + +Create payload shape: + +```json +{ + "action": "create", + "name": "Personal Raycast", + "rawToken": "", + "packageKodyIds": ["*"], + "packageIds": [], + "exportNames": ["*"], + "sources": ["raycast"] +} +``` + +Concrete `packageIds` and `packageKodyIds` must refer to packages owned by the +signed-in user. Package and export allowlists support the deliberate wildcard +value `*`. Source values do not use wildcard matching: if a request includes +`source`, that exact value must be present in the token's `sources` list. + +The `/new` page supports metadata prefill query parameters for setup flows: + +- `name` +- `packageKodyId` / `packageKodyIds` / `kodyId` / `kodyIds` +- `packageId` / `packageIds` +- `exportName` / `exportNames` +- `source` / `sources` + +Repeat parameters or comma-separate values. Do not put the raw token in the URL; +paste it into the form or submit it over the authenticated same-origin JSON API. + ## Request body ```json @@ -202,6 +251,22 @@ curl --fail --silent \ }' ``` +## Personal Raycast client pattern + +Recommended flow for a personal Raycast extension: + +1. generate a high-entropy raw token locally +2. copy the raw token to the clipboard or keep it in Raycast's local secret + storage +3. open Kody at + `/account/package-invocation-tokens/new?name=Personal%20Raycast&packageKodyIds=*&exportNames=*&sources=raycast` +4. paste the raw token into the form and create the token +5. send invocation requests with `Authorization: Bearer ` and + `"source": "raycast"` + +The Raycast wildcard token can call any package/export owned by the signed-in +Kody user, but only for requests that identify themselves with source `raycast`. + ## Related - [Packages and manifests](./packages-and-manifests.md) diff --git a/packages/worker/client/app.tsx b/packages/worker/client/app.tsx index fb33db3571..a3f536c2e6 100644 --- a/packages/worker/client/app.tsx +++ b/packages/worker/client/app.tsx @@ -90,6 +90,7 @@ export function App(handle: Handle) { return () => { const isWideLayout = + currentPathname.startsWith('/account/package-invocation-tokens') || currentPathname.startsWith('/account/remote-connectors') || currentPathname.startsWith('/account/secrets') const sessionEmail = session?.email ?? '' @@ -182,6 +183,12 @@ export function App(handle: Handle) { Secrets + + Package tokens + Connectors diff --git a/packages/worker/client/routes/account-package-invocation-tokens.tsx b/packages/worker/client/routes/account-package-invocation-tokens.tsx new file mode 100644 index 0000000000..e5acf3f9fc --- /dev/null +++ b/packages/worker/client/routes/account-package-invocation-tokens.tsx @@ -0,0 +1,954 @@ +import { type Handle, css } from 'remix/ui' +import { on } from '#client/event-mixin.ts' +import { + type AccountStatus, + readJson, +} from '#client/routes/account-approval-shared.ts' +import { + colors, + mq, + radius, + spacing, + typography, +} from '#client/styles/tokens.ts' +import { + cardCss, + cardTitleCss, + descriptionCss, + fieldCss, + fieldLabelCss, + getDangerButtonCss, + getPrimaryButtonCss, + getSecondaryButtonCss, + inputCss, + textareaCss, +} from '#client/styles/style-primitives.ts' + +type PackageOption = { + id: string + kodyId: string + name: string +} + +type PackageInvocationTokenListItem = { + id: string + name: string + packageIds: Array + packageKodyIds: Array + exportNames: Array + sources: Array + createdAt: string + updatedAt: string + lastUsedAt: string | null + revokedAt: string | null +} + +type AccountPackageInvocationTokensPayload = { + ok: true + email: string + username: string + invocationUrlOrigin: string + packages: Array + tokens: Array + selectedTokenId?: string +} + +type EditorState = { + name: string + rawToken: string + packageIdsText: string + packageKodyIdsText: string + exportNamesText: string + sourcesText: string +} + +const accountPackageInvocationTokensApiPath = + '/account/package-invocation-tokens.json' +const accountPackageInvocationTokensBasePath = + '/account/package-invocation-tokens' +const wildcardScope = '*' + +function createEmptyEditorState(): EditorState { + return { + name: '', + rawToken: '', + packageIdsText: '', + packageKodyIdsText: '', + exportNamesText: '', + sourcesText: '', + } +} + +function formatTimestamp(value: string | null) { + return value ? new Date(value).toLocaleString() : 'Never' +} + +function readTrimmedParam(params: URLSearchParams, key: string) { + const value = params.get(key) + return value?.trim() ? value.trim() : null +} + +function readCommaListParams(params: URLSearchParams, keys: Array) { + return keys.flatMap((key) => + params + .getAll(key) + .flatMap((value) => value.split(',')) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ) +} + +function createEditorStateFromNewTokenQuery(href: string): EditorState { + const params = new URL(href, 'http://localhost').searchParams + const state = createEmptyEditorState() + const packageIds = readCommaListParams(params, ['packageId', 'packageIds']) + const packageKodyIds = readCommaListParams(params, [ + 'packageKodyId', + 'packageKodyIds', + 'kodyId', + 'kodyIds', + ]) + const exportNames = readCommaListParams(params, ['exportName', 'exportNames']) + const sources = readCommaListParams(params, ['source', 'sources']) + return { + ...state, + name: readTrimmedParam(params, 'name') ?? state.name, + packageIdsText: packageIds.join('\n'), + packageKodyIdsText: packageKodyIds.join('\n'), + exportNamesText: exportNames.join('\n'), + sourcesText: sources.join('\n'), + } +} + +function isNewTokenPath(href: string) { + const url = new URL(href, 'http://localhost') + return url.pathname === `${accountPackageInvocationTokensBasePath}/new` +} + +function getNewTokenQueryKey(href: string) { + const url = new URL(href, 'http://localhost') + if (url.pathname !== `${accountPackageInvocationTokensBasePath}/new`) + return '' + const keys = [ + 'name', + 'packageId', + 'packageIds', + 'packageKodyId', + 'packageKodyIds', + 'kodyId', + 'kodyIds', + 'exportName', + 'exportNames', + 'source', + 'sources', + ] + return keys + .map((key) => `${key}=${url.searchParams.getAll(key).join('\u0000')}`) + .join('&') +} + +function parseListText(value: string) { + return value + .split(/[\n,]/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) +} + +function formatScope(values: Array) { + if (values.length === 0) return 'None' + if (values.includes(wildcardScope)) return 'Any' + return values.join(', ') +} + +function tokenStatus(token: PackageInvocationTokenListItem) { + return token.revokedAt ? 'Revoked' : 'Active' +} + +function tokenStatusCss(token: PackageInvocationTokenListItem) { + return { + display: 'inline-flex', + alignItems: 'center', + width: 'max-content', + padding: `${spacing.xs} ${spacing.sm}`, + borderRadius: radius.full, + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeight.medium, + backgroundColor: token.revokedAt + ? 'color-mix(in srgb, var(--color-danger) 8%, transparent)' + : colors.primarySoftest, + color: token.revokedAt ? colors.error : colors.primaryText, + } +} + +export function AccountPackageInvocationTokensRoute(handle: Handle) { + let status: AccountStatus = 'loading' + let saveState: 'idle' | 'creating' | 'revoking' = 'idle' + let email = '' + let username = '' + let invocationUrlOrigin = '' + let packages: Array = [] + let tokens: Array = [] + let editorState = createEmptyEditorState() + let selectedTokenId: string | null = null + let message: string | null = null + let messageTone: 'info' | 'error' = 'info' + let lastLoadedHref = '' + let lastNewTokenQueryKey = '' + let revokeConfirm = false + + const primaryButtonCss = getPrimaryButtonCss() + const secondaryButtonCss = getSecondaryButtonCss() + const dangerButtonCss = getDangerButtonCss() + + async function loadTokens(signal: AbortSignal) { + try { + const href = + typeof window === 'undefined' + ? accountPackageInvocationTokensBasePath + : window.location.href + lastLoadedHref = href + const response = await fetch(accountPackageInvocationTokensApiPath, { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }) + if (signal.aborted) return + if (response.status === 401) { + window.location.assign('/login') + return + } + const payload = + await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load package invocation tokens.') + } + applyPayload(payload, href) + status = 'ready' + message = null + messageTone = 'info' + handle.update() + } catch (error) { + if (signal.aborted) return + status = 'error' + message = + error instanceof Error + ? error.message + : 'Unable to load package invocation tokens.' + messageTone = 'error' + handle.update() + } + } + + function applyPayload( + payload: AccountPackageInvocationTokensPayload, + href: string, + ) { + email = payload.email + username = payload.username + invocationUrlOrigin = payload.invocationUrlOrigin + packages = payload.packages + tokens = payload.tokens + revokeConfirm = false + if (payload.selectedTokenId) { + selectedTokenId = payload.selectedTokenId + editorState = createEmptyEditorState() + return + } + if (isNewTokenPath(href)) { + const queryKey = getNewTokenQueryKey(href) + if (queryKey !== lastNewTokenQueryKey) { + lastNewTokenQueryKey = queryKey + editorState = createEditorStateFromNewTokenQuery(href) + selectedTokenId = null + } + return + } + if ( + selectedTokenId && + !tokens.some((token) => token.id === selectedTokenId) + ) { + selectedTokenId = null + } + } + + function setEditorField(field: keyof EditorState, value: string) { + editorState = { + ...editorState, + [field]: value, + } + } + + function readEditorStateFromForm(form: HTMLFormElement) { + const formData = new FormData(form) + return { + name: String(formData.get('name') ?? '').trim(), + rawToken: String(formData.get('rawToken') ?? '').trim(), + packageIdsText: String(formData.get('packageIds') ?? ''), + packageKodyIdsText: String(formData.get('packageKodyIds') ?? ''), + exportNamesText: String(formData.get('exportNames') ?? ''), + sourcesText: String(formData.get('sources') ?? ''), + } satisfies EditorState + } + + function startNewToken() { + editorState = createEmptyEditorState() + selectedTokenId = null + revokeConfirm = false + message = null + messageTone = 'info' + if (typeof window !== 'undefined') { + window.history.pushState( + null, + '', + `${accountPackageInvocationTokensBasePath}/new`, + ) + lastLoadedHref = window.location.href + lastNewTokenQueryKey = getNewTokenQueryKey(window.location.href) + } + handle.update() + } + + function useRaycastDefaults() { + editorState = { + ...editorState, + name: editorState.name || 'Personal Raycast', + packageIdsText: '', + packageKodyIdsText: wildcardScope, + exportNamesText: wildcardScope, + sourcesText: 'raycast', + } + message = + 'Raycast defaults allow any owned package and export when source is raycast.' + messageTone = 'info' + handle.update() + } + + async function createToken(form?: HTMLFormElement) { + if (saveState !== 'idle') return + const nextEditorState = form ? readEditorStateFromForm(form) : editorState + editorState = nextEditorState + const packageIds = parseListText(nextEditorState.packageIdsText) + const packageKodyIds = parseListText(nextEditorState.packageKodyIdsText) + const exportNames = parseListText(nextEditorState.exportNamesText) + const sources = parseListText(nextEditorState.sourcesText) + + if (!nextEditorState.name) { + message = 'Token name is required.' + messageTone = 'error' + handle.update() + return + } + if (!nextEditorState.rawToken) { + message = 'Paste the raw token before creating it.' + messageTone = 'error' + handle.update() + return + } + if (packageIds.length === 0 && packageKodyIds.length === 0) { + message = + 'Add at least one package id, Kody id, or wildcard package scope.' + messageTone = 'error' + handle.update() + return + } + if (exportNames.length === 0) { + message = 'Add at least one export name or wildcard export scope.' + messageTone = 'error' + handle.update() + return + } + + saveState = 'creating' + message = null + messageTone = 'info' + handle.update() + try { + const response = await fetch(accountPackageInvocationTokensApiPath, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + action: 'create', + name: nextEditorState.name, + rawToken: nextEditorState.rawToken, + packageIds, + packageKodyIds, + exportNames, + sources, + }), + }) + if (response.status === 401) { + window.location.assign('/login') + return + } + const payload = await readJson< + AccountPackageInvocationTokensPayload & { error?: string; ok?: boolean } + >(response) + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to create token.') + } + applyPayload(payload, accountPackageInvocationTokensBasePath) + saveState = 'idle' + editorState = createEmptyEditorState() + message = + 'Created token. The raw token was not stored and will not be shown again.' + messageTone = 'info' + if (typeof window !== 'undefined') { + window.history.pushState( + null, + '', + accountPackageInvocationTokensBasePath, + ) + lastLoadedHref = window.location.href + } + handle.update() + } catch (error) { + saveState = 'idle' + message = + error instanceof Error ? error.message : 'Unable to create token.' + messageTone = 'error' + handle.update() + } + } + + async function revokeSelectedToken() { + if (!selectedTokenId || saveState !== 'idle') return + saveState = 'revoking' + message = null + messageTone = 'info' + handle.update() + try { + const response = await fetch(accountPackageInvocationTokensApiPath, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + action: 'revoke', + id: selectedTokenId, + }), + }) + if (response.status === 401) { + window.location.assign('/login') + return + } + const payload = await readJson< + AccountPackageInvocationTokensPayload & { error?: string; ok?: boolean } + >(response) + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to revoke token.') + } + applyPayload(payload, accountPackageInvocationTokensBasePath) + saveState = 'idle' + message = 'Revoked token.' + messageTone = 'info' + handle.update() + } catch (error) { + saveState = 'idle' + message = + error instanceof Error ? error.message : 'Unable to revoke token.' + messageTone = 'error' + handle.update() + } + } + + function selectToken(token: PackageInvocationTokenListItem) { + selectedTokenId = token.id + revokeConfirm = false + message = null + messageTone = 'info' + if (typeof window !== 'undefined') { + window.history.pushState(null, '', accountPackageInvocationTokensBasePath) + lastLoadedHref = window.location.href + } + handle.update() + } + + return () => { + const currentHref = + typeof window === 'undefined' + ? accountPackageInvocationTokensBasePath + : window.location.href + const isRefreshingForLocationChange = + status !== 'loading' && currentHref !== lastLoadedHref + if (status === 'loading' || isRefreshingForLocationChange) { + handle.queueTask(loadTokens) + } + const isMutating = saveState !== 'idle' + const selectedToken = + tokens.find((token) => token.id === selectedTokenId) ?? null + const endpointTemplate = username + ? `${invocationUrlOrigin}/@${username}/api/package-invocations//` + : '' + + return ( +
+
+
+

+ {email + ? `${email} package invocation tokens` + : 'Package invocation tokens'} +

+

+ Create bearer tokens for trusted personal clients without storing + the raw token in Kody. +

+
+ +
+ + {status === 'loading' ? ( +

+ Loading package invocation tokens... +

+ ) : null} + {message ? ( +

+ {message} +

+ ) : null} + + {status === 'ready' ? ( +
+ + +
+
{ + event.preventDefault() + if (event.currentTarget instanceof HTMLFormElement) { + void createToken(event.currentTarget) + } + }), + css(cardCss), + ]} + > +
+

Create token

+

+ Paste the raw token once. Kody stores only its hash, so keep + the raw value in the trusted client. +

+
+ + + + + +
+