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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/contributing/package-invocation-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ The backing same-origin JSON endpoint is:
from a new raw token
- `POST /account/package-invocation-tokens.json` with `action: "revoke"` —
revoke a token by id
- `POST /account/package-invocation-tokens.json` with `action: "unrevoke"` —
restore a revoked token by id
- `POST /account/package-invocation-tokens.json` with `action: "reinstate"` —
reinstate a revoked token by id
- `POST /account/package-invocation-tokens.json` with `action: "delete"` —
permanently delete a token row by id

Expand Down
6 changes: 5 additions & 1 deletion docs/guides/account-package-invocation-token-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ is write-only and optional: leaving it blank keeps the current bearer value,
while pasting a new raw token replaces the stored hash. The UI never shows the
existing raw token or token hash.

In the editor, look for the **Token value** section. The **New raw token value**
field is where the user pastes or generates a replacement token value before
saving.

When the exact bearer value does not matter, the user can click **Generate** in
the raw-token field. Kody generates a high-entropy browser-side token, fills the
password field, and offers a **Copy** control so the user can paste it into the
Expand Down Expand Up @@ -75,7 +79,7 @@ package id scope.
the Raw token field, or click **Generate** and copy/deliver the generated
value before creating the token.
- For rotation, ask the user to open the existing token detail URL and paste
the new raw token into the write-only replacement field.
or generate the new raw token in the **Token value** section.
4. Instruct the external caller to send:
- `Authorization: Bearer <raw-token>`
- JSON `source` matching one of the allowed sources when sources are scoped
Expand Down
151 changes: 85 additions & 66 deletions packages/worker/client/routes/account-package-invocation-tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ export function AccountPackageInvocationTokensRoute(handle: Handle) {
| 'creating'
| 'updating'
| 'revoking'
| 'unrevoking'
| 'reinstating'
| 'deleting' = 'idle'
let email = ''
let username = ''
Expand Down Expand Up @@ -776,10 +776,10 @@ export function AccountPackageInvocationTokensRoute(handle: Handle) {
}
}

