Named URLs
Plans get shareable, human-readable URLs based on the session name you provide.
When you run /planpush name:auth-redesign, the plan is published at /p/auth-redesign instead of an auto-generated ID like /p/sess_064d4a62049b.
| Format | Example | How it's set |
|---|---|---|
| Named (preferred) | /p/auth-redesign | name: prefix or prompted on first push |
| Auto-generated | /p/sess_064d4a62049b | Fallback when no name is given |
Visibility
Sessions have two visibility states: private and published.
- Private — visible only to the owner and admins. Created by passing
X-Visibility: privatefrom the CLI. - Published — visible to all authenticated users in your org. This is the default.
Publishing is one-way: once a private session is published, it can't be made private again. Unauthorized access returns 404 (not 403) to prevent leaking session existence.
Version history
Every push creates a new version snapshot. Versions are retained for 90 days, then automatically deleted.
- View older versions by appending
?v=Nto the plan URL (e.g./p/auth-redesign?v=2) - A version banner appears when viewing a non-latest version
- Each version records who pushed it and when
Archiving
Sessions can be archived to keep the dashboard clean. Archived sessions are hidden from the default view but not deleted.
- Toggle archive from the dashboard (owner or admin)
- Archived sessions are soft-deleted (
archived_attimestamp) and can be unarchived at any time
Authentication
The web sign-in provider is selected with the AUTH_PROVIDER environment variable (default github):
- GitHub OAuth (
AUTH_PROVIDER=github) — standard OAuth flow, gated to a single GitHub org. The zero-config default for small teams. - Okta OIDC SSO (
AUTH_PROVIDER=okta) — OpenID Connect (Authorization Code + PKCE) for enterprise single sign-on. See Okta SSO below.
Either provider works with the CLI device flow (RFC 8628): a code is shown in the terminal and you approve it in the browser — the browser step simply uses whichever provider is active. The device flow requires a web login first; if the plugin can't find your account it returns an error with the login URL.
AUTH_PROVIDER=okta, the GitHub web-login routes are disabled so there's no path that bypasses Okta-enforced MFA. Identity is stored provider-agnostically, so an instance can be moved between providers without reshaping the user table.
Access gating
How a user is granted access depends on the active provider:
- GitHub: access is restricted to members of a single GitHub organization, set via
GITHUB_ORG. Users outside the org are rejected at sign-in. - Okta: access is governed by group → role mapping. A user whose Okta groups map to at least one role is admitted; a user who maps to no role is denied (no session created). Bootstrap admins are listed in
INITIAL_ADMIN_EMAILS.
HTML sanitization
Pushed HTML is sanitized server-side to prevent XSS:
- Inline scripts are stripped via cheerio
- All inline scripts injected by the server use CSP nonces (base64url-encoded)
- Plan CSS and JS are injected server-side, not authored by users
Rate limiting
All authentication endpoints are rate-limited to 30 requests per 15 minutes per IP via express-rate-limit.
Okta SSO (OIDC)
PlanPush signs users in through Okta using OpenID Connect (Authorization Code + PKCE). ID-token signatures are verified against Okta's JWKS with automatic key rotation; MFA is delegated to Okta.
1. Create the Okta application
In the Okta admin console, create an OIDC — Web Application (a confidential client) and configure:
| Okta setting | Value |
|---|---|
| Grant type | Authorization Code (PKCE enabled) |
| Sign-in redirect URI | {BASE_URL}/auth/callback |
| Initiate login URI | {BASE_URL}/auth/initiate_login_uri (enables the Okta dashboard tile) |
| Sign-out redirect URI | {BASE_URL} (or your POST_LOGOUT_REDIRECT_URI) |
2. Configure PlanPush
Set the following in your .env and restart:
| Variable | Description |
|---|---|
AUTH_PROVIDER | Set to okta to make Okta the web sign-in provider |
OKTA_ISSUER | Your Okta org/authorization-server issuer, e.g. https://your-org.okta.com |
OKTA_CLIENT_ID | Client ID from the Okta app |
OKTA_CLIENT_SECRET | Client secret from the Okta app |
INITIAL_ADMIN_EMAILS | Comma-separated emails granted admin on first login (bootstrap) |
/auth/login) and are redirected to Okta; the registered initiate-login URI lets the Okta dashboard tile launch the same flow. There is no unsolicited-response ACS endpoint to harden.
Group → role mapping
With Okta active, access is managed by Okta group rather than per user. PlanPush reads the groups claim from the ID token and reconciles the user's roles on every login, so promotions and demotions in Okta propagate automatically.
Setup
- Add a groups claim to the Okta OIDC app (Sign-on → OpenID Connect ID Token), filtered to the groups you want PlanPush to see.
- Sign in once with a bootstrap admin (an address in
INITIAL_ADMIN_EMAILS). - In the dashboard's admin area, map each Okta group to a PlanPush role. Mappings are stored in the database and editable live — no redeploy needed.
Maintaining access
- Grant/revoke access: change a user's Okta group membership — it takes effect on their next login.
- Re-sync is non-destructive to manual grants: each role grant is tagged with an origin (
ssoormanual). Login reconciles only thessogrants to match the current group mapping; one-offmanualgrants made in the dashboard (break-glass / exceptions) persist. - Zero roles = denied: a user whose groups map to no role (and who isn't a bootstrap admin) sees an "access not yet granted" page and gets no session.
INITIAL_ADMIN_EMAILS replaces the GitHub-mode "first user becomes admin" rule — it solves the chicken-and-egg of needing an admin before any group mappings exist.
SCIM provisioning
PlanPush exposes a SCIM 2.0 endpoint (built on scimmy) so Okta can auto-provision and, crucially, auto-deprovision users — the compliance control enterprises audit for.
Setup
- Set
SCIM_AUTH_TOKENto a strong random bearer token in your.env. - In Okta, enable provisioning for the app with SCIM base URL
{BASE_URL}/scim/v2and the bearer token above./Usersis the priority resource;/Groupsmembership sync is accepted (role effect still flows through group → role mapping). - Assign users/groups to the app in Okta — provisioning happens automatically.
Deprovisioning
When Okta sends a SCIM PATCH active=false (e.g. an employee is offboarded), PlanPush immediately:
- marks the user deactivated, clears the deactivation cache, and
- revokes the user's API-token family — so both their web sessions and CLI tokens stop working at once, not after the next token expiry.
/scim/v2 endpoints are protected by the dedicated SCIM_AUTH_TOKEN bearer (constant-time check, fails closed if unset) — separate from user sessions.
Sessions & logout
Browser sessions are server-side and revocable (stored in the database via express-session), so access can be cut immediately rather than waiting for a signed cookie to expire.
- Idle timeout —
SESSION_IDLE_TIMEOUT(default 8h), a rolling window reset on each request. - Absolute lifetime —
SESSION_MAX_AGE(default 7d), enforced against session creation time regardless of activity. - RP-initiated logout — logging out destroys the server-side session and (in Okta mode) redirects to Okta's
end_session_endpoint, so the user is signed out of both PlanPush and Okta. SetPOST_LOGOUT_REDIRECT_URIfor the return destination (defaults toBASE_URL). - Forced revocation — deactivating a user (admin UI or SCIM) ends their sessions and tokens immediately.
Roles & permissions
Access is governed by a role/permission model with four built-in roles — admin, project manager, developer, and QA. Permissions are checked per request (with a short cache), so a role change takes effect right away rather than waiting for a new login. In Okta mode, roles are assigned via group → role mapping.
| Capability | Admin | PM | Developer | QA |
|---|---|---|---|---|
| View published plans | Yes | Yes | Yes | Yes |
| Comment & resolve | Yes | Yes | Yes | Yes |
| Push (create) sessions | Yes | Yes | Yes | — |
| Publish / archive | Any | Any | Own | — |
| View others' private plans | Yes | Yes | — | — |
| View audit log | Yes | Yes | — | — |
| Delete sessions | Yes | — | — | — |
| Manage users & roles | Yes | — | — | — |
INITIAL_ADMIN_EMAILS; in GitHub mode the first person to sign in becomes admin. (Instances created before roles existed are migrated so the old member capability set maps to developer.)
User management
Admins can deactivate users from the Members tab in the dashboard. Deactivated users are blocked from signing in and their active tokens are invalidated.
- Deactivation status is cached for 5 minutes to minimize DB lookups on every request
- Last-admin protection: you can't deactivate the only admin or demote them to a non-admin role
Audit log
All significant actions are recorded in the audit log. Writes are fire-and-forget (non-blocking) to avoid slowing down requests.
Admins and project managers see the full activity feed in the dashboard; other roles see only their own activity.
API tokens
Users can create and manage API tokens from the dashboard's API Tokens tab. Tokens can be revoked individually. Revocation is immediate — the revoked_at timestamp is checked on every request.
Push endpoint
The Claude Code plugin pushes design docs via POST /api/push.
| Header | Purpose |
|---|---|
X-Session-Name | Set the session name and URL slug on first push |
X-Session-Id | Update an existing session on subsequent pushes |
X-Visibility | Set to private to create a private session (default: published) |
Authorization | Bearer token from the device flow |
Device flow
The CLI authenticates using the RFC 8628 device authorization grant:
- Plugin requests a device code from
GET /api/auth/device - User opens the activation URL and enters the code in the browser
- Plugin polls
POST /api/auth/device/tokenuntil approved - Server returns a refresh token; plugin exchanges it for access tokens via
POST /api/auth/token
Credentials are stored locally. Subsequent runs skip authentication entirely.
Slack integration
Set SLACK_WEBHOOK_URL to receive notifications in your Slack channel when:
- A plan is updated with a new version (skipped for private plans)
- A comment is posted (skipped for private plans)
- A comment is resolved (skipped for private plans)
Docker
The official image is published to Docker Hub as frannsoftdev/planpush.
- Tags:
frannsoftdev/planpush:{version}andfrannsoftdev/planpush:latest - Architecture: linux/amd64
- Runs Node as a non-root
planpushuser viasu-exec - Migrations run automatically on startup
- Graceful shutdown on SIGTERM/SIGINT (drains connections, destroys DB pool)
See the Get Started guide for full deployment instructions.
Database
PlanPush uses Knex.js as a query builder (no ORM).
| Option | Driver | Config |
|---|---|---|
| SQLite (default) | better-sqlite3 | Zero-config — data stored in a file |
| PostgreSQL | pg | Set DATABASE_URL=postgres://... |
Migrations are JavaScript files that run automatically at startup. The KV store used for caching is database-backed (not filesystem).
Environment variables
| Variable | Required | Description |
|---|---|---|
SECRET_KEY | Yes | Random string, min 32 chars |
BASE_URL | Yes | Public URL of your server |
AUTH_PROVIDER | No | Web sign-in provider: github (default) or okta |
GITHUB_CLIENT_ID | github | GitHub OAuth App client ID |
GITHUB_CLIENT_SECRET | github | GitHub OAuth App client secret |
GITHUB_ORG | github | GitHub org name for access control |
OKTA_ISSUER | okta | Okta issuer URL, e.g. https://your-org.okta.com |
OKTA_CLIENT_ID | okta | Okta OIDC app client ID |
OKTA_CLIENT_SECRET | okta | Okta OIDC app client secret |
INITIAL_ADMIN_EMAILS | No | Comma-separated bootstrap admin emails (Okta mode) |
POST_LOGOUT_REDIRECT_URI | No | Return URL after Okta single logout (defaults to BASE_URL) |
SESSION_IDLE_TIMEOUT | No | Rolling idle timeout in seconds (default 28800 = 8h) |
SESSION_MAX_AGE | No | Absolute session lifetime in seconds (default 604800 = 7d) |
SCIM_AUTH_TOKEN | No | Bearer token enabling the SCIM 2.0 provisioning endpoint |
PORT | No | Server port (default: 3000) |
DATABASE_URL | No | PostgreSQL connection string (omit for SQLite) |
SLACK_WEBHOOK_URL | No | Slack incoming webhook for notifications |
github-marked vars are required only when AUTH_PROVIDER=github; the okta-marked vars only when AUTH_PROVIDER=okta. SECRET_KEY must be at least 32 characters — the server refuses to start if it's shorter.
Comments
Comments are anchored to specific sections of a design doc. They survive when the plan is regenerated.