bedrock-flows 0.7.1

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.
Files changed (85) hide show
  1. package/auth-schema.sql +8 -0
  2. package/bin/bedrock-flows.mjs +127 -0
  3. package/lib/setup.mjs +262 -0
  4. package/package.json +11 -0
  5. package/template/.storybook/main.js +46 -0
  6. package/template/.storybook/manager-head.html +963 -0
  7. package/template/.storybook/preview-head.html +35 -0
  8. package/template/.storybook/preview.js +23 -0
  9. package/template/CHANGELOG.md +236 -0
  10. package/template/README.md +26 -0
  11. package/template/apps/dashboard/index.html +15 -0
  12. package/template/apps/dashboard/package.json +22 -0
  13. package/template/apps/dashboard/src/App.module.css +1318 -0
  14. package/template/apps/dashboard/src/App.tsx +2716 -0
  15. package/template/apps/dashboard/src/auth-client.ts +17 -0
  16. package/template/apps/dashboard/src/changelog.tsx +92 -0
  17. package/template/apps/dashboard/src/index.css +86 -0
  18. package/template/apps/dashboard/src/main.tsx +15 -0
  19. package/template/apps/dashboard/src/theme.ts +31 -0
  20. package/template/apps/dashboard/src/vite-env.d.ts +4 -0
  21. package/template/apps/dashboard/vite.config.ts +48 -0
  22. package/template/apps/worker/.dev.vars.example +50 -0
  23. package/template/apps/worker/package.json +19 -0
  24. package/template/apps/worker/src/index.ts +295 -0
  25. package/template/apps/worker/tsconfig.json +11 -0
  26. package/template/apps/worker/wrangler.jsonc +29 -0
  27. package/template/bedrock.config.ts +16 -0
  28. package/template/design-system/README.md +97 -0
  29. package/template/design-system/starter-v1/components/button/component.css +42 -0
  30. package/template/design-system/starter-v1/components/button/danger.html +21 -0
  31. package/template/design-system/starter-v1/components/button/default.html +21 -0
  32. package/template/design-system/starter-v1/components/button/disabled.html +21 -0
  33. package/template/design-system/starter-v1/components/button/ghost.html +21 -0
  34. package/template/design-system/starter-v1/components/button/macro.njk +14 -0
  35. package/template/design-system/starter-v1/components/button/primary.html +21 -0
  36. package/template/design-system/starter-v1/components/button/variants.json +30 -0
  37. package/template/design-system/starter-v1/ds.json +3 -0
  38. package/template/design-system/starter-v1/global.css +52 -0
  39. package/template/design-system/starter-v1/style.css +107 -0
  40. package/template/gitignore +8 -0
  41. package/template/package.json +41 -0
  42. package/template/prototypes/F-001-hello/1-welcome.njk +30 -0
  43. package/template/prototypes/F-001-hello/2-form.njk +46 -0
  44. package/template/prototypes/F-001-hello/3-done.njk +29 -0
  45. package/template/prototypes/F-001-hello/meta.json +6 -0
  46. package/template/prototypes/_shared/_auth-gate.njk +54 -0
  47. package/template/prototypes/_shared/delivery.njk +43 -0
  48. package/template/prototypes/_shared/layout.njk +15 -0
  49. package/template/prototypes/_shared/screen.njk +1818 -0
  50. package/template/prototypes/_shared/wireflow.njk +4731 -0
  51. package/template/public/auth-gate.css +150 -0
  52. package/template/public/bedrock/color-inspector.js +284 -0
  53. package/template/public/bedrock/component-overlay.js +219 -0
  54. package/template/public/bedrock/data/bedrock-config.js +45 -0
  55. package/template/public/bedrock/font-size-overlay.js +590 -0
  56. package/template/public/bedrock/grid-overlay.js +379 -0
  57. package/template/public/bedrock/prototype-navigation.js +974 -0
  58. package/template/public/cmdk.js +146 -0
  59. package/template/public/ds-xray.css +112 -0
  60. package/template/public/ds-xray.js +271 -0
  61. package/template/public/favicon.svg +4 -0
  62. package/template/public/icons/bolt-fill.svg +3 -0
  63. package/template/public/icons/bolt.svg +3 -0
  64. package/template/public/icons/caret-down-fill.svg +3 -0
  65. package/template/public/icons/check-double.svg +4 -0
  66. package/template/public/icons/check.svg +3 -0
  67. package/template/public/icons/chevron-left.svg +3 -0
  68. package/template/public/icons/chevron-right.svg +3 -0
  69. package/template/public/icons/circle-info.svg +6 -0
  70. package/template/public/icons/grid.svg +6 -0
  71. package/template/public/icons/message-square-1.svg +3 -0
  72. package/template/public/icons/message-square.svg +3 -0
  73. package/template/public/icons/messages.svg +4 -0
  74. package/template/public/icons/options-horizontal.svg +5 -0
  75. package/template/public/icons/swatches.svg +6 -0
  76. package/template/public/icons/workflow.svg +6 -0
  77. package/template/public/lightbox.js +87 -0
  78. package/template/public/proto-chrome.css +596 -0
  79. package/template/public/screen-comments.css +723 -0
  80. package/template/public/wireflow-client.js +26 -0
  81. package/template/scripts/build-storybooks.mjs +8 -0
  82. package/template/scripts/dev-setup.mjs +15 -0
  83. package/template/scripts/generate-stories.mjs +12 -0
  84. package/template/scripts/generate-variants.mjs +22 -0
  85. package/template/tsconfig.base.json +19 -0
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Browser-side Better Auth client for the React dashboard.
3
+ *
4
+ * Same-origin → no baseURL needed; cookies flow with credentials: 'include'.
5
+ * Worker mounts the auth handler at /api/auth/*, so vite dev needs to be
6
+ * accessed through wrangler dev (or a proxy) for sign-in/up to round-trip.
7
+ * The dashboard itself loads fine on vite alone but session calls fail.
8
+ */
9
+ import { createAuthClient } from 'better-auth/react'
10
+ import { adminClient, emailOTPClient } from 'better-auth/client/plugins'
11
+
12
+ // adminClient → authClient.admin.* (Users page). emailOTPClient → authClient.emailOtp.*
13
+ // (verifyEmail / sendVerificationOtp) for the signup email-verification step.
14
+ export const authClient = createAuthClient({
15
+ plugins: [adminClient(), emailOTPClient()],
16
+ })
17
+ export const { useSession, signIn, signOut, signUp } = authClient
@@ -0,0 +1,92 @@
1
+ // In-app changelog page + the running version number. CHANGELOG.md at the
2
+ // repo root is the single source of truth: Vite bundles it in at build time
3
+ // (?raw), the topmost released heading becomes APP_VERSION, so the badge in
4
+ // the sidebar can never drift from what the changelog says shipped.
5
+ import type { ReactNode } from 'react'
6
+ import styles from './App.module.css'
7
+ import raw from '../../../CHANGELOG.md?raw'
8
+
9
+ // First released heading — "## [0.3.0] — date". A "## [Unreleased]" section
10
+ // above it doesn't count: the badge reflects the last cut release.
11
+ export const APP_VERSION = raw.match(/^## \[(\d+\.\d+\.\d+)\]/m)?.[1] ?? 'dev'
12
+
13
+ // The changelog uses a small markdown subset (h2 versions, h3 groups,
14
+ // paragraphs, bullets, **bold**, `code`, [links]) — rendered with a tiny
15
+ // parser instead of pulling in a markdown dependency for one page.
16
+ const INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)\s]+\))/g
17
+
18
+ function renderInline(text: string): ReactNode[] {
19
+ return text.split(INLINE).map((part, i) => {
20
+ if (/^\*\*[^*]+\*\*$/.test(part)) return <strong key={i}>{part.slice(2, -2)}</strong>
21
+ if (/^`[^`]+`$/.test(part)) return <code key={i}>{part.slice(1, -1)}</code>
22
+ const link = part.match(/^\[([^\]]+)\]\(([^)\s]+)\)$/)
23
+ if (link) return <a key={i} href={link[2]} target="_blank" rel="noreferrer">{link[1]}</a>
24
+ return part
25
+ })
26
+ }
27
+
28
+ type Block = { kind: 'h2' | 'h3' | 'p'; text: string } | { kind: 'ul'; items: string[] }
29
+
30
+ function parseBlocks(md: string): Block[] {
31
+ const blocks: Block[] = []
32
+ let para: string[] = []
33
+ const flush = () => {
34
+ if (para.length) { blocks.push({ kind: 'p', text: para.join(' ') }); para = [] }
35
+ }
36
+ for (const line of md.split('\n')) {
37
+ if (line.startsWith('# ')) { flush(); continue } // page chrome already has the title
38
+ if (line.startsWith('## ')) { flush(); blocks.push({ kind: 'h2', text: line.slice(3) }); continue }
39
+ if (line.startsWith('### ')) { flush(); blocks.push({ kind: 'h3', text: line.slice(4) }); continue }
40
+ if (line.startsWith('- ')) {
41
+ flush()
42
+ const last = blocks[blocks.length - 1]
43
+ const ul = last && last.kind === 'ul' ? last : (blocks.push({ kind: 'ul', items: [] }), blocks[blocks.length - 1] as { kind: 'ul'; items: string[] })
44
+ ul.items.push(line.slice(2))
45
+ continue
46
+ }
47
+ if (/^\s+\S/.test(line)) {
48
+ // Hard-wrapped continuation of the previous bullet or paragraph line.
49
+ const last = blocks[blocks.length - 1]
50
+ if (!para.length && last && last.kind === 'ul') { last.items[last.items.length - 1] += ' ' + line.trim(); continue }
51
+ para.push(line.trim())
52
+ continue
53
+ }
54
+ if (!line.trim()) { flush(); continue }
55
+ para.push(line.trim())
56
+ }
57
+ flush()
58
+ return blocks
59
+ }
60
+
61
+ export function ChangelogView() {
62
+ const blocks = parseBlocks(raw)
63
+ return (
64
+ <section className={styles.changelog}>
65
+ {blocks.map((b, i) => {
66
+ if (b.kind === 'h2') {
67
+ // "[0.3.0] — 2026-06-12" → version + date; non-semver headings
68
+ // ("[Unreleased]") render as-is without the v prefix.
69
+ const m = b.text.match(/^\[([^\]]+)\](?:\s*—\s*(.*))?$/)
70
+ const ver = m?.[1] ?? b.text
71
+ const semver = /^\d/.test(ver)
72
+ return (
73
+ <h2 key={i} className={styles.changelogVersion}>
74
+ {semver ? `v${ver}` : ver}
75
+ {semver && ver === APP_VERSION && <span className={styles.changelogCurrent}>running</span>}
76
+ {m?.[2] && <span className={styles.changelogDate}>{m[2]}</span>}
77
+ </h2>
78
+ )
79
+ }
80
+ if (b.kind === 'h3') return <h3 key={i} className={styles.changelogGroup}>{b.text}</h3>
81
+ if (b.kind === 'ul') {
82
+ return (
83
+ <ul key={i} className={styles.changelogList}>
84
+ {b.items.map((it, j) => <li key={j}>{renderInline(it)}</li>)}
85
+ </ul>
86
+ )
87
+ }
88
+ return <p key={i} className={styles.changelogNote}>{renderInline(b.text)}</p>
89
+ })}
90
+ </section>
91
+ )
92
+ }
@@ -0,0 +1,86 @@
1
+ *,
2
+ *::before,
3
+ *::after {
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ html,
8
+ body,
9
+ #root {
10
+ margin: 0;
11
+ padding: 0;
12
+ min-height: 100%;
13
+ }
14
+
15
+ /* Dashboard chrome tokens. The overview page is 100% chrome (no embedded
16
+ prototype iframes), so this whole UI follows the user's system theme. */
17
+ :root {
18
+ --dash-bg: #f8fafc;
19
+ --dash-surface: #ffffff;
20
+ --dash-surface-alt: #f8fafc;
21
+ --dash-text: #111827;
22
+ --dash-text-muted: #6b7280;
23
+ --dash-border: #e5e7eb;
24
+ --dash-border-strong: #cbd5e1;
25
+ --dash-link: #1d4ed8;
26
+ --dash-link-hover: #0f172a;
27
+ --dash-code-bg: #eef2f7;
28
+ --dash-table-head-bg: #111827;
29
+ --dash-table-head-text: #ffffff;
30
+ --dash-table-row-hover: #f8fafc;
31
+ --dash-table-divider: #f1f5f9;
32
+ }
33
+ /* Dark tokens apply when (a) the OS prefers dark and the user hasn't
34
+ forced light, or (b) the user explicitly chose dark in the user menu.
35
+ The theme choice is set as data-theme on <html> by the theme
36
+ controller (see src/theme.ts). */
37
+ @media (prefers-color-scheme: dark) {
38
+ :root:not([data-theme='light']) {
39
+ --dash-bg: #0b1220;
40
+ --dash-surface: #111827;
41
+ --dash-surface-alt: #1e293b;
42
+ --dash-text: #f1f5f9;
43
+ --dash-text-muted: #94a3b8;
44
+ --dash-border: #1e293b;
45
+ --dash-border-strong: #334155;
46
+ --dash-link: #60a5fa;
47
+ --dash-link-hover: #f1f5f9;
48
+ --dash-code-bg: #1e293b;
49
+ --dash-table-head-bg: #1e293b;
50
+ --dash-table-head-text: #f1f5f9;
51
+ --dash-table-row-hover: #1e293b;
52
+ --dash-table-divider: #1e293b;
53
+ }
54
+ }
55
+ :root[data-theme='dark'] {
56
+ --dash-bg: #0b1220;
57
+ --dash-surface: #111827;
58
+ --dash-surface-alt: #1e293b;
59
+ --dash-text: #f1f5f9;
60
+ --dash-text-muted: #94a3b8;
61
+ --dash-border: #1e293b;
62
+ --dash-border-strong: #334155;
63
+ --dash-link: #60a5fa;
64
+ --dash-link-hover: #f1f5f9;
65
+ --dash-code-bg: #1e293b;
66
+ --dash-table-head-bg: #1e293b;
67
+ --dash-table-head-text: #f1f5f9;
68
+ --dash-table-row-hover: #1e293b;
69
+ --dash-table-divider: #1e293b;
70
+ }
71
+
72
+ body {
73
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
74
+ color: var(--dash-text);
75
+ background: var(--dash-bg);
76
+ -webkit-font-smoothing: antialiased;
77
+ -moz-osx-font-smoothing: grayscale;
78
+ }
79
+
80
+ code {
81
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
82
+ font-size: 0.92em;
83
+ background: var(--dash-code-bg);
84
+ padding: 1px 5px;
85
+ border-radius: 4px;
86
+ }
@@ -0,0 +1,15 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+ import { applyTheme, getTheme } from './theme'
6
+
7
+ // Apply the saved theme before first paint so there's no flash of the
8
+ // system theme when the user has overridden it.
9
+ applyTheme(getTheme())
10
+
11
+ createRoot(document.getElementById('root')!).render(
12
+ <StrictMode>
13
+ <App />
14
+ </StrictMode>,
15
+ )
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Manual theme override, shared by the dashboard and the prototype
3
+ * chrome. Without an override the UI follows the OS (prefers-color-scheme
4
+ * media query). When the user picks Light or Dark in the user menu we set
5
+ * `data-theme` on <html>; CSS uses `:root[data-theme='dark']` /
6
+ * `:root:not([data-theme='light'])` to honor the choice.
7
+ *
8
+ * Stored under one localStorage key so the wireflow + screen pages
9
+ * (which read it via a tiny inline script) stay consistent with the
10
+ * dashboard without their own UI.
11
+ */
12
+ export type Theme = 'system' | 'light' | 'dark'
13
+
14
+ export const THEME_KEY = 'bedrockTheme'
15
+
16
+ export function getTheme(): Theme {
17
+ const v = (typeof localStorage !== 'undefined' && localStorage.getItem(THEME_KEY)) || 'system'
18
+ return v === 'light' || v === 'dark' ? v : 'system'
19
+ }
20
+
21
+ export function applyTheme(theme: Theme): void {
22
+ const el = document.documentElement
23
+ if (theme === 'system') el.removeAttribute('data-theme')
24
+ else el.setAttribute('data-theme', theme)
25
+ }
26
+
27
+ export function setTheme(theme: Theme): void {
28
+ if (theme === 'system') localStorage.removeItem(THEME_KEY)
29
+ else localStorage.setItem(THEME_KEY, theme)
30
+ applyTheme(theme)
31
+ }
@@ -0,0 +1,4 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // Build-time constants injected by vite.config.ts `define` from bedrock.config.ts.
4
+ declare const __BEDROCK_COMMENTS_PROD_ORIGIN__: string
@@ -0,0 +1,48 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { prototypePlugin } from '@obra-studio/bedrock-flows/vite-plugin'
6
+ import bedrockConfig from '../../bedrock.config'
7
+
8
+ // prototypes/ + design-system/ live at the repo root, not in the app dir.
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
10
+
11
+ // Dynamic routes the Cloudflare Worker owns (Better Auth + KV-backed comments,
12
+ // people directory, spec store, debug log). In dev they're proxied to the
13
+ // worker (`wrangler dev` on :8787) so the dashboard can actually sign in —
14
+ // the React shell is auth-gated and the Vite plugin doesn't run Better Auth.
15
+ // Everything else (/p, /ds) stays on Vite for live reload.
16
+ const WORKER_ORIGIN = 'http://localhost:8787'
17
+ const workerRoute = { target: WORKER_ORIGIN, changeOrigin: true }
18
+
19
+ export default defineConfig({
20
+ plugins: [react(), prototypePlugin(bedrockConfig, repoRoot)],
21
+ // Per-project values reach App.tsx from bedrock.config.ts — never hardcode
22
+ // a deployment's worker URL in platform code (children sync these files).
23
+ define: {
24
+ __BEDROCK_COMMENTS_PROD_ORIGIN__: JSON.stringify(
25
+ bedrockConfig.modules.commenting?.prodOrigin ?? '',
26
+ ),
27
+ },
28
+ server: {
29
+ // Pin the dev port so bookmarks (and the worker's localhost trust) stay
30
+ // stable instead of drifting to 5174 when 5173 is busy.
31
+ port: 5173,
32
+ proxy: {
33
+ '/api': workerRoute,
34
+ '/__wf-comments': workerRoute,
35
+ '/__wf-people': workerRoute,
36
+ '/__wf-log': workerRoute,
37
+ '/__spec': workerRoute,
38
+ },
39
+ },
40
+ // Repo-root public/ holds the shared chrome (proto-chrome.css, screen-comments.css,
41
+ // favicon, icons) + the bundled wireflow-client.js — served at / in dev, copied to dist.
42
+ publicDir: path.resolve(repoRoot, 'public'),
43
+ build: {
44
+ // Unified output at the repo root so the worker serves dashboard + /p + /ds together.
45
+ outDir: path.resolve(repoRoot, 'dist'),
46
+ emptyOutDir: true,
47
+ },
48
+ })
@@ -0,0 +1,50 @@
1
+ # Template for apps/worker/.dev.vars (which is gitignored). Copy to .dev.vars
2
+ # and fill in. These feed `wrangler dev`; for production set the same keys with
3
+ # `wrangler secret put <KEY>`. See docs/INFRASTRUCTURE.md.
4
+
5
+ # ── Better Auth (required) ───────────────────────────────────────────────────
6
+ # Signing secret for sessions. Generate: openssl rand -base64 32
7
+ BETTER_AUTH_SECRET=replace-me
8
+ # The origin the app is served from. Local: http://localhost:8787
9
+ # Prod: https://<worker-name>.<account>.workers.dev
10
+ BETTER_AUTH_URL=http://localhost:8787
11
+ # Optional: API key for the @better-auth/infra `dash` admin dashboard
12
+ # (/api/auth/dash/*). Unset = dashboard endpoints unusable; auth still boots.
13
+ # BETTER_AUTH_API_KEY=ba_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
14
+ # Google OAuth — set BOTH to enable "Continue with Google". Create an OAuth
15
+ # client in Google Cloud Console with authorized redirect URI:
16
+ # <BETTER_AUTH_URL>/api/auth/callback/google
17
+ # (local: http://localhost:8787/api/auth/callback/google)
18
+ # GOOGLE_CLIENT_ID=
19
+ # GOOGLE_CLIENT_SECRET=
20
+ # Optional signup allow-list — set either / both. Exact emails (AUTH_ALLOWLIST)
21
+ # and/or whole domains (AUTH_ALLOWED_DOMAINS). Blank/unset = anyone can sign up.
22
+ # Emailed invites authorize their address past this list.
23
+ # AUTH_ALLOWLIST=you@example.com,teammate@example.com
24
+ # AUTH_ALLOWED_DOMAINS=example.com
25
+ # Owner email auto-promoted to admin on signup → manages users in-app (Users tab).
26
+ # AUTH_OWNER_EMAIL=you@example.com
27
+ # LOCALHOST-ONLY: role of the synthetic dev session (default: designer).
28
+ # Set to `manager` to test the manager comment flow (pending-review gating).
29
+ # One of: user | manager | designer | admin. Ignored in production.
30
+ # DEV_ROLE=manager
31
+
32
+ # ── Comment resolve endpoint (optional) ──────────────────────────────────────
33
+ # Enables POST /__wf-comments/<id>/resolve (used by scripts/comment-resolve.mjs
34
+ # and resolve-all-open.mjs). Must match BF_RESOLVE_TOKEN when running those CLIs.
35
+ # WF_RESOLVE_TOKEN=choose-a-long-random-string
36
+
37
+ # ── Postmark notification emails (optional; no-op unless POSTMARK_TOKEN set) ──
38
+ # Postmark server token. Until a sending domain is verified you can use
39
+ # POSTMARK_API_TEST (sandbox — nothing is delivered).
40
+ # POSTMARK_TOKEN=your-postmark-server-token
41
+ # Verified sender address (defaults to johan@obra.studio in code if unset).
42
+ # POSTMARK_FROM=you@your-verified-domain.com
43
+ # Postmark message stream (defaults to "outbound").
44
+ # POSTMARK_STREAM=outbound
45
+ # Comma-separated emails notified on EVERY new comment (not only @mentions).
46
+ # COMMENTS_NOTIFY_LIST=you@example.com
47
+
48
+ # Extra trusted auth origins (comma-separated, * wildcards OK) — e.g. your
49
+ # account workers.dev wildcard so preview workers can authenticate.
50
+ AUTH_TRUSTED_ORIGINS=https://*.your-subdomain.workers.dev
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@bedrock-flows/worker",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Cloudflare Worker — static assets + enabled modules’ workerRoutes (comments, spec KV)",
7
+ "scripts": {
8
+ "deploy": "wrangler deploy",
9
+ "dev": "wrangler dev",
10
+ "typecheck": "tsc -p tsconfig.json --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@obra-studio/bedrock-flows": "__BEDROCK_FLOWS_VERSION__"
14
+ },
15
+ "devDependencies": {
16
+ "@cloudflare/workers-types": "^4.20260507.1",
17
+ "wrangler": "^4.88.0"
18
+ }
19
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Cloudflare Worker — thin shell.
3
+ *
4
+ * Serves built static assets via the `assets` binding and dispatches to each
5
+ * enabled module's `worker` handler (first non-null Response wins). Comment KV
6
+ * logic now lives in @obra-studio/bedrock-flows/commenting — no inline commentCode here.
7
+ *
8
+ * Still in the shell (asset proxying; may move to module serverRoutes later):
9
+ * - /__wf-log debug endpoint
10
+ * - /p/<id>/(mermaid|figma) alternate wireflow views
11
+ * - static assets with cache headers
12
+ * Ported from bedrock-flows worker/index.ts.
13
+ */
14
+ import bedrockConfig from '../../../bedrock.config.js'
15
+ import { loadEnabledModules } from '@obra-studio/bedrock-flows/core'
16
+ import commenting, { sanitizeManagerCommentsPut, type CommentsEnv } from '@obra-studio/bedrock-flows/commenting'
17
+ import spec from '@obra-studio/bedrock-flows/spec'
18
+ import { handleAuthRequest, getSessionUser, type AuthWorkerEnv } from '@obra-studio/bedrock-flows/auth'
19
+
20
+ interface Env {
21
+ WF_ATTACHMENTS?: R2Bucket
22
+ ASSETS: { fetch: (request: Request) => Promise<Response> }
23
+ WF_COMMENTS: KVNamespace
24
+ SPEC_KV: KVNamespace
25
+ WF_RESOLVE_TOKEN?: string
26
+ // Best-effort comment-notification email (commenting module; all optional).
27
+ POSTMARK_TOKEN?: string
28
+ POSTMARK_FROM?: string
29
+ POSTMARK_STREAM?: string
30
+ COMMENTS_NOTIFY_LIST?: string
31
+ // Better Auth (D1 + secret). D1 also backs @mention email resolution.
32
+ AUTH_DB: D1Database
33
+ BETTER_AUTH_SECRET: string
34
+ BETTER_AUTH_URL?: string
35
+ BETTER_AUTH_API_KEY?: string
36
+ GOOGLE_CLIENT_ID?: string
37
+ GOOGLE_CLIENT_SECRET?: string
38
+ AUTH_ALLOWLIST?: string
39
+ }
40
+
41
+ const LOG_PATH = '/__wf-log'
42
+ // PUT a comment thread — identifies the manager-sanitize case inside the
43
+ // comment-endpoint gate (which covers every /__wf-comments/* method).
44
+ const COMMENT_PUT = /^\/__wf-comments\/[A-Za-z0-9_-]+\.json$/
45
+
46
+ // Assemble worker handlers from the enabled, worker-capable modules.
47
+ const moduleRegistry = { [commenting.id]: commenting }
48
+ const workerHandlers = loadEnabledModules(bedrockConfig, moduleRegistry)
49
+ .map((m) => m.worker)
50
+ .filter((h): h is NonNullable<typeof h> => Boolean(h))
51
+
52
+ // spec drives flows, so mount its KV /__spec handler whenever flows is enabled.
53
+ if (bedrockConfig.modules.flows?.enabled && spec.worker) {
54
+ workerHandlers.push(spec.worker)
55
+ }
56
+
57
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
58
+
59
+ function inviteJson(body: unknown, status = 200): Response {
60
+ return new Response(JSON.stringify(body), {
61
+ status,
62
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
63
+ })
64
+ }
65
+
66
+ // Email an invite via Postmark (best-effort). Falls back to the Postmark sandbox
67
+ // token when POSTMARK_TOKEN is unset → nothing is delivered, call still succeeds.
68
+ async function sendInviteEmail(env: Env, to: string, link: string): Promise<void> {
69
+ const token = env.POSTMARK_TOKEN || 'POSTMARK_API_TEST'
70
+ const from = env.POSTMARK_FROM || 'johan@obra.studio'
71
+ const stream = env.POSTMARK_STREAM || 'outbound'
72
+ const text = `You've been invited to review prototypes in Bedrock Flows.\n\nCreate your account: ${link}\n`
73
+ const html =
74
+ `<p style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px">You've been invited to review prototypes in <strong>Bedrock Flows</strong>.</p>` +
75
+ `<p style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px"><a href="${link}">Create your account →</a></p>`
76
+ try {
77
+ await fetch('https://api.postmarkapp.com/email', {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Postmark-Server-Token': token },
80
+ body: JSON.stringify({ From: from, To: to, Subject: "You're invited to Bedrock Flows", TextBody: text, HtmlBody: html, MessageStream: stream }),
81
+ })
82
+ } catch (err) {
83
+ console.error('[invite] email send failed', err)
84
+ }
85
+ }
86
+
87
+ // POST { email } (admin only) → store an invite + email a signup link.
88
+ // GET ?token=<token> (public) → resolve the token to its email for prefill.
89
+ async function handleInvite(request: Request, env: Env): Promise<Response> {
90
+ const url = new URL(request.url)
91
+
92
+ if (request.method === 'GET') {
93
+ const token = url.searchParams.get('token') || ''
94
+ if (!token) return inviteJson({ error: 'missing token' }, 400)
95
+ try {
96
+ const row = await env.AUTH_DB
97
+ .prepare('SELECT email FROM invitation WHERE token = ? LIMIT 1')
98
+ .bind(token)
99
+ .first<{ email: string }>()
100
+ if (!row) return inviteJson({ error: 'invalid token' }, 404)
101
+ return inviteJson({ email: row.email })
102
+ } catch {
103
+ return inviteJson({ error: 'invalid token' }, 404)
104
+ }
105
+ }
106
+
107
+ if (request.method === 'POST') {
108
+ const user = await getSessionUser(request, env as AuthWorkerEnv)
109
+ if (!user || user.role !== 'admin') return inviteJson({ error: 'admin only' }, 403)
110
+ let body: { email?: string }
111
+ try {
112
+ body = (await request.json()) as { email?: string }
113
+ } catch {
114
+ return inviteJson({ error: 'invalid JSON' }, 400)
115
+ }
116
+ const email = (body.email || '').trim().toLowerCase()
117
+ if (!EMAIL_RE.test(email)) return inviteJson({ error: 'invalid email' }, 400)
118
+
119
+ const token = crypto.randomUUID()
120
+ try {
121
+ // Self-migrating: create the table on first use so no separate migration
122
+ // step is needed when standing up a new project.
123
+ await env.AUTH_DB
124
+ .prepare('CREATE TABLE IF NOT EXISTS invitation (email TEXT PRIMARY KEY, token TEXT NOT NULL, createdAt TEXT, used INTEGER DEFAULT 0)')
125
+ .run()
126
+ await env.AUTH_DB
127
+ .prepare('INSERT OR REPLACE INTO invitation (email, token, createdAt, used) VALUES (?, ?, ?, 0)')
128
+ .bind(email, token, new Date().toISOString())
129
+ .run()
130
+ } catch (err) {
131
+ console.error('[invite] db write failed', err)
132
+ return inviteJson({ error: 'could not store invite (is the invitation table migrated?)' }, 500)
133
+ }
134
+
135
+ const base = (env.BETTER_AUTH_URL || url.origin).replace(/\/$/, '')
136
+ await sendInviteEmail(env, email, `${base}/?invite=${token}`)
137
+ return inviteJson({ ok: true })
138
+ }
139
+
140
+ return inviteJson({ error: 'method not allowed' }, 405)
141
+ }
142
+
143
+ export default {
144
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
145
+ const url = new URL(request.url)
146
+
147
+ if (url.pathname === LOG_PATH) {
148
+ if (request.method !== 'POST') {
149
+ return new Response('method not allowed', { status: 405 })
150
+ }
151
+ let body: unknown
152
+ try { body = await request.json() } catch { body = { raw: 'unparseable' } }
153
+ const ua = request.headers.get('user-agent') || ''
154
+ const ip = request.headers.get('cf-connecting-ip') || ''
155
+ console.log('[wf-log]', JSON.stringify({ ts: Date.now(), ip, ua, body }))
156
+ return new Response('', { status: 204 })
157
+ }
158
+
159
+ // Public client config — which auth providers are actually configured, so the
160
+ // sign-in UI only shows what works (e.g. hide Google when not provisioned).
161
+ if (url.pathname === '/__auth-config') {
162
+ return new Response(
163
+ JSON.stringify({ google: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) }),
164
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
165
+ )
166
+ }
167
+
168
+ // Invite flow: admin POSTs an email → invitee gets a link → self-signup.
169
+ // Public GET ?token resolves the invite to its email for the signup prefill.
170
+ if (url.pathname === '/api/invite') {
171
+ return handleInvite(request, env)
172
+ }
173
+
174
+ // Auth: /api/auth/* (Better Auth) + the /__wf-people @mention directory.
175
+ const authRes = await handleAuthRequest(request, env as AuthWorkerEnv)
176
+ if (authRes) return authRes
177
+
178
+ // The agent token (WF_RESOLVE_TOKEN) is the machine identity for the
179
+ // headless agent CLIs (scripts/comment*.mjs, resolve-all-open.mjs): it
180
+ // grants READ access to gated content/comments. Writes keep their own
181
+ // rules (resolve endpoint re-checks this token; spec PUT needs a
182
+ // designer/admin session).
183
+ const hasAgentToken = (() => {
184
+ const bearer = (request.headers.get('Authorization') || '').replace(/^Bearer\s+/i, '')
185
+ return Boolean(env.WF_RESOLVE_TOKEN && bearer === env.WF_RESOLVE_TOKEN)
186
+ })()
187
+
188
+ // Comment endpoints — reads AND writes need a caller identity: a
189
+ // signed-in session or the agent token. (Reads used to be public for the
190
+ // localhost read-only mirror; closed deliberately — client feedback is
191
+ // as sensitive as the designs it's about. A localhost mirror pointed at
192
+ // a prod origin now renders without comments.) OPTIONS preflights pass
193
+ // through to the commenting handler's CORS response.
194
+ const isCommentPath =
195
+ url.pathname.startsWith('/__wf-comments/') ||
196
+ url.pathname === '/__wf-comment-counts' ||
197
+ url.pathname === '/__wf-comment-counts.json'
198
+ if (isCommentPath && request.method !== 'OPTIONS') {
199
+ const user = hasAgentToken ? null : await getSessionUser(request, env as AuthWorkerEnv)
200
+ if (!hasAgentToken && !user) {
201
+ return new Response(JSON.stringify({ error: 'auth required' }), {
202
+ status: 401,
203
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
204
+ })
205
+ }
206
+ // Managers only give comments — rewrite their thread PUTs so new
207
+ // comments land in 'pending-review' (designer approval required) and
208
+ // other people's entries / workflow fields can't be altered.
209
+ if (user?.role === 'manager' && request.method === 'PUT' && COMMENT_PUT.test(url.pathname)) {
210
+ request = await sanitizeManagerCommentsPut(request, env as unknown as CommentsEnv, {
211
+ id: user.id,
212
+ role: user.role,
213
+ })
214
+ }
215
+ }
216
+
217
+ // ── Server-side content gate ─────────────────────────────────────────
218
+ // Design content requires a signed-in session (or the agent token). The
219
+ // in-page auth gates are cosmetic overlays on top of fully-served HTML —
220
+ // without this, an anonymous browser reads every prototype, design
221
+ // system, and spec behind them. HTML navigations bounce to the dashboard
222
+ // sign-in (?next= returns them to the page after); data/subresource
223
+ // requests get a bare 401. Localhost dev passes via the synthetic
224
+ // session.
225
+ const isProtectedContent =
226
+ /^\/(p|ds)(\/|$)/.test(url.pathname) ||
227
+ url.pathname === '/__prototypes.json' ||
228
+ url.pathname === '/__ds.json' ||
229
+ url.pathname.startsWith('/__spec/')
230
+ if (isProtectedContent) {
231
+ const user = hasAgentToken ? null : await getSessionUser(request, env as AuthWorkerEnv)
232
+ if (!hasAgentToken && !user) {
233
+ const wantsHtml =
234
+ request.method === 'GET' && (request.headers.get('Accept') || '').includes('text/html')
235
+ if (wantsHtml) {
236
+ return Response.redirect(
237
+ `${url.origin}/?next=${encodeURIComponent(url.pathname + url.search)}`,
238
+ 302,
239
+ )
240
+ }
241
+ return new Response(JSON.stringify({ error: 'auth required' }), {
242
+ status: 401,
243
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
244
+ })
245
+ }
246
+ // Spec edits change what gets designed — designer/admin SESSION only.
247
+ // (The spec PUT was previously unauthenticated; managers, reviewers,
248
+ // and the read-level agent token don't rewrite the spec.)
249
+ if (
250
+ url.pathname.startsWith('/__spec/') &&
251
+ request.method === 'PUT' &&
252
+ !(user && (user.role === 'designer' || user.role === 'admin'))
253
+ ) {
254
+ return new Response(JSON.stringify({ error: 'designer role required' }), {
255
+ status: 403,
256
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
257
+ })
258
+ }
259
+ }
260
+
261
+ // Enabled modules get first crack (commenting KV, later spec KV). Pass ctx
262
+ // so the commenting handler can fire best-effort emails via waitUntil.
263
+ for (const handle of workerHandlers) {
264
+ const res = await handle(request, env, ctx)
265
+ if (res) return res
266
+ }
267
+
268
+ // /p/<id>/mermaid and /p/<id>/figma — alternate wireflow views.
269
+ const viewMatch = /^\/p\/([A-Za-z0-9_-]+)\/(mermaid|figma)\/?$/.exec(url.pathname)
270
+ if (viewMatch) {
271
+ const dirUrl = new URL(request.url)
272
+ dirUrl.pathname = `/p/${viewMatch[1]}/`
273
+ const res = await env.ASSETS.fetch(new Request(dirUrl.toString(), { headers: request.headers }))
274
+ return new Response(res.body, {
275
+ status: res.status === 307 || res.status === 308 ? 200 : res.status,
276
+ headers: res.headers,
277
+ })
278
+ }
279
+
280
+ // Static assets with cache headers.
281
+ const res = await env.ASSETS.fetch(request)
282
+ const p = url.pathname
283
+ const fingerprinted = /[-.][0-9a-f]{8,}\.(?:js|css|woff2?|png|jpe?g|svg|webp|gif)$/i.test(p)
284
+ const cacheableAsset = /\.(?:css|woff2?|png|jpe?g|svg|webp|gif|ico)$/i.test(p)
285
+ if (fingerprinted || cacheableAsset) {
286
+ const headers = new Headers(res.headers)
287
+ headers.set(
288
+ 'Cache-Control',
289
+ fingerprinted ? 'public, max-age=31536000, immutable' : 'public, max-age=3600',
290
+ )
291
+ return new Response(res.body, { status: res.status, headers })
292
+ }
293
+ return res
294
+ },
295
+ }