async function unrevokeSelectedToken() {
async function reinstateSelectedToken() {
const tokenId = getCurrentSelectedTokenId()
if (!tokenId || saveState !== 'idle') return
saveState = 'unrevoking'
saveState = 'reinstating'
message = null
messageTone = 'info'
handle.update()
Expand All @@ -792,7 +792,7 @@ export function AccountPackageInvocationTokensRoute(handle: Handle) {
},
credentials: 'include',
body: JSON.stringify({
action: 'unrevoke',
action: 'reinstate',
id: tokenId,
}),
})
Expand All @@ -804,12 +804,12 @@ export function AccountPackageInvocationTokensRoute(handle: Handle) {
AccountPackageInvocationTokensPayload & { error?: string; ok?: boolean }
>(response)
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error || 'Unable to un-revoke token.')
throw new Error(payload?.error || 'Unable to reinstate token.')
}
mutationVersion += 1
applyPayload(payload, buildTokenDetailPath(tokenId))
saveState = 'idle'
message = 'Un-revoked token.'
message = 'Reinstated token.'
messageTone = 'info'
if (typeof window !== 'undefined') {
window.history.pushState(null, '', buildTokenDetailPath(tokenId))
Expand All @@ -819,7 +819,7 @@ export function AccountPackageInvocationTokensRoute(handle: Handle) {
} catch (error) {
saveState = 'idle'
message =
error instanceof Error ? error.message : 'Unable to un-revoke token.'
error instanceof Error ? error.message : 'Unable to reinstate token.'
messageTone = 'error'
handle.update()
}
Expand Down Expand Up @@ -1267,62 +1267,81 @@ export function AccountPackageInvocationTokensRoute(handle: Handle) {
/>
</label>

<label mix={css(fieldCss)}>
<span mix={css(fieldLabelCss)}>Replace raw token</span>
<div
mix={css({
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto auto',
gap: spacing.sm,
[mq.mobile]: {
gridTemplateColumns: '1fr',
},
})}
>
<input
name="rawToken"
type="password"
value={editorState.rawToken}
placeholder="Leave blank to keep the current token value"
autoComplete="off"
disabled={isMutating}
mix={[
on('input', (event) => {
setEditorField(
'rawToken',
event.currentTarget.value,
)
handle.update()
}),
css(inputCss),
]}
/>
<button
type="button"
disabled={isMutating}
mix={[
on('click', generateEditorRawToken),
css(secondaryButtonCss),
]}
>
Generate
</button>
<button
type="button"
disabled={isMutating || !editorState.rawToken}
mix={[
on('click', () => void copyEditorRawToken()),
css(secondaryButtonCss),
]}
>
Copy
</button>
<div
mix={css({
display: 'grid',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.md,
border: `1px solid ${colors.border}`,
backgroundColor: colors.background,
})}
>
<div mix={css({ display: 'grid', gap: spacing.xs })}>
<span mix={css(fieldLabelCss)}>Token value</span>
<p mix={css(descriptionCss)}>
The current token value is hidden and cannot be
recovered. To edit it, enter or generate a new raw token
value here, then save.
</p>
</div>
<span mix={css(descriptionCss)}>
Paste or generate a new raw token only when rotating this
credential. Kody stores only its hash.
</span>
</label>
<label mix={css(fieldCss)}>
<span mix={css(fieldLabelCss)}>New raw token value</span>
<div
mix={css({
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto auto',
gap: spacing.sm,
[mq.mobile]: {
gridTemplateColumns: '1fr',
},
})}
>
<input
name="rawToken"
type="password"
value={editorState.rawToken}
placeholder="Leave blank to keep the current token value"
autoComplete="off"
disabled={isMutating}
mix={[
on('input', (event) => {
setEditorField(
'rawToken',
event.currentTarget.value,
)
handle.update()
}),
css(inputCss),
]}
/>
<button
type="button"
disabled={isMutating}
mix={[
on('click', generateEditorRawToken),
css(secondaryButtonCss),
]}
>
Generate
</button>
<button
type="button"
disabled={isMutating || !editorState.rawToken}
mix={[
on('click', () => void copyEditorRawToken()),
css(secondaryButtonCss),
]}
>
Copy
</button>
</div>
<span mix={css(descriptionCss)}>
Leave this blank to keep the current token value. Kody
stores only the hash of any new value.
</span>
</label>
</div>

<div
mix={css({
Expand Down Expand Up @@ -1575,14 +1594,14 @@ export function AccountPackageInvocationTokensRoute(handle: Handle) {
disabled={isMutating}
mix={[
on('click', () => {
void unrevokeSelectedToken()
void reinstateSelectedToken()
}),
css(secondaryButtonCss),
]}
>
{saveState === 'unrevoking'
? 'Un-revoking...'
: 'Un-revoke token'}
{saveState === 'reinstating'
? 'Reinstating...'
: 'Reinstate token'}
</button>
) : (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const mockModule = vi.hoisted(() => ({
},
]),
revokePackageInvocationToken: vi.fn(async () => true),
unrevokePackageInvocationToken: vi.fn(async () => true),
reinstatePackageInvocationToken: vi.fn(async () => true),
deletePackageInvocationToken: vi.fn(async () => true),
updatePackageInvocationToken: vi.fn(async () => true),
listSavedPackagesByUserId: vi.fn(async () => [
Expand Down Expand Up @@ -95,10 +95,10 @@ vi.mock('#worker/package-invocations/repo.ts', () => ({
mockModule.insertPackageInvocationToken(...args),
listPackageInvocationTokensByUserId: (...args: Array<unknown>) =>
mockModule.listPackageInvocationTokensByUserId(...args),
reinstatePackageInvocationToken: (...args: Array<unknown>) =>
mockModule.reinstatePackageInvocationToken(...args),
revokePackageInvocationToken: (...args: Array<unknown>) =>
mockModule.revokePackageInvocationToken(...args),
unrevokePackageInvocationToken: (...args: Array<unknown>) =>
mockModule.unrevokePackageInvocationToken(...args),
deletePackageInvocationToken: (...args: Array<unknown>) =>
mockModule.deletePackageInvocationToken(...args),
updatePackageInvocationToken: (...args: Array<unknown>) =>
Expand Down Expand Up @@ -135,13 +135,13 @@ function resetMocks() {
mockModule.insertPackageInvocationToken.mockClear()
mockModule.listPackageInvocationTokensByUserId.mockClear()
mockModule.revokePackageInvocationToken.mockClear()
mockModule.unrevokePackageInvocationToken.mockClear()
mockModule.reinstatePackageInvocationToken.mockClear()
mockModule.deletePackageInvocationToken.mockClear()
mockModule.updatePackageInvocationToken.mockClear()
mockModule.listSavedPackagesByUserId.mockClear()
}

test('package invocation token API lists, creates, updates, validates, revokes, restores, and deletes tokens', async () => {
test('package invocation token API lists, creates, updates, validates, revokes, reinstates, and deletes tokens', async () => {
resetMocks()
const env = createEnv()
const handler = createAccountPackageInvocationTokensApiHandler(env)
Expand Down Expand Up @@ -386,28 +386,28 @@ test('package invocation token API lists, creates, updates, validates, revokes,
})
await expect(revokeResponse.json()).resolves.toMatchObject({ ok: true })

const unrevokeResponse = await handler.handler({
const reinstateResponse = await handler.handler({
request: new Request(
'https://example.com/account/package-invocation-tokens.json',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'unrevoke',
action: 'reinstate',
id: 'token-1',
}),
},
),
params: {},
} as never)

expect(unrevokeResponse.status).toBe(200)
expect(mockModule.unrevokePackageInvocationToken).toHaveBeenCalledWith({
expect(reinstateResponse.status).toBe(200)
expect(mockModule.reinstatePackageInvocationToken).toHaveBeenCalledWith({
db: env.APP_DB,
userId: 'stable-user-1',
id: 'token-1',
})
await expect(unrevokeResponse.json()).resolves.toMatchObject({
await expect(reinstateResponse.json()).resolves.toMatchObject({
ok: true,
selectedTokenId: 'token-1',
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import {
hashPackageInvocationBearerToken,
insertPackageInvocationToken,
listPackageInvocationTokensByUserId,
reinstatePackageInvocationToken,
revokePackageInvocationToken,
type PackageInvocationTokenRecord,
unrevokePackageInvocationToken,
updatePackageInvocationToken,
} from '#worker/package-invocations/repo.ts'
import {
Expand Down Expand Up @@ -102,8 +102,8 @@ export function createAccountPackageInvocationTokensApiHandler(env: Env) {
if (action === 'revoke') {
return handleRevokeAction({ env, request, user, body })
}
if (action === 'unrevoke') {
return handleUnrevokeAction({ env, request, user, body })
if (action === 'reinstate') {
return handleReinstateAction({ env, request, user, body })
}
if (action === 'delete') {
return handleDeleteAction({ env, request, user, body })
Expand Down Expand Up @@ -404,7 +404,7 @@ async function handleRevokeAction(input: {
)
}

async function handleUnrevokeAction(input: {
async function handleReinstateAction(input: {
env: Env
request: Request
user: AuthenticatedUser
Expand All @@ -414,7 +414,7 @@ async function handleUnrevokeAction(input: {
if (!id) {
return jsonResponse({ ok: false, error: 'Token id is required.' }, 400)
}
const restored = await unrevokePackageInvocationToken({
const restored = await reinstatePackageInvocationToken({
db: input.env.APP_DB,
userId: input.user.mcpUser.userId,
id,
Expand Down
2 changes: 1 addition & 1 deletion packages/worker/src/package-invocations/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ export async function revokePackageInvocationToken(input: {
return (result.meta.changes ?? 0) > 0
}

export async function unrevokePackageInvocationToken(input: {
export async function reinstatePackageInvocationToken(input: {
db: D1Database
userId: string
id: string
Expand Down
Loading