ugly-app 0.1.948 → 0.1.949

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # ugly-app
2
2
 
3
- A full-stack TypeScript framework for shipping production web apps. Scaffold
4
- with `npx ugly-app init my-app` and get an opinionated Node + React + Postgres
5
- stack with type-safe RPC over WebSocket and HTTP, real-time doc subscriptions,
6
- built-in auth, AI generation, storage, workers, cron, and a CLI for every
7
- workflow.
3
+ A full-stack TypeScript framework for shipping production web apps.
4
+ Scaffold with `npx ugly-app init my-app` and get an opinionated Node +
5
+ React + Postgres stack with type-safe RPC over WebSocket and HTTP,
6
+ real-time doc subscriptions, built-in auth, AI generation, storage,
7
+ workers, cron, and a CLI for every workflow.
8
8
 
9
9
  ugly-app is designed to run against [ugly.bot](https://ugly.bot), which
10
- provides auth, infra (Postgres, Qdrant, NATS, S3-compatible object storage),
11
- AI provider keys, push, email, and deployment. Your app talks to all of it
12
- through the project's dev tunnel and its `UGLY_BOT_TOKEN`.
10
+ provides auth, infra (Postgres, Qdrant, NATS, S3-compatible object
11
+ storage), AI provider keys, push, email, and deployment. Your app talks
12
+ to all of it through the project's dev tunnel and its `UGLY_BOT_TOKEN`.
13
13
 
14
14
  ```bash
15
15
  npx ugly-app init my-app
@@ -44,8 +44,8 @@ npm run dev
44
44
 
45
45
  ## Server — `createApp()`
46
46
 
47
- Single server entry point. Returns an `App` that owns Express, the WebSocket
48
- server, the typed DB, and the RPC dispatcher.
47
+ Single server entry point. Returns an `App` that owns Express, the
48
+ WebSocket server, the typed DB, and the RPC dispatcher.
49
49
 
50
50
  ```ts
51
51
  // server/index.ts
@@ -103,31 +103,29 @@ function createApp<
103
103
  ): App<CollectionMap<typeof BUILTIN_DEFS & Defs>, RegistryPages<R>>;
104
104
  ```
105
105
 
106
- Passing `pages` in the registry (`{ requests, messages, pages }`) upgrades
107
- `app.pushSend()` to a per-route typed API. `pages` is optional; apps that only
108
- call `configurator.setPages()` still work — they just get the loose
109
- `PageRegistry` typing on `pushSend`.
106
+ Passing `pages` in the registry (`{ requests, messages, pages }`)
107
+ upgrades `app.pushSend()` to a per-route typed API. `pages` is optional;
108
+ apps that only call `configurator.setPages()` still work — they just get
109
+ the loose `PageRegistry` typing on `pushSend`.
110
110
 
111
111
  ### The returned `App`
112
112
 
113
- `App` extends `AppRouter`, so `dispatch(name, input, userId)` is available for
114
- in-process invocation of any registered handler.
115
-
116
- | Member | Description |
117
- |--------|-------------|
113
+ | Field | Description |
114
+ |-------|-------------|
118
115
  | `start(port?)` | Start the server (default 3000; templates use 4321). |
119
- | `db` | `TypedDB<Map>` — map inferred from your collections + framework built-ins. |
116
+ | `db` | The `TypedDB<Map>` (map inferred from your collections + framework built-ins). |
120
117
  | `httpServer` | The underlying Node `http.Server`. |
121
118
  | `wss` | The main `WebSocketServer` (path set via `setWsPath`, default `/rpc`). |
122
- | `dispatch(name, input, userId)` | `Promise<unknown>` — invoke any registered RPC handler programmatically. |
119
+ | `dispatch(name, input, userId)` | Invoke a registered RPC handler programmatically (inherited from `AppRouter`). |
123
120
  | `registerRoutes(fn)` | Mount additional Express routes after creation. |
124
- | `pushSend(input)` | Typed push whose click-through target is a route from this app's `pages`. Mints a `https://ugly.bot/l/<code>` short link so the URL is always absolute and dock-app-routable. |
121
+ | `pushSend(input)` | Typed push whose click-through target is a route from this app's `pages`. The framework mints a `https://ugly.bot/l/<code>` short link so the click-through is always absolute and dock-app-routable. |
125
122
 
126
- Framework services start inside `app.start()`: schema drift check, NATS
127
- connection + KV buckets, data-proxy connection, event-counter flush, TTL
128
- cleanup for log tables, console/error capture, and ugly.bot log forwarding.
129
- Postgres, NATS, storage, and AI clients load **lazily** — a host without
130
- `DATABASE_URL` / `NATS_URL` / `R2_BUCKET` doesn't pay their startup cost.
123
+ Framework services start automatically inside `app.start()`: schema
124
+ drift check, NATS connection + KV buckets, data-proxy connection,
125
+ event-counter flush, TTL cleanup for log tables, console/error capture,
126
+ and ugly.bot log forwarding. Postgres, NATS, storage, and AI clients
127
+ are loaded **lazily** — a host without `DATABASE_URL` / `NATS_URL` /
128
+ `R2_BUCKET` doesn't pay their startup cost.
131
129
 
132
130
  ### `AppConfigurator`
133
131
 
@@ -158,8 +156,8 @@ Every method is optional; `setPages` is what mounts the SPA.
158
156
 
159
157
  ### Handler signatures
160
158
 
161
- Handlers are plain async functions — no context object. Access state via
162
- captured imports (`app.db`, `storage`, `pgQuery`, `uglyBotRequest`, …).
159
+ Handlers are plain async functions — no context object. Access state
160
+ via captured imports (`app.db`, `storage`, `pgQuery`, `uglyBotRequest`, …).
163
161
 
