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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/deepnote-cli-publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,18 @@ data app is the model that supports it — not a published static site.
By default a published site is a plain static website: it can serve HTML, CSS, JavaScript, and
assets, but it cannot call the Deepnote API.

Passing `--api-access enabled` lets the page acquire a short-lived, project- and viewer-scoped token
from the Deepnote shell that embeds it. That token has a deliberately narrow surface — read the
configured notebook, start a run, poll that run — which is what makes an interactive page possible
without a server of your own.
Passing `--api-access enabled` lets the page acquire a project- and viewer-scoped token from the
Deepnote shell that embeds it. The token expires 15 minutes after it is minted and has a deliberately
Comment thread
coderabbitai[bot] marked this conversation as resolved.
narrow surface — read the configured notebook's inputs and block metadata (no block source), start a
detached run, and poll that run for its outputs as `snapshotBlocks` — which is what makes an
interactive page possible without a server of your own. Every other endpoint answers 403, so a
feature that works in a local preview with a personal token can break only once embedded.

The page obtains the token by posting a `deepnote-static-files-api-token-request` message to the
shell origin, which replies with the token, the API origin to send it to, and its expiry. Expiry is
not a permanent failure: repeat that request to receive a fresh token, ideally shortly before the
current one expires and again on a 401. `examples/local-runner/cloud-app` implements the handshake
and the refresh.

This is a second opt-in layered on top of site sharing, and it can only ever narrow the audience, not
widen it: a viewer who cannot see the site cannot obtain a token for it. Because every viewer is a
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,16 @@ ${c.bold('Description:')}
Replaces matching files in ${c.dim('_deepnote_static/')} and enables static website sharing
after every upload succeeds. API access is left unchanged unless explicitly set.

${c.bold('Embedded API access:')}
With API access enabled, the embedded app calls Deepnote with a viewer-scoped token that
expires after 15 minutes — never your personal token. It covers one run loop: read the
configured notebook (inputs and block metadata, no source), start a detached run, and poll
that run for its outputs as ${c.dim('snapshotBlocks')}. Every other endpoint answers 403, so a
feature built against a local preview with a personal token can break only once embedded.
Gate those paths on an ${c.dim('isEmbedded')} check (${c.dim('window !== window.parent')}): skip or hide them
when embedded, and surface a 403 instead of swallowing it.
Details: ${c.underline('https://github.com/deepnote/deepnote/blob/main/docs/deepnote-cli-publish.md')}

