/** * 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 /** * 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 } 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 } export type PackageAppAuth = { readonly mode: PackageAppAuthMode readonly scopes: ReadonlyArray /** `/-/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): 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 /** 302 to login, returning to the request's path + search. Use `throw` in loaders. */ redirectToLogin(request: Request, input?: Omit): Response /** The signed-in user, or throws `redirectToLogin(request)` when anonymous. */ requireUser(request: Request): Promise /** Honors `mode`: `'required'` behaves like `requireUser`, `'optional'` like `getUser`. */ resolveUser(request: Request): Promise } /** 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 { const params: Record = { app: identity.app, } if (shouldAttachOrg(identity, request, configuredOrigin)) { params.org = identity.org } return params } function normalizeScopes( scopes: ReadonlyArray | undefined, ): Array { 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 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 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, 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 = {}): string { return build( PACKAGE_APP_AUTH_ME_PATH, identityQueryParams(identity, input.request, configuredOrigin), input.request, ) } async function getUser(request: Request): Promise { 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 = {}, ): 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 { const user = await getUser(request) if (!user) throw redirectToLogin(request) return user } async function resolveUser(request: Request): Promise { 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, } }