164
162
  ```ts
165
163
  // req() — public, userId may be null
@@ -171,37 +169,30 @@ getMe: async (userId: string, input) => { /* … */ }
171
169
 
172
170
  ### Built-in framework requests
173
171
 
174
- `createApp` registers several framework handlers reachable from any client via
175
- the normal RPC pipeline. App-provided handlers with the same name override the
176
- framework's defaults. Rate-limited handlers are marked below.
172
+ `createApp` registers several framework handlers reachable from any
173
+ client via the normal RPC pipeline. App-provided handlers with the same
174
+ name override the framework's defaults.
177
175
 
178
- | Name | Notes |
179
- |------|-------|
176
+ | Name | Purpose |
177
+ |------|---------|
180
178
  | `userGet` | Returns `{ userId, name, avatarUri }` for the given user (or caller). |
181
179
  | `initSession` / `captureEvent` | Session + event logging tagged with experiment branches (public — no auth). |
182
- | `textGen` | AI text proxy — server-validated, billed through ugly.bot. Rate-limited 20/60s. |
183
- | `imageGen` | AI image proxy. Rate-limited 10/60s. |
184
- | `kagiSearch` / `kagiEnrichWeb` / `kagiEnrichNews` | Web search via ugly.bot. Rate-limited 20/60s. |
185
- | `kagiSummarize` | URL / text summary. Rate-limited 10/60s. |
180
+ | `textGen` / `imageGen` | AI proxies — server-validated, billed through ugly.bot. |
181
+ | `kagiSearch` / `kagiSummarize` / `kagiEnrichWeb` / `kagiEnrichNews` | Web search via ugly.bot. |
186
182
  | `uploadUrl` | Issues a presigned PUT for the `temp` bucket. |
187
- | `shareLink` | Mint a `https://ugly.bot/l/<code>` short link with OG metadata. Rate-limited 60/60s. |
188
- | `feedbackReportCreateNoAuth` | Public, same-origin feedback endpoint used by browser telemetry. Rate-limited 10/60s. |
189
- | `errorLogCaptureNoAuth` | Public error-log capture. |
190
- | `perfSnapshotCaptureNoAuth` | Public perf snapshot capture. Rate-limited 60/60s. |
191
- | `submitFeedbackBot` | Bot-persona feedback submission. |
192
- | `feedbackReportResolve` | Admin resolve/decline. Rate-limited 120/60s. |
183
+ | `shareLink` | Mint a `https://ugly.bot/l/<code>` short link with OG metadata. |
184
+ | `feedbackReportCreateNoAuth` / `errorLogCaptureNoAuth` / `perfSnapshotCaptureNoAuth` | Same-origin, public endpoints used by browser telemetry. |
185
+ | `submitFeedbackBot` / `feedbackReportResolve` | Bot-persona feedback submission; admin resolve/decline. |
193
186
  | `adminGetPerfLogs` | Admin-only perf telemetry read. |
194
187
  | `adminCreateTestUser` / `adminListTestUsers` / `adminDeleteTestUser` | Admin-only synthetic-user management. Gated by `setIsAdmin()`. |
195
188
  | `projectPlanList` / `projectPlanCreate` / `projectPlanUpdate` / `projectPlanDelete` | Project plan CRUD used by Studio. |
196
189
 
197
- Add per-endpoint rate limits on your own handlers with `rateLimit: { max, window }` on the request definition (see below).
198
-
199
190
  ---
200
191
 
201
192
  ## Shared API definitions
202
193
 
203
- `shared/` is consumed by both server and client. Keep all Zod schemas, types,
204
- collections, and route declarations here.
194
+ `shared/` is consumed by both server and client. Keep all Zod schemas,
195
+ types, collections, and route declarations here.
205
196
 
206
197
  ### Requests (`shared/api.ts`)
207
198
 
