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.
- package/auth-schema.sql +8 -0
- package/bin/bedrock-flows.mjs +127 -0
- package/lib/setup.mjs +262 -0
- package/package.json +11 -0
- package/template/.storybook/main.js +46 -0
- package/template/.storybook/manager-head.html +963 -0
- package/template/.storybook/preview-head.html +35 -0
- package/template/.storybook/preview.js +23 -0
- package/template/CHANGELOG.md +236 -0
- package/template/README.md +26 -0
- package/template/apps/dashboard/index.html +15 -0
- package/template/apps/dashboard/package.json +22 -0
- package/template/apps/dashboard/src/App.module.css +1318 -0
- package/template/apps/dashboard/src/App.tsx +2716 -0
- package/template/apps/dashboard/src/auth-client.ts +17 -0
- package/template/apps/dashboard/src/changelog.tsx +92 -0
- package/template/apps/dashboard/src/index.css +86 -0
- package/template/apps/dashboard/src/main.tsx +15 -0
- package/template/apps/dashboard/src/theme.ts +31 -0
- package/template/apps/dashboard/src/vite-env.d.ts +4 -0
- package/template/apps/dashboard/vite.config.ts +48 -0
- package/template/apps/worker/.dev.vars.example +50 -0
- package/template/apps/worker/package.json +19 -0
- package/template/apps/worker/src/index.ts +295 -0
- package/template/apps/worker/tsconfig.json +11 -0
- package/template/apps/worker/wrangler.jsonc +29 -0
- package/template/bedrock.config.ts +16 -0
- package/template/design-system/README.md +97 -0
- package/template/design-system/starter-v1/components/button/component.css +42 -0
- package/template/design-system/starter-v1/components/button/danger.html +21 -0
- package/template/design-system/starter-v1/components/button/default.html +21 -0
- package/template/design-system/starter-v1/components/button/disabled.html +21 -0
- package/template/design-system/starter-v1/components/button/ghost.html +21 -0
- package/template/design-system/starter-v1/components/button/macro.njk +14 -0
- package/template/design-system/starter-v1/components/button/primary.html +21 -0
- package/template/design-system/starter-v1/components/button/variants.json +30 -0
- package/template/design-system/starter-v1/ds.json +3 -0
- package/template/design-system/starter-v1/global.css +52 -0
- package/template/design-system/starter-v1/style.css +107 -0
- package/template/gitignore +8 -0
- package/template/package.json +41 -0
- package/template/prototypes/F-001-hello/1-welcome.njk +30 -0
- package/template/prototypes/F-001-hello/2-form.njk +46 -0
- package/template/prototypes/F-001-hello/3-done.njk +29 -0
- package/template/prototypes/F-001-hello/meta.json +6 -0
- package/template/prototypes/_shared/_auth-gate.njk +54 -0
- package/template/prototypes/_shared/delivery.njk +43 -0
- package/template/prototypes/_shared/layout.njk +15 -0
- package/template/prototypes/_shared/screen.njk +1818 -0
- package/template/prototypes/_shared/wireflow.njk +4731 -0
- package/template/public/auth-gate.css +150 -0
- package/template/public/bedrock/color-inspector.js +284 -0
- package/template/public/bedrock/component-overlay.js +219 -0
- package/template/public/bedrock/data/bedrock-config.js +45 -0
- package/template/public/bedrock/font-size-overlay.js +590 -0
- package/template/public/bedrock/grid-overlay.js +379 -0
- package/template/public/bedrock/prototype-navigation.js +974 -0
- package/template/public/cmdk.js +146 -0
- package/template/public/ds-xray.css +112 -0
- package/template/public/ds-xray.js +271 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/icons/bolt-fill.svg +3 -0
- package/template/public/icons/bolt.svg +3 -0
- package/template/public/icons/caret-down-fill.svg +3 -0
- package/template/public/icons/check-double.svg +4 -0
- package/template/public/icons/check.svg +3 -0
- package/template/public/icons/chevron-left.svg +3 -0
- package/template/public/icons/chevron-right.svg +3 -0
- package/template/public/icons/circle-info.svg +6 -0
- package/template/public/icons/grid.svg +6 -0
- package/template/public/icons/message-square-1.svg +3 -0
- package/template/public/icons/message-square.svg +3 -0
- package/template/public/icons/messages.svg +4 -0
- package/template/public/icons/options-horizontal.svg +5 -0
- package/template/public/icons/swatches.svg +6 -0
- package/template/public/icons/workflow.svg +6 -0
- package/template/public/lightbox.js +87 -0
- package/template/public/proto-chrome.css +596 -0
- package/template/public/screen-comments.css +723 -0
- package/template/public/wireflow-client.js +26 -0
- package/template/scripts/build-storybooks.mjs +8 -0
- package/template/scripts/dev-setup.mjs +15 -0
- package/template/scripts/generate-stories.mjs +12 -0
- package/template/scripts/generate-variants.mjs +22 -0
- 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,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
|
+
}
|