← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
/**
* Near-zero-config auth helper for hosted Kody package apps.
*
* The platform owns reserved paths at the **host root** of every package-app
* host (`*.kody.run` and verified custom domains), never under the package mount (`/{leaf}` or legacy `/packages/{leaf}`):
*
* - `GET /-/auth/login?app={leaf}&return_to=…&scope=email` start sign-in
* - `GET /-/auth/callback` platform-only code exchange
* - `GET|POST /-/auth/logout?app={leaf}&return_to=…` clear only that app's
* package-app session (shared hosts keep sibling app sessions)
* - `GET /-/auth/me?app={leaf}` JSON current user or 401
*
* Legacy `/_kody/auth/*` paths remain accepted by the platform.
*
* On preview / app-origin (path-based `/@owner/packages/{leaf}`), login,
* logout, and `/me` also need `org` (or `username`) so host-auth can resolve
* identity when `PACKAGE_APP_BASE_URL` is unset.
*
* This module only builds those URLs, redirects to them, and reads who is
* signed in. It never talks to kody.codes OAuth endpoints and has no `kody:`
* imports. Pass `hostedUrl` / `origin` from `packageContext` when you have it
* (or explicit `app` / `org` / `username`).
*
* Reading the user in server code: the platform strips `Cookie` before app
* code runs and instead sets `X-Kody-Visitor` (verified JSON `userId`,
* `username`, `role`) on the request after removing any inbound `X-Kody-*`
* header, so it cannot be forged. `getUser` reads that header first and only
* falls back to forwarding `Cookie` to `/me` when it is absent (browser-style
* requests, local tools).
*/
export const PACKAGE_APP_VISITOR_HEADER = 'X-Kody-Visitor'
export const PACKAGE_APP_AUTH_PREFIX = '/-/auth'
export const PACKAGE_APP_AUTH_LEGACY_PREFIX = '/_kody/auth'
export const PACKAGE_APP_AUTH_LOGIN_PATH = `${PACKAGE_APP_AUTH_PREFIX}/login`
export const PACKAGE_APP_AUTH_LOGOUT_PATH = `${PACKAGE_APP_AUTH_PREFIX}/logout`
export const PACKAGE_APP_AUTH_ME_PATH = `${PACKAGE_APP_AUTH_PREFIX}/me`
/** Scopes beyond the default profile (id, name, avatar, role). */
export type PackageAppAuthScope = 'email'
export type PackageAppAuthMode = 'required' | 'optional'
/** JSON returned by `GET /-/auth/me`. */
export type PackageAppUser = {
userId: string
username: string
displayName: string
avatarUrl?: string
/** Present only when the user consented to the `email` scope. */
email?: string
role: string
}
export type PackageAppAuthOptions = {
/**
* `'required'` makes `resolveUser` / `requireUser` redirect anonymous users
* to login; `'optional'` (default) allows anonymous.
*/
mode?: PackageAppAuthMode
/** Extra scopes beyond profile. `email` requires user consent. */
scopes?: ReadonlyArray<PackageAppAuthScope>
/**
* Host origin for absolute URLs (for example `https://notes.kody.run`).
* Wins over `hostedUrl`. Defaults to the incoming request's origin, or to
* host-root relative paths when neither request nor origin is available.
*/
origin?: string | null
/**
* `packageContext.hostedUrl`. Its origin is used for absolute URLs; its
* path also supplies package identity (`app` leaf, and `org` on
* `/@owner/packages/{leaf}` mounts).
*/
hostedUrl?: string | null
/**
* Package name leaf for host-auth `?app=`. Derived from `hostedUrl` when
* omitted (subdomain `/{leaf}` or app-origin `/@owner/packages/{leaf}`).
*/
app?: string | null
/**
* Publisher org / username handle for host-auth `?org=` on app-origin /
* preview. Alias of `username`. Derived from `hostedUrl` when omitted.
*/
org?: string | null
/**
* Alias of `org` (platform accepts `org`, `username`, or `owner`).
*/
username?: string | null
/** Override `fetch` (tests, service bindings). Defaults to global `fetch`. */
fetch?: (request: Request) => Promise<Response>
}
export type PackageAppAuthUrlInput = {
/**
* Where to land afterwards: a host-root path (`/notes?tab=1`) or an
* absolute URL on this host. Defaults to the platform's own default.
*/
returnTo?: string | null
/** Request used to infer the origin when `origin` / `hostedUrl` are unset. */
request?: Request
}
export type PackageAppAuthLoginInput = PackageAppAuthUrlInput & {
/** Replaces the helper-level `scopes` for this URL only. */
scopes?: ReadonlyArray<PackageAppAuthScope>
}
export type PackageAppAuth = {
readonly mode: PackageAppAuthMode
readonly scopes: ReadonlyArray<PackageAppAuthScope>
/** `/-/auth/login?app=…&return_to=…` — absolute when an origin is known, else host-root path. */
loginUrl(input?: PackageAppAuthLoginInput): string
/** `/-/auth/logout?app=…&return_to=…` — works as a link (GET) or form action (POST). */
logoutUrl(input?: PackageAppAuthUrlInput): string
/** `/-/auth/me?app=…` (plus `org` on app-origin when known). */
meUrl(input?: Pick<PackageAppAuthUrlInput, 'request'>): string
/**
* Current user, or `null` when anonymous. In package-app server code this is
* the platform's verified `X-Kody-Visitor` header (no network). Otherwise it
* forwards the request's Cookie to `/-/auth/me`.
*/
getUser(request: Request): Promise<PackageAppUser | null>
/** 302 to login, returning to the request's path + search. Use `throw` in loaders. */
redirectToLogin(request: Request, input?: Omit<PackageAppAuthLoginInput, 'request'>): Response
/** The signed-in user, or throws `redirectToLogin(request)` when anonymous. */
requireUser(request: Request): Promise<PackageAppUser>
/** Honors `mode`: `'required'` behaves like `requireUser`, `'optional'` like `getUser`. */
resolveUser(request: Request): Promise<PackageAppUser | null>
}
/** Thrown by `getUser` when `/-/auth/me` fails for a reason other than "not signed in". */
export class PackageAppAuthError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = 'PackageAppAuthError'
this.status = status
}
}
type PackageAppAuthIdentity = {
app: string | null
org: string | null
/** True when identity came from (or serves on) a path-based `/@owner/packages/…` mount. */
appOriginMount: boolean
}
function safeOrigin(value: string | null | undefined): string | null {
if (!value) return null
try {
return new URL(value).origin
} catch {
return null
}
}
function normalizeHandle(value: string | null | undefined): string | null {
if (!value) return null
const trimmed = value.trim().toLowerCase()
return trimmed || null
}
function decodePathSegment(raw: string): string | null {
try {
return decodeURIComponent(raw)
} catch {
return null
}
}
/**
* Derive package leaf + publisher handle from a hosted mount URL.
* Supports subdomain short (`https://owner.kody.run/leaf`) and legacy
* (`https://owner.kody.run/packages/leaf`) mounts, plus app-origin path mounts
* (`https://kody.codes/@owner/packages/leaf`).
*/
export function parsePackageAppAuthIdentityFromHostedUrl(
hostedUrl: string | null | undefined,
): PackageAppAuthIdentity {
if (!hostedUrl) return { app: null, org: null, appOriginMount: false }
try {
const url = new URL(hostedUrl)
const parts = url.pathname.split('/').filter(Boolean)
if (parts[0]?.startsWith('@') && parts[1] === 'packages' && parts[2]) {
const org = normalizeHandle(decodePathSegment(parts[0].slice(1)))
const app = normalizeHandle(decodePathSegment(parts[2]))
return { app, org, appOriginMount: true }
}
if (parts[0] === 'packages' && parts[1]) {
const app = normalizeHandle(decodePathSegment(parts[1]))
const hostLabel = url.hostname.split('.')[0] ?? ''
const org = normalizeHandle(hostLabel)
return { app, org, appOriginMount: false }
}
// Short subdomain mount: https://owner.kody.run/{leaf}
if (parts.length >= 1 && parts[0] && parts[0] !== 'packages' && !parts[0].startsWith('@') && parts[0] !== '-') {
const app = normalizeHandle(decodePathSegment(parts[0]))
const hostLabel = url.hostname.split('.')[0] ?? ''
const org = normalizeHandle(hostLabel)
return { app, org, appOriginMount: false }
}
return { app: null, org: null, appOriginMount: false }
} catch {
return { app: null, org: null, appOriginMount: false }
}
}
function resolveIdentity(options: PackageAppAuthOptions): PackageAppAuthIdentity {
const fromUrl = parsePackageAppAuthIdentityFromHostedUrl(options.hostedUrl)
const app = normalizeHandle(options.app) ?? fromUrl.app
const org =
normalizeHandle(options.org) ??
normalizeHandle(options.username) ??
fromUrl.org
return {
app,
org,
appOriginMount: fromUrl.appOriginMount,
}
}
/**
* Host-auth on `*.kody.run` user subdomains only needs `app` (org is the Host
* label). App-origin / preview (path-based `/@owner/packages/…`, or any host
* that is not `{org}.…`) also needs `org`/`username`.
*/
function shouldAttachOrg(
identity: PackageAppAuthIdentity,
request: Request | undefined,
configuredOrigin: string | null,
): boolean {
if (!identity.org) return false
if (identity.appOriginMount) return true
const originStr =
configuredOrigin ?? (request ? new URL(request.url).origin : null)
if (!originStr) {
// Relative URLs with no known host: include org when we have it so
// preview / inline mounts still work.
return true
}
try {
const host = new URL(originStr).hostname.toLowerCase()
if (host === identity.org || host.startsWith(`${identity.org}.`)) {
return false
}
return true
} catch {
return true
}
}
function identityQueryParams(
identity: PackageAppAuthIdentity,
request: Request | undefined,
configuredOrigin: string | null,
): Record<string, string | null | undefined> {
const params: Record<string, string | null | undefined> = {
app: identity.app,
}
if (shouldAttachOrg(identity, request, configuredOrigin)) {
params.org = identity.org
}
return params
}
function normalizeScopes(
scopes: ReadonlyArray<PackageAppAuthScope> | undefined,
): Array<PackageAppAuthScope> {
return [...new Set(scopes ?? [])]
}
function parseUser(value: unknown): PackageAppUser {
if (!value || typeof value !== 'object') {
throw new PackageAppAuthError('Invalid /-/auth/me response', 502)
}
const raw = value as Record<string, unknown>
if (typeof raw.userId !== 'string' || !raw.userId) {
throw new PackageAppAuthError('Invalid /-/auth/me response', 502)
}
const user: PackageAppUser = {
userId: raw.userId,
username: typeof raw.username === 'string' ? raw.username : '',
displayName:
typeof raw.displayName === 'string' ? raw.displayName : String(raw.username ?? ''),
role: typeof raw.role === 'string' ? raw.role : 'user',
}
if (typeof raw.avatarUrl === 'string' && raw.avatarUrl) user.avatarUrl = raw.avatarUrl
if (typeof raw.email === 'string' && raw.email) user.email = raw.email
return user
}
/**
* The verified visitor the platform put on a package-app request, or `null`
* when the header is absent or malformed. `displayName` is the username (the
* header carries no display name); `role` is `'user'` for visitors who are not
* members of the owning org, matching `/-/auth/me`.
*/
export function readPackageAppVisitorHeader(request: Request): PackageAppUser | null {
const raw = request.headers.get(PACKAGE_APP_VISITOR_HEADER)
if (!raw) return null
let value: unknown
try {
value = JSON.parse(raw)
} catch {
return null
}
if (!value || typeof value !== 'object') return null
const header = value as Record<string, unknown>
if (typeof header.userId !== 'string' || !header.userId) return null
const username = typeof header.username === 'string' ? header.username : ''
return {
userId: header.userId,
username,
displayName: username,
role: typeof header.role === 'string' && header.role ? header.role : 'user',
}
}
export function createPackageAppAuth(options: PackageAppAuthOptions = {}): PackageAppAuth {
const mode: PackageAppAuthMode = options.mode ?? 'optional'
const scopes = normalizeScopes(options.scopes)
const configuredOrigin = safeOrigin(options.origin) ?? safeOrigin(options.hostedUrl)
const identity = resolveIdentity(options)
const doFetch = options.fetch ?? ((request: Request) => fetch(request))
function originFor(request: Request | undefined): string {
if (configuredOrigin) return configuredOrigin
return request ? new URL(request.url).origin : ''
}
function build(
path: string,
params: Record<string, string | null | undefined>,
request: Request | undefined,
): string {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value) search.set(key, value)
}
const query = search.toString()
return `${originFor(request)}${path}${query ? `?${query}` : ''}`
}
function loginUrl(input: PackageAppAuthLoginInput = {}): string {
const scope = normalizeScopes(input.scopes ?? scopes).join(' ')
return build(
PACKAGE_APP_AUTH_LOGIN_PATH,
{
...identityQueryParams(identity, input.request, configuredOrigin),
return_to: input.returnTo,
scope,
},
input.request,
)
}
function logoutUrl(input: PackageAppAuthUrlInput = {}): string {
return build(
PACKAGE_APP_AUTH_LOGOUT_PATH,
{
...identityQueryParams(identity, input.request, configuredOrigin),
return_to: input.returnTo,
},
input.request,
)
}
function meUrl(input: Pick<PackageAppAuthUrlInput, 'request'> = {}): string {
return build(
PACKAGE_APP_AUTH_ME_PATH,
identityQueryParams(identity, input.request, configuredOrigin),
input.request,
)
}
async function getUser(request: Request): Promise<PackageAppUser | null> {
if (request.headers.has(PACKAGE_APP_VISITOR_HEADER)) {
return readPackageAppVisitorHeader(request)
}
const cookie = request.headers.get('Cookie')
if (!cookie) return null
const response = await doFetch(
new Request(meUrl({ request }), {
method: 'GET',
headers: { Accept: 'application/json', Cookie: cookie },
redirect: 'manual',
}),
)
if (response.status === 401) return null
if (!response.ok) {
throw new PackageAppAuthError(
`/-/auth/me responded ${response.status}`,
response.status,
)
}
return parseUser(await response.json())
}
function redirectToLogin(
request: Request,
input: Omit<PackageAppAuthLoginInput, 'request'> = {},
): Response {
const url = new URL(request.url)
const location = loginUrl({
...input,
request,
returnTo: input.returnTo ?? `${url.pathname}${url.search}`,
})
return new Response(null, {
status: 302,
headers: { Location: location, 'Cache-Control': 'no-store' },
})
}
async function requireUser(request: Request): Promise<PackageAppUser> {
const user = await getUser(request)
if (!user) throw redirectToLogin(request)
return user
}
async function resolveUser(request: Request): Promise<PackageAppUser | null> {
switch (mode) {
case 'required':
return requireUser(request)
case 'optional':
return getUser(request)
default: {
const unreachable: never = mode
throw new Error(`Unknown auth mode: ${String(unreachable)}`)
}
}
}
return {
mode,
scopes,
loginUrl,
logoutUrl,
meUrl,
getUser,
redirectToLogin,
requireUser,
resolveUser,
}
}