@@ -231,8 +222,8 @@ export const requests = defineRequests({
231
222
  ```
232
223
 
233
224
  Every request is reachable as **both** `socket.request(name, input)`
234
- (WebSocket) and `POST /api/:name { input }` (HTTP). `z` is re-exported from
235
- Zod for convenience.
225
+ (WebSocket) and `POST /api/:name { input }` (HTTP). `z` is re-exported
226
+ from Zod for convenience.
236
227
 
237
228
  ### Collections (`shared/collections.ts`)
238
229
 
@@ -268,19 +259,20 @@ export const collections = defineCollections({
268
259
  - `public` — allow unauthenticated client reads.
269
260
  - `cascadeFrom` — parent collection: when the parent doc is deleted, docs in this collection are cascade-deleted.
270
261
  - `trackKeys?` — fields usable as NATS routing keys for scoped `trackDocs` subscriptions.
271
- - `getter?(ids)` — batch resolver for collections with no local table (e.g. `userPublic` resolves via ugly.bot).
262
+ - `getter?(ids)` — batch resolver for collections with no local table (framework pattern; e.g. `userPublic` resolves via ugly.bot).
272
263
  - `skipReadValidation?` — opt out of per-row zod validation on reads for very hot collections. Writes are always validated. Global kill-switch: `UGLY_DB_VALIDATE_READS=0`.
273
264
  - `search?: { fields, language? }` — full-text index over the named JSONB paths (Neon: Postgres FTS; D1: SQLite FTS5, ranked by bm25).
274
265
  - `vector?: { dimensions, metric?, filterable? }` — ANN index. Vector supplied out-of-band at write time (`setDoc(c, doc, { vec })`) — never stored in the doc JSON. Query with `getDocs(c, filter, { near })`.
275
266
 
276
267
  All documents extend `DBObject`: `{ _id, version, created, updated }`.
277
- Use `dbDefaults()` to stamp `version` / `created` / `updated` on inserts.
278
- **Always generate `_id` with `nanoid()`** — never `crypto.randomUUID()`,
279
- `Date.now()`, or `Math.random()` (see `CLAUDE.md`).
268
+ Use `dbDefaults()` to stamp `version` / `created` / `updated` on
269
+ inserts. **Always generate `_id` with `nanoid()`** — never
270
+ `crypto.randomUUID()`, `Date.now()`, or `Math.random()` (see
271
+ `CLAUDE.md`).
280
272
 
281
- After schema changes, run `npm run db:schema-gen` then `npm run db:migrate`.
282
- The app refuses to start when drift is detected (set `SCHEMA_CHECK_SKIP=true`
283
- only as a last resort).
273
+ After schema changes, run `npm run db:schema-gen` then `npm run
274
+ db:migrate`. The app refuses to start when drift is detected (set
275
+ `SCHEMA_CHECK_SKIP=true` only as a last resort).
284
276
 
285
277
  ---
286
278
 
@@ -303,20 +295,23 @@ export const pages = definePages({
303
295
  export type AppPages = typeof pages;
304
296
  ```
305
297
 
306
- `definePage<Params>(options?)` returns a `PageDef<Params>` — a runtime object
307
- carrying `PageMeta` plus a phantom `_params` used only for TypeScript
308
- inference. `PageMeta` fields:
298
+ `definePage<Params>(options?)` returns a `PageDef<Params>` — a runtime
299
+ object carrying `PageMeta` plus a phantom `_params` used only for
300
+ TypeScript inference.
301
+
302
+ **Options** (all optional):
309
303
 
310
- - `auth` (default `true`) — protected route. When `isAuthenticated()` returns false on this route, the router synchronously renders the framework's `<AuthRoot>` in place of the page (Mode A → `<LoginPopup>`, Mode B → `<MagicLinkForm>` plus optional Google button). Apps cannot override this fallback.
311
- - `ssr` (default `false`) — server-render the page for SEO. `{ auth: true, ssr: true }` is silently downgraded to `ssr: false` with a warning: the SSR document is served from a shared edge cache and must not depend on the viewer.
304
+ - `auth` (default `true`) — protected route. When `isAuthenticated()` returns false on this route, the router synchronously renders the framework's `<AuthRoot>` in place of the page (dispatches on `window.__UGLY_APP_AUTH_MODE__`: Mode A → `<LoginPopup>`, Mode B → `<MagicLinkForm>` plus optional Google button). Apps cannot override this fallback — the framework owns the login UX so no route can accidentally ship as auth-required with no way to log in.
305
+ - `ssr` (default `false`) — server-render the page for SEO. `{ auth: true, ssr: true }` is silently dropped to `ssr: false` with a warning: the SSR document is served from a shared edge cache and must not depend on the viewer.
312
306
  - `cacheQuery?: string[]` — query params that participate in the SSR edge-cache key. Anything NOT listed bypasses the cache instead of poisoning it (e.g. `cacheQuery: ['q']` on a search page).
313
- - `ssrCacheTimeout?: number` — edge cache lifetime in seconds. Default is `DEFAULT_SSR_CACHE_TIMEOUT` (1 year) because the `buildId` is already part of the cache key. Override only for pages that drift between deploys.
307
+ - `ssrCacheTimeout?: number` — edge cache lifetime in seconds. Default is effectively 1 year (`DEFAULT_SSR_CACHE_TIMEOUT`) because the `buildId` is part of the cache key. Override only for pages that drift between deploys.
314
308
 
315
309
  Path syntax: `:param` matches a single path segment; `*param` is greedy
316
- (captures slashes). Query-string params are declared in `Params` but never
317
- appear in the path template.
310
+ (captures slashes). Query-string params are declared in `Params` but
311
+ never appear in the path template.
318
312
 
319
- `definePages<T>(p)` is a pass-through identity — use it to name the registry.
313
+ `definePages<T>(p)` is a pass-through identity — use it to give the
314
+ registry a name.
320
315
 
321
316
  ### `createRouter()` (`ugly-app/client`)
322
317
 
@@ -337,23 +332,23 @@ export const {
337
332
  **Config:**
338
333
  - `pages` (required) — the `PageRegistry` from `shared/pages.ts`.
339
334
  - `allPages?` — the lazy route → element map (`PageMap<Pages>`). Prefer registering via `setAllPages()` from the browser entry when the app also server-renders — the Worker bundle has no code splitting, so a static import inlines every page (and its deps) into `worker.js`.
340
- - `ssrPages?: SsrPageMap<Pages>` — `Partial<{ [K]: ComponentType<Params> }>` of statically-imported components for `ssr: true` routes. Used by both the Worker's `renderToString` and the client's first hydration render so the initial paint resolves synchronously and never mismatches the server output.
335
+ - `ssrPages?` — `Partial<{ [K]: ComponentType<Params> }>` of statically-imported components for `ssr: true` routes. Used by both the Worker's `renderToString` and the client's first hydration render so the initial paint resolves synchronously and never mismatches the server output.
341
336
 
342
337
  **Returns:**
343
338
 
344
- - **`RouterProvider`** — props: `children`, `fallback?`, `isAuthenticated?: () => boolean`, `initialUrl?: { pathname, search }` (required for SSR + hydration). Manages route state, browser history, and the popup layer. Runs the per-route auth check synchronously — apps cannot override the login fallback.
345
- - **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing?: EasingFunction`, `transitionComponent?: React.ComponentType<RouterTransitionProps>` (replaces the default `ViewFlipper`), `renderPage?(state: RouterStateRaw) => ReactElement` (sync alternative to `allPages` loaders).
339
+ - **`RouterProvider`** — props: `children`, `fallback?`, `isAuthenticated?`, `initialUrl?` (`{ pathname, search }`, required for SSR + hydration). Manages route state, browser history, and the popup layer. Runs the per-route auth check synchronously — apps cannot override the login fallback.
340
+ - **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing?`, `transitionComponent?` (a `React.ComponentType<RouterTransitionProps>` that replaces the default `ViewFlipper`), `renderPage?(state) => ReactElement` (sync alternative to `allPages` loaders, called with `RouterStateRaw`).
346
341
  - **`useRouter()`** — returns `RouterContextValue<Pages>` (typed navigation + popup API). Throws if used outside `<RouterProvider>`.
347
342
  - **`Link`** — typed SPA link bound to this router. Renders a real `<a>` (right-click / cmd-click / SEO keep working) but intercepts a plain left-click and routes through `push` / `replace`. Props: `to`, `params` (both type-checked against `pages`), `replace?`, `children`, plus common anchor attrs (`className`, `style`, `target`, `aria-label`, `data-id`, `onClick`).
348
343
  - **`setAllPages(map)`** — register the lazy `PageMap<Pages>` after construction (typically from the browser entry, so the Worker's import graph stays free of every lazy page).
349
344
 
350
- A standalone `Link` is also exported from `ugly-app/client` for code that
351
- can't easily reach the typed one; it accepts an optional `router` prop and
352
- falls back to `<RouterProvider>` context.
345
+ A standalone `Link` is also exported from `ugly-app/client` for code
346
+ that can't easily reach the typed one; it accepts an optional `router`
347
+ prop and falls back to `<RouterProvider>` context.
353
348
 
354
- **Never use a bare `<a href="/route">`** for internal navigation — it triggers
355
- a full document reload (white flash + repaint). See the "client navigation"
356
- rule in `CLAUDE.md`.
349
+ **Never use a bare `<a href="/route">`** for internal navigation — it
350
+ triggers a full document reload (white flash + repaint). See the "client
351
+ navigation" rule in `CLAUDE.md`.
357
352
 
358
353
  ### Page map — `lazyPage` / `lazyPageLoader`
359
354
 
@@ -374,9 +369,10 @@ export const allPages = {
374
369
  - **`lazyPageLoader(factory)`** — lazy-imports an async loader `(params) => Promise<ReactElement>`. Use when a route needs data fetching before render. The loader file is the chunk boundary, so it can statically import its page component.
375
370
 
376
371
  Both wrappers recover from stale-deploy chunk 404s ("Failed to fetch
377
- dynamically imported module"): on the first failure they trigger a single
378
- `window.location.reload()` (guarded by `sessionStorage` so a genuinely broken
379
- chunk can't loop) to fetch fresh chunk hashes.
372
+ dynamically imported module"): on the first failure they trigger a
373
+ single `window.location.reload()` (guarded by `sessionStorage` so a
374
+ genuinely broken chunk can't loop) to fetch the fresh `index.html` with
375
+ new chunk hashes.
380
376
 
381
377
  ```ts
382
378
  // pages/SlowPageLoader.tsx
@@ -406,15 +402,15 @@ replace('search', { q: 'hello' }); // → /search?q=hello
406
402
  back(); // browser history back
407
403
  ```
408
404
 
409
- Route names and params are fully typed against `pages`. `push` / `replace`
410
- no-op with a `console.error` when `buildUrl()` produces a URL that doesn't
411
- match a registered route.
405
+ Route names and params are fully typed against `pages`. `push` /
406
+ `replace` no-op with a `console.error` when `buildUrl()` produces a URL
407
+ that doesn't match a registered route.
412
408
 
413
409
  ### Popups — `openPopup()`
414
410
 
415
- `useRouter().openPopup()` is the canonical modal / sheet / menu API. The
416
- router owns the popup layer, drives a spring animation, and stacks popups
417
- z-index-correctly above every page.
411
+ `useRouter().openPopup()` is the canonical modal / sheet / menu API.
412
+ The router owns the popup layer, drives a spring animation, and stacks
413
+ popups z-index-correctly above every page.
418
414
 
419
415
  ```tsx
420
416
  const { openPopup } = useRouter();
@@ -439,20 +435,20 @@ handle.hide(); // dismiss programmatically — same as router.closePopup(handle.
439
435
  - **`contextMenu`** — same as transient, intended for menus and pickers.
440
436
 
441
437
  `renderLayer` receives `{ content, spring, hide }`: `spring` is a
442
- `createAnimatedValue()` result driving 0 → 1; `hide` closes the popup. The
443
- default layer animates open with `easeOut` (250 ms) and closed with `easeIn`
444
- (200 ms). Popups render as siblings of the router's children (managed inside
445
- `RouterProvider`), so they stack above every page.
438
+ `createAnimatedValue()` result driving 0 → 1; `hide` closes the popup.
439
+ The default layer animates open with `easeOut` (250 ms) and closed with
440
+ `easeIn` (200 ms). Popups render as siblings of the router's children
441
+ (managed inside `RouterProvider`), so they stack above every page.
446
442
 
447
443
  ### Scroll containers
448
444
 
449
445
  `html, body, #root` are `overflow: hidden` — the document itself never
450
- scrolls, so a page that just renders tall content is clipped. For pages that
451
- own their scrolling (or render outside the normal authed chrome — e.g. a
452
- public page special-cased before the auth gate), wrap the content in
453
- `SimpleScrollView` (exported from `ugly-app/client`). For long / virtualized
454
- lists with scroll-position persistence, use the richer `ScrollView`. See the
455
- "page scrolling" rule in `CLAUDE.md`.
446
+ scrolls, so a page that just renders tall content is clipped. For pages
447
+ that own their scrolling (or render outside the normal authed chrome —
448
+ e.g. a public page special-cased before the auth gate), wrap the
449
+ content in `SimpleScrollView` (exported from `ugly-app/client`). For
450
+ long / virtualized lists with scroll-position persistence, use the
451
+ richer `ScrollView`. See the "page scrolling" rule in `CLAUDE.md`.
456
452
 
457
453
  ---
458
454
 
@@ -475,9 +471,6 @@ bootstrapApp({
475
471
  });
476
472
  ```
477
473
 
478
- `bootstrapApp(options): void` — returns nothing; it renders directly to the
479
- DOM.
480
-
481
474
  **`BootstrapAppOptions`:**
482
475
 
483
476
  | Field | Description |
@@ -495,28 +488,28 @@ DOM.
495
488
  | `silentSso?` | Apex-domain apps that use ugly.bot as their auth authority but don't share its cookie. When `true`, a logged-out boot attempts a top-level SSO redirect (once per tab) to adopt an existing ugly.bot session. Leave unset for `*.ugly.bot` subdomains and Mode B apps. |
496
489
  | `testMode?` | Skip ugly.bot silent SSO and trust the auth cookie the fixture set. Only honored when `UGLY_APP_TEST_MODE=1`. |
497
490
 
498
- `bootstrapApp` reads `window.__AUTH_TOKEN__` (injected by the server after
499
- cookie verification). If absent, it renders unauthenticated immediately — the
500
- router's per-route auth guard surfaces `<AuthRoot>` only when the user opens a
501
- protected route. If the token is present, it connects the socket, mounts
502
- `<AppProvider>`, and renders.
491
+ `bootstrapApp` reads `window.__AUTH_TOKEN__` (injected by the server
492
+ after cookie verification). If absent, it renders unauthenticated
493
+ immediately — the router's per-route auth guard surfaces `<AuthRoot>`
494
+ only when the user opens a protected route. If the token is present, it
495
+ connects the socket, mounts `<AppProvider>`, and renders.
503
496
 
504
497
  After render, a hidden iframe silently calls
505
- `${UGLY_BOT_URL}/oauth/silent`: if it returns a fresh code, the cookie is
506
- refreshed via `POST /auth/verify`; if the returned account differs from the
507
- current session, the page reloads onto the live account (guarded once per
508
- from→to pair to prevent loops). Any `?ugly_oauth_code=…` in the URL
509
- (redirect-fallback OAuth code from ugly.bot) is redeemed at the top of
510
- bootstrap before anything renders.
498
+ `${UGLY_BOT_URL}/oauth/silent`: if it returns a fresh code, the cookie
499
+ is refreshed via `POST /auth/verify`; if the returned account differs
500
+ from the current session, the page reloads onto the live account
501
+ (guarded once per from→to pair to prevent loops). Any
502
+ `?ugly_oauth_code=…` in the URL (redirect-fallback OAuth code from
503
+ ugly.bot) is redeemed at the top of bootstrap before anything renders.
511
504
 
512
- If `bootstrapApp` is loaded at `/auth/magic-link/verify` (Mode B), it renders
513
- `<MagicLinkCallback>` instead of the regular app shell.
505
+ If `bootstrapApp` is loaded at `/auth/magic-link/verify` (Mode B), it
506
+ renders `<MagicLinkCallback>` instead of the regular app shell.
514
507
 
515
508
  ### `AppProvider` & `useApp()`
516
509
 
517
- `bootstrapApp` mounts `<AppProvider>` automatically after socket connect. Use
518
- `useApp()` inside any page to access the active user, socket, and app-scoped
519
- services.
510
+ `bootstrapApp` mounts `<AppProvider>` automatically after socket
511
+ connect. Use `useApp()` inside any page to access the active user,
512
+ socket, and app-scoped services.
520
513
 
521
514
  ```ts
522
515
  const {
@@ -524,26 +517,28 @@ const {
524
517
  user, // UserBase doc
525
518
  socket, // AppSocket — typed RPC client
526
519
  uglyBotSocket, // UglyBotSocket | null — direct platform socket for STT/TTS
527
- showPopup, // (content: ReactElement) => id — flat overlay layer (see note)
528
- hidePopup, // (id: string) => void
529
- hideAllPopups, // () => void
520
+ showPopup, // flat overlay layer (see note) — returns popup id
521
+ hidePopup,
522
+ hideAllPopups,
530
523
  runAsync, // (label, async () => {…}, options?) — shows loading overlay
531
- splashDone, // (step: string) => void — mark a splash-screen step complete
524
+ splashDone, // (step: string) — mark a splash-screen step complete
532
525
  localizer, // (key, params?) => string — alias for useLocalizer()
533
526
  } = useApp();
534
527
  ```
535
528
 
536
- `useApp<TAsyncOptions>()` is generic in the async-options type so apps can
537
- pass a custom option through to a custom `loadingOverlay` element. When
538
- `runAsync` is pending, `AppProvider` `cloneElement`s the overlay with
539
- `{ label, asyncOptions }`. `useAppOptional()` returns `null` outside the
540
- provider; `useLocalizer()` prefers `<StringsProvider>` data, falls back to
541
- the `AppProvider` `localizer` prop, and finally to identity.
529
+ `useApp<TAsyncOptions>()` is generic in the async-options type so apps
530
+ can pass a custom option through to a custom `loadingOverlay` element.
531
+ `AppProvider` `cloneElement`s the overlay with `{ label, asyncOptions
532
+ }` while `runAsync` is pending. `useAppOptional()` returns `null`
533
+ outside the provider; `useLocalizer()` returns a localizer that prefers
534
+ `<StringsProvider>` data, falls back to the `AppProvider` `localizer`
535
+ prop, and finally to identity.
542
536
 
543
537
  Two popup APIs coexist: `useRouter().openPopup()` (spring-animated,
544
- dismissible, mode-aware — **the default choice**) and `useApp().showPopup()`
545
- (a flat overlay layer returning a string id, for content that must sit
546
- *outside* the router transition). Prefer the router's.
538
+ dismissible, mode-aware — **the default choice**) and
539
+ `useApp().showPopup()` (a flat overlay layer returning a string id, for
540
+ content that must sit *outside* the router transition). Prefer the
541
+ router's.
547
542
 
548
543
  ### Direct socket access
549
544
 
@@ -570,14 +565,14 @@ from `ugly-app/client`.
570
565
  ugly-app uses HttpOnly cookies and server-side JWT injection — no
571
566
  `localStorage`, no client-side token handling.
572
567
 
573
- Two modes, picked from the `auth` block in the project's `.uglyapp` config:
568
+ Two modes, picked from the `auth` block in the project's `.uglyapp`
569
+ config:
574
570
 
575
571
  - **Mode A — `mode: 'uglybot'`** (default when no `auth` block is present). Auth is delegated to ugly.bot OAuth. AI / email / push proxies bill the end user's ugly.bot credits.
576
572
  - **Mode B — `mode: 'self'`**. The app issues its own sessions via magic-link email and/or Google OAuth. AI / email / push proxies bill the developer's ugly.bot account using the project's `AI_PROXY_TOKEN`.
577
573
 
578
- `window.__UGLY_APP_AUTH_MODE__` is injected into every page so client code
579
- (incl. `<AuthRoot>`) can branch correctly. In Mode B with Google configured,
580
- `window.__UGLY_APP_GOOGLE_CLIENT_ID__` is also injected.
574
+ `window.__UGLY_APP_AUTH_MODE__` is injected into every page so client
575
+ code (incl. `<AuthRoot>`) can branch correctly.
581
576
 
582
577
  ### Mode A — ugly.bot OAuth (default)
583
578
 
@@ -589,9 +584,9 @@ Two modes, picked from the `auth` block in the project's `.uglyapp` config:
589
584
 
590
585
  ### Mode B — self-issued sessions
591
586
 
592
- When `.uglyapp` sets `auth.mode: 'self'`, `buildAuth()` wires the magic-link
593
- provider as primary; if `providers.google.clientId` is configured it adds
594
- Google as an extra provider on the same router.
587
+ When `.uglyapp` sets `auth.mode: 'self'`, `buildAuth()` wires the
588
+ magic-link provider as primary; if `providers.google.clientId` is
589
+ configured it adds Google as an extra provider on the same router.
595
590
 
596
591
  - `<AuthRoot>` renders `<MagicLinkForm>` (plus the Google button when configured) instead of `<LoginPopup>`.
597
592
  - Background ugly.bot silent SSO is a no-op.
@@ -601,9 +596,9 @@ Google as an extra provider on the same router.
601
596
 
602
597
  ### Token-in-URL embed
603
598
 
604
- Any GET request with `?token=<JWT>` will, if the token verifies, set the
605
- cookie and 302-redirect to the same URL without the token parameter — useful
606
- for embedding any page in an iframe.
599
+ Any GET request with `?token=<JWT>` will, if the token verifies, set
600
+ the cookie and 302-redirect to the same URL without the token
601
+ parameter — useful for embedding any page in an iframe.
607
602
 
608
603
  ### Built-in auth routes
609
604
 
@@ -637,8 +632,8 @@ configurator.setAuth({
637
632
  ## Database — `TypedDB`
638
633
 
639
634
  Access via `app.db`. All methods accept a `CollectionDef` (from
640
- `defineCollections()`) or, on the `raw*` / `getQuery*` variants, a plain
641
- collection name string.
635
+ `defineCollections()`) or, on the `raw*` / `getQuery*` variants, a
636
+ plain collection name string.
642
637
 
643
638
  ### Writing
644
639
 
@@ -663,10 +658,10 @@ await db.batch(async () => {
663
658
  });
664
659
  ```
665
660
 
666
- Supported update operators: `$inc`, `$addToSet`, `$pull`, `$unset`, `$set`.
667
- All keys are dot-notation, fully typed against the collection's schema.
668
- Partial updates go through optimistic concurrency to prevent lost updates
669
- on concurrent writers.
661
+ Supported update operators: `$inc`, `$addToSet`, `$pull`, `$unset`,
662
+ `$set`. All keys are dot-notation, fully typed against the collection's
663
+ schema. Partial updates go through optimistic concurrency to prevent
664
+ lost updates on concurrent writers.
670
665
 
671
666
  ### Reading
672
667
 
@@ -700,8 +695,8 @@ await db.deleteWhere(collections.note, { userId }); // typed bulk delete
700
695
  await db.deleteQuery(collections.note, { userId }); // legacy untyped bulk delete
701
696
  ```
702
697
 
703
- Pass `deleteHandlers` as the 5th argument to `createApp` to run per-collection
704
- `onDelete` callbacks.
698
+ Pass `deleteHandlers` as the 5th argument to `createApp` to run
699
+ per-collection `onDelete` callbacks.
705
700
 
706
701
  ### Search
707
702
 
@@ -750,9 +745,10 @@ Imports available from `ugly-app`:
750
745
 
751
746
  ## AI providers
752
747
 
753
- AI calls are proxied through ugly.bot — your app never holds a provider key.
754
- Pass `UGLY_BOT_TOKEN` in the environment and the framework handles routing,
755
- balance tracking, retries, and per-user billing.
748
+ AI calls are proxied through ugly.bot — your app never holds a
749
+ provider key. Pass `UGLY_BOT_TOKEN` in the environment and the
750
+ framework handles routing, balance tracking, retries, and per-user
751
+ billing.
756
752
 
757
753
  ### Server-side text generation
758
754
 
@@ -774,9 +770,9 @@ const { message } = await uglyBotRequest<{ message: { content: string } }>('text
774
770
  });
775
771
  ```
776
772
 
777
- Available models are exposed via `textGenModels` / `textGenModelData` from
778
- `ugly-app` — the platform supports Claude, GPT, Gemini, Together, Groq,
779
- Fireworks, Kimi, and Kie families.
773
+ Available models are exposed via `textGenModels` / `textGenModelData`
774
+ from `ugly-app` — the platform supports Claude, GPT, Gemini, Together,
775
+ Groq, Fireworks, Kimi, and Kie families.
780
776
 
781
777
  ### Server-side image generation
782
778
 
@@ -787,8 +783,8 @@ const imageGen = createImageGen(userId);
787
783
  const url = await imageGen.generate('A red panda eating noodles', { model: 'flux_schnell' });
788
784
  ```
789
785
 
790
- `imageGenModels` / `imageGenModelData` enumerate available models (Together
791
- FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).
786
+ `imageGenModels` / `imageGenModelData` enumerate available models
787
+ (Together FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).
792
788
 
793
789
  ### Embeddings
794
790
 
@@ -813,8 +809,8 @@ await search.enrichNews({ query: 'topic' });
813
809
 
814
810
  ### Client-side AI calls
815
811
 
816
- Calls from React components go through the framework RPC pipeline — no token
817
- plumbing in the browser:
812
+ Calls from React components go through the framework RPC pipeline — no
813
+ token plumbing in the browser:
818
814
 
819
815
  ```ts
820
816
  import { callTextGen, callJsonGen, callImageGen } from 'ugly-app/client';
@@ -826,8 +822,9 @@ const image = await callImageGen({ prompt: 'a corgi astronaut', model: 'flux_sch
826
822
 
827
823
  ### STT / TTS
828
824
 
829
- Speech goes **directly** from the browser to ugly.bot — never proxied through
830
- your app server (see the "STT/TTS routes to ugly.bot" rule in `CLAUDE.md`).
825
+ Speech goes **directly** from the browser to ugly.bot — never proxied
826
+ through your app server (see the "STT/TTS routes to ugly.bot" rule in
827
+ `CLAUDE.md`).
831
828
 
832
829
  ```ts
833
830
  import { useSTT, useTTS, AudioPlayer, AudioRecorder } from 'ugly-app/client';
@@ -861,23 +858,24 @@ const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);
861
858
  await storage.delete('public', destKey);
862
859
  ```
863
860
 
864
- Client-side, use `socket.uploadFile(file, key)` — it requests a presigned URL
865
- via the built-in `uploadUrl` framework request and streams the upload. In
866
- dev, uploads go through a same-origin `/_s3` proxy to avoid CORS with local
867
- MinIO.
861
+ Client-side, use `socket.uploadFile(file, key)` — it requests a
862
+ presigned URL via the built-in `uploadUrl` framework request and
863
+ streams the upload. In dev, uploads go through a same-origin `/_s3`
864
+ proxy to avoid CORS with local MinIO.
868
865
 
869
- `STORAGE_KEY_PREFIX` (env) prefixes all keys — useful for per-environment
870
- isolation.
866
+ `STORAGE_KEY_PREFIX` (env) prefixes all keys — useful for
867
+ per-environment isolation.
871
868
 
872
- On Cloudflare Workers, storage uses the R2 binding directly; presigned PUTs
873
- are not exposed (browser uploads must go through a Worker endpoint).
869
+ On Cloudflare Workers, storage uses the R2 binding directly; presigned
870
+ PUTs are not exposed (browser uploads must go through a Worker
871
+ endpoint).
874
872
 
875
873
  ### Static assets — `static/`
876
874
 
877
875
  Large, rarely-changing files (3D models, textures, audio, fonts) go in the
878
876
  project-root `static/` folder. `ugly-app build:static` hashes them and
879
- generates `shared/StaticAssets.ts`; publish uploads only the files whose
880
- bytes changed.
877
+ generates `shared/StaticAssets.ts`; publish uploads only the files whose bytes
878
+ changed.
881
879
 
882
880
  ```ts
883
881
  import { staticUrl } from '../shared/StaticAssets'
@@ -887,14 +885,15 @@ const url = staticUrl('models/char2.ugm')
887
885
  ```
888
886
 
889
887
  The bucket gets its own custom domain, so **Cloudflare's CDN serves these
890
- directly** — no Worker invocation per request, and R2 answers `Range`
891
- natively for progressive loaders. Publish also sets the bucket CORS policy
892
- and a `Cross-Origin-Resource-Policy: cross-origin` response rule, without
893
- which COEP `require-corp` would silently block them.
888
+ directly** — no Worker invocation per request, and R2 answers `Range` natively
889
+ for progressive loaders. (A Worker reading through the R2 binding bypasses the
890
+ edge cache, which is why these do not go through it.) Publish also sets the
891
+ bucket CORS policy and a `Cross-Origin-Resource-Policy: cross-origin` response
892
+ rule, without which COEP `require-corp` would silently block them.
894
893
 
895
894
  Unlike `/{buildId}/assets/*`, these URLs do not change when you deploy, so
896
- returning users do not re-download unchanged files. Assets that no build
897
- from the last `retentionDays` (default 7) references are deleted from R2
895
+ returning users do not re-download unchanged files. Assets that no build from
896
+ the last `retentionDays` (default 7) references are deleted from R2
898
897
  automatically, along with expired `builds/` prefixes.
899
898
 
900
899
  Requires a provisioned app domain — publish fails rather than emitting URLs
@@ -904,8 +903,8 @@ nothing serves. Configure in `.uglyapp`:
904
903
  "staticAssets": { "dir": "static", "retentionDays": 7 }
905
904
  ```
906
905
 
907
- Dev serves the same files off disk at same-origin, so a newly added asset
908
- works before it has ever been published.
906
+ Dev serves the same files off disk at same-origin, so a newly added asset works
907
+ before it has ever been published.
909
908
 
910
909
  ### `hostBundle` — serve the vite bundle from the CDN too
911
910
 
@@ -920,20 +919,19 @@ prefixed, a byte-identical chunk keeps its URL across deploys — so returning
920
919
  users re-download only what actually changed, and each deploy uploads a
921
920
  handful of KB instead of the whole bundle.
922
921
 
923
- The first deploy only provisions; the bundle moves to the CDN on the next
924
- one. That is deliberate — a single pass would point the bundle at a hostname
925
- whose TLS certificate may not have issued yet.
926
-
927
- **Workers are handled for you, but know why.** The bundle is then
928
- cross-origin, and `new Worker(url)` *rejects a cross-origin script URL
929
- outright* — that is structural, and no CORS or CORP header lifts it.
930
- `bootstrapApp` installs a shim that routes such URLs through a same-origin
931
- `blob:` importing the real script, so `new Worker(new URL('./x.worker.ts',
932
- import.meta.url))` keeps working. `SharedWorker` is **not** shimmed: every
933
- blob URL is unique, so the same trick would give each caller a private
934
- worker and break sharing. Register service workers by root-absolute path
935
- (`/sw.js`) — `register()` resolves against the document, so those stay
936
- same-origin.
922
+ The first deploy only provisions; the bundle moves to the CDN on the next one.
923
+ That is deliberate — a single pass would point the bundle at a hostname whose
924
+ TLS certificate may not have issued yet.
925
+
926
+ **Workers are handled for you, but know why.** The bundle is then cross-origin,
927
+ and `new Worker(url)` *rejects a cross-origin script URL outright* — that is
928
+ structural, and no CORS or CORP header lifts it. `bootstrapApp` installs a
929
+ shim that routes such URLs through a same-origin `blob:` importing the real
930
+ script, so `new Worker(new URL('./x.worker.ts', import.meta.url))` keeps
931
+ working. `SharedWorker` is **not** shimmed: every blob URL is unique, so the
932
+ same trick would give each caller a private worker and break sharing. Register
933
+ service workers by root-absolute path (`/sw.js`) — `register()` resolves
934
+ against the document, so those stay same-origin.
937
935
 
938
936
  ---
939
937
 
@@ -965,15 +963,16 @@ const cronHandlers: WorkerHandlers<typeof cronTasks> = {
965
963
  configurator.setWorkers(cronTasks, cronHandlers);
966
964
  ```
967
965
 
968
- Each worker can have `inputSchema`, `outputSchema`, `schedule`, `timeout`,
969
- `description`. Workers without a schedule are still invocable via
970
- `POST /_workers/run` (auth: localhost in dev, `Authorization: Bearer $CRON_SECRET`
971
- in prod). Scheduled workers also appear in `/_cron/manifest` for the deploy
972
- orchestrator.
966
+ Each worker can have `inputSchema`, `outputSchema`, `schedule`,
967
+ `timeout`, `description`. Workers without a schedule are still
968
+ invocable via `POST /_workers/run` (auth: localhost in dev,
969
+ `Authorization: Bearer $CRON_SECRET` in prod). Scheduled workers also
970
+ appear in `/_cron/manifest` for the deploy orchestrator.
973
971
 
974
- On the Workers adapter, scheduled workers dispatch through Cloudflare Cron
975
- Triggers and durable queueing goes through Cloudflare Queues. On the Node
976
- adapter, `enqueueWorker` runs the handler inline (in-process queues only).
972
+ On the Workers adapter, scheduled workers dispatch through Cloudflare
973
+ Cron Triggers and durable queueing goes through Cloudflare Queues. On
974
+ the Node adapter, `enqueueWorker` runs the handler inline (in-process
975
+ queues only).
977
976
 
978
977
  ---
979
978
 
@@ -988,9 +987,9 @@ configurator.setStrings({
988
987
  });
989
988
  ```
990
989
 
991
- The framework injects `window.__LANG__`, `window.__STRINGS_VERSION__`, and
992
- `window.__CRITICAL_STRINGS__` into SSR HTML. Use `useLocalizer()` /
993
- `useStrings()` / `useLang()` / `useChangeLanguage()` on the client.
990
+ The framework injects `window.__LANG__`, `window.__STRINGS_VERSION__`,
991
+ and `window.__CRITICAL_STRINGS__` into SSR HTML. Use `useLocalizer()`
992
+ / `useStrings()` / `useLang()` / `useChangeLanguage()` on the client.
994
993
 
995
994
  ---
996
995
 
@@ -1015,9 +1014,10 @@ export const experiments: Experiment[] = [
1015
1014
  configurator.setExperiments(experiments);
1016
1015
  ```
1017
1016
 
1018
- Bucketing is deterministic: `hash(experimentId + userId)` (or `sessionId`
1019
- for unauthenticated users). The framework's `initSession` / `captureEvent`
1020
- requests automatically tag events with the user's branch assignments.
1017
+ Bucketing is deterministic: `hash(experimentId + userId)` (or
1018
+ `sessionId` for unauthenticated users). The framework's `initSession`
1019
+ / `captureEvent` requests automatically tag events with the user's
1020
+ branch assignments.
1021
1021
 
1022
1022
  ---
1023
1023
 
@@ -1043,15 +1043,17 @@ requests automatically tag events with the user's branch assignments.
1043
1043
 
1044
1044
  ## Two-adapter architecture
1045
1045
 
1046
- The same developer source compiles for two runtimes via `src/server/adapter/`:
1046
+ The same developer source compiles for two runtimes via
1047
+ `src/server/adapter/`:
1047
1048
 
1048
1049
  - **Adapter A — Node + TCP** (`src/server/adapter/node/`): default for `npm run dev` and any non-Workers deploy. Wraps `pg.Pool`, `nats.js`, AWS S3 SDK.
1049
1050
  - **Adapter B — Cloudflare Workers** (`src/server/adapter/workers/`): used when the Studio publish flow deploys to Cloudflare. Hono router + Durable Objects (one `CollectionDO` per project-collection, plus a `SessionDO` per user WS) + `@neondatabase/serverless` HTTP driver + R2 binding + Cloudflare Cron Triggers + Cloudflare Queues.
1050
1051
 
1051
- Developer-facing APIs (`createTypedDB`, `subscribeDoc`, `createStorageClient`,
1052
- `setCronTasks`, `setWorkers`) don't change between adapters. Local Workers dev
1053
- boots via `npm run dev:workers`, which starts a small Node HTTP proxy speaking
1054
- Neon's wire format so the Worker can talk to a real Postgres.
1052
+ Developer-facing APIs (`createTypedDB`, `subscribeDoc`,
1053
+ `createStorageClient`, `setCronTasks`, `setWorkers`) don't change
1054
+ between adapters. Local Workers dev boots via `npm run dev:workers`,
1055
+ which starts a small Node HTTP proxy speaking Neon's wire format so
1056
+ the Worker can talk to a real Postgres.
1055
1057
 
1056
1058
  ---
1057
1059
 
@@ -1106,8 +1108,8 @@ Browser-visible variables must be prefixed `VITE_` and consumed via
1106
1108
  | `ugly-app feedback:dev` / `feedback:prod` | Query user feedback. |
1107
1109
  | `ugly-app feedback:submit` / `feedback:resolve` | Manage feedback (run with `--help` for flags). |
1108
1110
 
1109
- Inside a scaffolded project, the same commands are available via `npm run …`
1110
- scripts — see `templates/CLAUDE.md`.
1111
+ Inside a scaffolded project, the same commands are available via `npm
1112
+ run …` scripts — see `templates/CLAUDE.md`.
1111
1113
 
1112
1114
  ---
1113
1115