${c.bold('Working with deepnote sync:')}
${c.dim('_deepnote_static/')} is part of the same project file store that
${c.dim('deepnote sync --all-files')} mirrors, so both commands write it. When the published
Expand Down
50 changes: 44 additions & 6 deletions packages/cli/src/commands/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ vi.mock('@deepnote/cloud', async importOriginal => {

import { deleteProjectFile, getProjectDetail, updateProjectStaticFiles, uploadProjectFile } from '@deepnote/cloud'
import { createProgram } from '../cli'
import { getChalk } from '../output'
import { embeddedApiAccessNote } from '../utils/static-site-api-access'

const mockedDelete = vi.mocked(deleteProjectFile)
const mockedGetProject = vi.mocked(getProjectDetail)
Expand Down Expand Up @@ -191,15 +193,51 @@ describe('deepnote publish', () => {
it.each([
['enabled', true],
['disabled', false],
] as const)('sets API access to %s when explicitly requested', async (state, enabled) => {
await fs.writeFile(join(tempDir, 'index.html'), 'hi')
] as const)(
'sets API access to %s when explicitly requested and notes the embedded token only when enabled',
async (state, enabled) => {
await fs.writeFile(join(tempDir, 'index.html'), 'hi')
mockedUpdateProject.mockResolvedValue({
sharingEnabled: true,
apiAccessEnabled: enabled,
url: 'https://static-p1.example.com/',
})
const logged: string[] = []
const spy = vi.spyOn(console, 'log').mockImplementation(message => logged.push(String(message)))

await run(tempDir, '--project-id', 'p1', '--token', 'tok', '--api-access', state, '-q')
await run(tempDir, '--project-id', 'p1', '--token', 'tok', '--api-access', state)
spy.mockRestore()

expect(mockedUpdateProject).toHaveBeenCalledWith('https://api.deepnote.com', 'tok', 'p1', {
sharingEnabled: true,
apiAccessEnabled: enabled,
expect(mockedUpdateProject).toHaveBeenCalledWith('https://api.deepnote.com', 'tok', 'p1', {
sharingEnabled: true,
apiAccessEnabled: enabled,
})
const output = logged.join('\n')
expect(output).toContain(`API access: ${state}`)
expect(output.includes(embeddedApiAccessNote(getChalk()))).toBe(enabled)
}
)

it('notes the embedded token when stored settings already have API access enabled', async () => {
await fs.writeFile(join(tempDir, 'index.html'), 'hi')
mockedGetProject.mockResolvedValue({
id: 'p1',
name: 'Project',
files: [],
staticFiles: {
sharingEnabled: true,
apiAccessEnabled: true,
url: 'https://static-p1.example.com/',
},
})
const logged: string[] = []
const spy = vi.spyOn(console, 'log').mockImplementation(message => logged.push(String(message)))

await run(tempDir, '--project-id', 'p1', '--token', 'tok')
spy.mockRestore()

expect(mockedUpdateProject).not.toHaveBeenCalled()
expect(logged.join('\n')).toContain(embeddedApiAccessNote(getChalk()))
})

it('prunes only stale files below the selected target', async () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type SyncRootOption,
savePublishMirror,
} from '../utils/publish-mirror'
import { embeddedApiAccessNote } from '../utils/static-site-api-access'
import { SYNC_MANIFEST_FILENAME } from '../utils/sync-manifest'

interface PublishOptions {
Expand Down Expand Up @@ -339,6 +340,9 @@ export function createPublishAction(program: Command) {
} else if (siteUrl !== undefined) {
log(`\n${c.bold('Static site URL:')} ${c.underline(siteUrl)}`)
log(`${c.dim(`API access: ${apiAccessEnabled ? 'enabled' : 'disabled'}`)}`)
if (apiAccessEnabled) {
log(`\n${embeddedApiAccessNote(c)}`)
}
}
}

Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/commands/static-site-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ vi.mock('@deepnote/cloud', async importOriginal => {

import { updateProjectStaticFiles } from '@deepnote/cloud'
import { createProgram } from '../cli'
import { getChalk } from '../output'
import { embeddedApiAccessNote } from '../utils/static-site-api-access'

const mockedUpdateProject = vi.mocked(updateProjectStaticFiles)

Expand Down Expand Up @@ -87,6 +89,18 @@ describe('deepnote static-site access', () => {
expect(mockedUpdateProject).not.toHaveBeenCalled()
})

it('notes the embedded token when API access ends up enabled', async () => {
mockedUpdateProject.mockResolvedValue({
sharingEnabled: true,
apiAccessEnabled: true,
url: 'https://static-p1.example.com/',
})

await run('--project-id', 'p1', '--token', 'tok', '--api-access', 'enabled')

expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(embeddedApiAccessNote(getChalk()))
})

it('reports API failures as runtime errors', async () => {
mockedUpdateProject.mockRejectedValue(new Error('Forbidden'))

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/static-site-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Command } from 'commander'
import { ExitCode } from '../exit-codes'
import { getChalk, log, error as logError } from '../output'
import { MissingTokenError, resolveToken } from '../utils/auth'
import { embeddedApiAccessNote } from '../utils/static-site-api-access'

export interface StaticSiteAccessOptions {
projectId: string
Expand Down Expand Up @@ -62,6 +63,9 @@ export function createStaticSiteAccessAction(program: Command) {
} else {
log(c.dim('Published files remain stored and can be shared again later.'))
}
if (settings.apiAccessEnabled) {
log(`\n${embeddedApiAccessNote(c)}`)
}
} catch (error) {
logError(error instanceof Error ? error.message : String(error))
process.exitCode = ExitCode.Error
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/utils/static-site-api-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ChalkInstance } from 'chalk'

export function embeddedApiAccessNote(c: ChalkInstance): string {
return [
c.yellow(
`${c.bold('Note:')} embedded apps call Deepnote with a viewer-scoped token that expires after 15 minutes — never your personal token.`
),
c.dim(
' It covers one run loop: read the configured notebook (no block source), start a detached run, poll that run.'
),
c.dim(' Every other endpoint answers 403. Build for that surface; see `deepnote publish --help`.'),
].join('\n')
}
13 changes: 9 additions & 4 deletions skills/deepnote/references/apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,15 @@ The viewer token is limited to one run loop:
| Poll that viewer's own run by id | Call arbitrary `/v2` endpoints |
| Receive sanitized output blocks (`snapshotBlocks`) instead of raw snapshot YAML | Read another viewer's run |

The consequence is a quiet failure mode: code developed against a local preview with a personal
token keeps working there and does nothing once embedded, with no error. Guard those paths on an
`isEmbedded` check rather than letting them fail silently — `examples/local-runner/cloud-app`
does exactly this for its run-history panel, and is the reference implementation for the handshake.
Every other endpoint answers HTTP 403 with `This endpoint is not available to static app tokens`,
`snapshotDelivery` is ignored (`snapshotContent` and `snapshotDownloadUrl` are always null), and the
token expires 15 minutes after it is minted, unlike a personal API key. Code developed against a
local preview with a personal token keeps working there and breaks only once embedded — and the
break stays invisible when the app swallows the 403 or the 401 from an expired token. Guard the
paths that cannot succeed on an `isEmbedded` check, surface the responses you do not handle, and
refresh the token over `postMessage` before it expires — `examples/local-runner/cloud-app` does
this for its run-history panel and token refresh, and is the reference implementation for the
handshake.

## 5. Local Node-backed apps (`serveStatic`)

Expand Down
21 changes: 15 additions & 6 deletions skills/deepnote/references/cli-publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ API access is security-sensitive and is not enabled by default. Pass `--api-acce
website needs a static-app viewer token to call allowed Deepnote endpoints. Pass
`--api-access disabled` to turn it off explicitly.

## The embedded token is narrower than a personal token

A published app runs embedded in Deepnote with a viewer-scoped token that expires after 15 minutes,
never the personal token a local preview uses. `references/apps.md` section 4 is the authoritative
description of what that token may and may not do: one run loop, every other endpoint answers 403,
and features built against a personal token can break only once embedded. After a successful publish
that leaves API access enabled, and after `deepnote static-site access` enables it, the CLI prints a
short reminder to that effect; `-q` suppresses it for publish.

## Change access without republishing

Use `deepnote static-site access` to change an existing site's access settings without uploading,
Expand Down Expand Up @@ -117,12 +126,12 @@ deepnote publish ./dist --project-id <uuid> --no-sync-root
deepnote static-site access --project-id <uuid> --sharing disabled
```

Exit code 0 means uploads and the sharing update succeeded. Exit code 1 means a project lookup,
upload, optional prune, or sharing update failed, or that Deepnote holds changes the sync workspace
has not pulled. Exit code 2 means invalid arguments, a missing token, an invalid local directory, or
a `--sync-root` that has no manifest, does not track the project, or whose tracked project
directory is missing, or a sync manifest that exists but cannot be read (pass `--no-sync-root` to
publish without it).
Exit code 0 means uploads and the project settings update succeeded. Exit code 1 means a project
lookup, upload, optional prune, or project settings update failed, or that Deepnote holds changes
the sync workspace has not pulled. Exit code 2 means invalid arguments, a missing token, an invalid
local directory, or a `--sync-root` that has no manifest, does not track the project, or whose
tracked project directory is missing, or a sync manifest that exists but cannot be read (pass
`--no-sync-root` to publish without it).

For `static-site access`, exit code 0 means the settings update succeeded, exit code 1 means the
project settings request failed, and exit code 2 means invalid arguments, a missing token, no
Expand Down
Loading