create-feedbax 0.2.0 → 0.2.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/dist/cli.js +2042 -16
- package/dist/index.js +1997 -5
- package/package.json +1 -1
- package/template/.feedbax-template.sha256 +1 -1
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/README.md +58 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/package.json +9 -5
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/cloudflare-workers.d.ts +6 -0
- package/template/src/comment-creation.test.tsx +42 -1
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +108 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +89 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/profile-menu.tsx +30 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/connected-identity.test.ts +144 -0
- package/template/src/connected-identity.ts +122 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +241 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +1 -0
- package/template/src/feedback-runtime.ts +79 -12
- package/template/src/feedbax/config/index.test.ts +28 -0
- package/template/src/feedbax/config/index.ts +93 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +215 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +101 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +324 -0
- package/template/src/feedbax/feedback/index.ts +55 -1
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +97 -440
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +24 -5
- package/template/src/portal-feedback-form.tsx +18 -3
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +10 -0
- package/template/src/portal-server-entry.ts +4 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +298 -208
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +49 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +41 -1
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
9c41cfc9758db8b7b6c022fe973f24e7a94244c96c13b6679010702d3d39a054
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Working on this Feedbax installation
|
|
2
|
+
|
|
3
|
+
Read `README.md` for local setup, configuration, and checks. Read `CONNECTED.md` when configuring identity handoff, D1, sessions, verified Votes, or Comment attribution.
|
|
4
|
+
|
|
5
|
+
This is a flat application, not the Feedbax development monorepo. Product settings are in `src/feedbax.ts`; infrastructure is in `wrangler.jsonc`; secrets are in ignored `.dev.vars` and encrypted Worker secrets. `src/feedbax.test-fixture.ts` is never a live configuration.
|
|
6
|
+
|
|
7
|
+
Use the copied shadcn primitives in `src/components/ui`. Keep the configured Geist font and theme tokens. After changes, run `pnpm lint`, `pnpm type-check`, `pnpm test`, and `pnpm build`, fixing errors. Use `pnpm format:check` to check formatting.
|
|
8
|
+
|
|
9
|
+
For setup, `pnpm exec feedbax doctor` must pass with real Notion credentials/mappings and enabled bindings. Run `pnpm deploy:dry-run` before deployment and verify the actual live URL after `pnpm exec feedbax deploy`. Distinguish copied source, configured local readiness, and a verified deployment in the result.
|
|
10
|
+
|
|
11
|
+
## Connected social features
|
|
12
|
+
|
|
13
|
+
For following, in-app/email notifications, or image attachments, use the full Connected guide (CONNECTED.md in a generated app; docs/connected-profile.md in the source repository). Apply every included D1 migration, including the Follow baseline migration, configure the timer and dedicated processing queue for small mode, choose Cloudflare/Resend/custom email when required, and keep the R2 bucket private for images. Never use a Device Profile as verified email evidence. Run doctor and behavior checks; explicitly distinguish local tests from real sender delivery and production authentication acceptance.
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# Connected Profile in 0.2.1
|
|
2
|
+
|
|
3
|
+
Connected Profile is optional. Omit `connected` to retain Notion-only behavior. A Connected Installation requires a dedicated D1 database and an existing Product Team application that authenticates Participants on its server. There is no Feedbax email sign-in. Small processing also uses a dedicated Cloudflare Queue for background work.
|
|
4
|
+
|
|
5
|
+
## Configure an Installation
|
|
6
|
+
|
|
7
|
+
Create a D1 database using `pnpm exec wrangler d1 create <installation-name>`. Review the target account and add the returned identifiers to `wrangler.jsonc`:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"d1_databases": [
|
|
12
|
+
{
|
|
13
|
+
"binding": "FEEDBAX_DB",
|
|
14
|
+
"database_name": "your-installation",
|
|
15
|
+
"database_id": "your-database-id",
|
|
16
|
+
"migrations_dir": "migrations"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Apply the included migration using `pnpm exec wrangler d1 migrations apply FEEDBAX_DB --remote` (and `--local` for local testing). Never share this database between Installations. Configure HTTPS for local sign-in testing because identity cookies require Secure.
|
|
23
|
+
|
|
24
|
+
Add the following to `src/feedbax.ts`, alongside an absolute HTTPS `product.siteUrl`:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
connected: {
|
|
28
|
+
issuer: "https://app.example.com",
|
|
29
|
+
loginUrl: "https://app.example.com/feedbax-login",
|
|
30
|
+
},
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Generate at least 32 random bytes for `IDENTITY_HANDOFF_SECRET`. Store the same secret only on the Product Team application's server and in the portal's `.dev.vars` / encrypted Worker secrets. Never place it in `feedbax.ts`, frontend code, a query string, or logs. The lifecycle CLI deploys this secret alongside the other configured secrets. `feedbax doctor` checks the configuration and reads the remote D1 schema without changing it.
|
|
34
|
+
|
|
35
|
+
## Product application handoff
|
|
36
|
+
|
|
37
|
+
1. Feedbax's Sign in action visits `/internal/identity/start`, sets a five-minute browser challenge cookie, and redirects to the configured login URL with `state` and `callback` query parameters.
|
|
38
|
+
2. The Product Team application authenticates the current user. It must allow only the exact configured Feedbax callback, retain the supplied state, and derive the stable subject and display name from its authenticated server session. Never sign identity fields supplied by a browser.
|
|
39
|
+
3. Sign the payload below on the application server with HMAC-SHA256. Times are Unix seconds; the token lifetime must be at most 120 seconds. Use an unpredictable nonce. The audience must exactly equal the Feedbax origin and the issuer must exactly equal the configured issuer.
|
|
40
|
+
4. Return an HTML form which posts one `token` field to the allowlisted callback. Use `application/x-www-form-urlencoded` and `Referrer-Policy: strict-origin`. Do not put the token in a redirect URL. HTML-escape interpolated values. The form must retain its application Origin: `no-referrer` makes browser cross-origin form POSTs send `Origin: null`, which the portal rejects. `strict-origin` shares only the application origin, without its path or query.
|
|
41
|
+
5. Feedbax validates the signature, origin, browser challenge, audience, issuer, and expiry, consumes the token nonce and browser challenge once, and creates an opaque seven-day HttpOnly session. Sign out revokes that session in D1. No identity token is stored in browser JavaScript storage.
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
45
|
+
|
|
46
|
+
// All identity values come from the application's authenticated server session.
|
|
47
|
+
const now = Math.floor(Date.now() / 1000);
|
|
48
|
+
const payload = Buffer.from(
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
purpose: "feedbax-identity-v1",
|
|
51
|
+
subject: authenticatedUser.id,
|
|
52
|
+
displayName: authenticatedUser.displayName,
|
|
53
|
+
issuer: "https://app.example.com",
|
|
54
|
+
audience: "https://feedback.example.com",
|
|
55
|
+
state: validatedBrowserState,
|
|
56
|
+
nonce: randomBytes(32).toString("base64url"),
|
|
57
|
+
issuedAt: now,
|
|
58
|
+
expiresAt: now + 120,
|
|
59
|
+
}),
|
|
60
|
+
).toString("base64url");
|
|
61
|
+
const signature = createHmac("sha256", process.env.IDENTITY_HANDOFF_SECRET)
|
|
62
|
+
.update(`feedbax-identity-v1:${payload}`)
|
|
63
|
+
.digest("base64url");
|
|
64
|
+
const token = `${payload}.${signature}`;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The application must use the same stable subject across browsers; never use display name or an unverified email as the subject. “Verified” means asserted by that trusted application, not legal identity or a guarantee of one account per human. The stored identity is scoped to the configured issuer and subject. Changing the issuer or subject scheme requires deliberate identity migration; do not repurpose identifiers for other people. Secret rotation blocks old handoffs; existing opaque sessions remain valid until expiry, logout, or explicit database revocation.
|
|
68
|
+
|
|
69
|
+
## Data ownership and compatibility
|
|
70
|
+
|
|
71
|
+
Notion remains authoritative for Posts, publication, Team workflow, and Comment content. D1 owns identity, sessions, unique Votes, and verified Comment attribution. A unique database key enforces one Vote per subject and Post; repeated add/remove requests are idempotent. Counts and Top ordering in Connected Profile use D1, including across browsers. Anonymous historical Notion counts are preserved in Notion but excluded from verified counts; they cannot be migrated into verified Votes without identity evidence. The Notion Vote Count property remains the legacy Notion-only counter, not a synchronized Connected counter.
|
|
72
|
+
|
|
73
|
+
Native Comments receive the signed display name on the server. Only Comments with durable D1 attribution are labeled Verified; historical Comments remain Unverified. Notion-authored Comments retain the Product Team label. Resolving/deleting a Comment in Notion still removes it from public reads. Browser-held fifteen-minute Comment capabilities and unpublished Post draft capabilities retain their existing scope; this release does not add cross-device Comment editing or recover draft capabilities. Post submission still uses a private contact Device Profile, editable through Contact profile; it does not gain an identity-verification badge.
|
|
74
|
+
|
|
75
|
+
If D1 is unavailable, Connected operations fail closed. They never fall back to anonymous voting or trust a browser-provided name. Attribution failures reject creation and attempt to remove the just-created Notion Comment; if external cleanup also fails, any surviving Comment remains unverified. The portal does not automatically retry Comment creation, since Notion offers no creation idempotency guarantee.
|
|
76
|
+
|
|
77
|
+
Back up the database and use Cloudflare D1 recovery before destructive changes. Disabling `connected` retains D1 data but switches visible Vote counts back to Notion-only history. Expired sessions/nonces may be purged during maintenance after their expiry; never delete active rows to reset Votes.
|
|
78
|
+
|
|
79
|
+
## Verification and operating limits
|
|
80
|
+
|
|
81
|
+
Before enabling in production, test the real application's sign-in/callback, a second browser, duplicate Votes, sign out, invalid/expired/replayed tokens, and a native Notion Comment with verified attribution. Use the dedicated acceptance Installation; do not turn on the dogfood portal until a real application login endpoint is configured.
|
|
82
|
+
|
|
83
|
+
Connected Top ranking currently reads all matching Published Posts before ranking and pagination. This favors correct ranking for the initial small-team scope; measure larger installations before adoption. Public counts can be briefly cached under the existing public-read policy; private Vote state and identity endpoints are never publicly cached.
|
|
84
|
+
|
|
85
|
+
Implementation references: [D1 database batches](https://developers.cloudflare.com/d1/worker-api/d1-database/) and [Drizzle D1 adapter](https://orm.drizzle.team/docs/sqlite/connect-cloudflare-d1).
|
|
86
|
+
|
|
87
|
+
## Complete application endpoint
|
|
88
|
+
|
|
89
|
+
Copy the following into your application's server code. It returns a standard Web `Response` and works behind a Node-compatible route handler. Configure the app's existing authentication middleware before this endpoint so an unauthenticated Participant is taken through normal app sign-in and returned to the same handoff URL. If your middleware does not redirect, the example returns an explicit 401; sign in to the product first and retry Feedbax.
|
|
90
|
+
|
|
91
|
+
The only app-specific dependency is `authenticate(request)`: call the same trusted session lookup used by your protected app pages. Return a stable internal user ID as `subject` and its server-read display name. Never read these values from form input, query parameters, browser storage, or an unsigned header. Do not hardcode a demonstration user in production.
|
|
92
|
+
|
|
93
|
+
```ts title="server/feedbax-login.ts"
|
|
94
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
95
|
+
|
|
96
|
+
export function createIdentityLoginHandler(options: {
|
|
97
|
+
applicationOrigin: string;
|
|
98
|
+
portalOrigin: string;
|
|
99
|
+
issuer: string;
|
|
100
|
+
secret: string;
|
|
101
|
+
authenticate(request: Request): Promise<{
|
|
102
|
+
subject: string;
|
|
103
|
+
displayName: string;
|
|
104
|
+
email?: string;
|
|
105
|
+
emailVerified?: boolean;
|
|
106
|
+
} | null>;
|
|
107
|
+
}) {
|
|
108
|
+
const app = new URL(options.applicationOrigin).origin;
|
|
109
|
+
const portal = new URL(options.portalOrigin).origin;
|
|
110
|
+
if (
|
|
111
|
+
![app, portal, options.issuer].every(
|
|
112
|
+
(url) => new URL(url).protocol === "https:",
|
|
113
|
+
) ||
|
|
114
|
+
options.secret.length < 32
|
|
115
|
+
)
|
|
116
|
+
throw new Error(
|
|
117
|
+
"Configure HTTPS origins and a server-only handoff secret of at least 32 characters.",
|
|
118
|
+
);
|
|
119
|
+
const headers = {
|
|
120
|
+
"Cache-Control": "no-store",
|
|
121
|
+
"Referrer-Policy": "strict-origin",
|
|
122
|
+
};
|
|
123
|
+
return async (request: Request): Promise<Response> => {
|
|
124
|
+
const url = new URL(request.url);
|
|
125
|
+
if (request.method !== "GET")
|
|
126
|
+
return new Response("Method not allowed", { status: 405, headers });
|
|
127
|
+
const state = url.searchParams.get("state");
|
|
128
|
+
const callback = url.searchParams.get("callback");
|
|
129
|
+
if (
|
|
130
|
+
url.origin !== app ||
|
|
131
|
+
!state ||
|
|
132
|
+
!/^[A-Za-z0-9_-]{43}$/.test(state) ||
|
|
133
|
+
callback !== `${portal}/internal/identity/callback` ||
|
|
134
|
+
url.searchParams.getAll("state").length !== 1 ||
|
|
135
|
+
url.searchParams.getAll("callback").length !== 1
|
|
136
|
+
)
|
|
137
|
+
return new Response("Invalid Feedbax sign-in request", {
|
|
138
|
+
status: 400,
|
|
139
|
+
headers,
|
|
140
|
+
});
|
|
141
|
+
const user = await options.authenticate(request);
|
|
142
|
+
if (!user)
|
|
143
|
+
return new Response(
|
|
144
|
+
"Sign in to your product, then retry Feedbax sign-in.",
|
|
145
|
+
{ status: 401, headers },
|
|
146
|
+
);
|
|
147
|
+
if (
|
|
148
|
+
![user.subject, user.displayName].every(
|
|
149
|
+
(value) =>
|
|
150
|
+
typeof value === "string" &&
|
|
151
|
+
value.trim().length > 0 &&
|
|
152
|
+
value.length <= 512,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return new Response("Application identity is incomplete", {
|
|
156
|
+
status: 403,
|
|
157
|
+
headers,
|
|
158
|
+
});
|
|
159
|
+
const now = Math.floor(Date.now() / 1000);
|
|
160
|
+
const payload = Buffer.from(
|
|
161
|
+
JSON.stringify({
|
|
162
|
+
purpose: "feedbax-identity-v1",
|
|
163
|
+
subject: user.subject,
|
|
164
|
+
displayName: user.displayName,
|
|
165
|
+
...(user.emailVerified === true && user.email
|
|
166
|
+
? { email: user.email, emailVerified: true }
|
|
167
|
+
: {}),
|
|
168
|
+
issuer: options.issuer,
|
|
169
|
+
audience: portal,
|
|
170
|
+
state,
|
|
171
|
+
nonce: randomBytes(32).toString("base64url"),
|
|
172
|
+
issuedAt: now,
|
|
173
|
+
expiresAt: now + 120,
|
|
174
|
+
}),
|
|
175
|
+
).toString("base64url");
|
|
176
|
+
const signature = createHmac("sha256", options.secret)
|
|
177
|
+
.update(`feedbax-identity-v1:${payload}`)
|
|
178
|
+
.digest("base64url");
|
|
179
|
+
const nonce = randomBytes(24).toString("base64url");
|
|
180
|
+
const html = `<!doctype html><html lang="en"><meta charset="utf-8"><title>Continue to feedback</title><body><form method="post" action="${escapeHtml(callback)}"><input type="hidden" name="token" value="${payload}.${signature}"><button type="submit">Continue to feedback</button></form><script nonce="${nonce}">document.forms[0].requestSubmit()</script></body></html>`;
|
|
181
|
+
return new Response(html, {
|
|
182
|
+
headers: {
|
|
183
|
+
...headers,
|
|
184
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
185
|
+
"Content-Security-Policy": `default-src 'none'; form-action ${portal}; script-src 'nonce-${nonce}'; frame-ancestors 'none'`,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function escapeHtml(value: string) {
|
|
191
|
+
return value.replace(
|
|
192
|
+
/[&<>"']/g,
|
|
193
|
+
(character) =>
|
|
194
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
195
|
+
character
|
|
196
|
+
]!,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Register the handler at the exact URL configured as `connected.loginUrl`. For a framework with Web route handlers, the wiring looks like this (adapt the import paths and session lookup to your application):
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import { createIdentityLoginHandler } from "./server/feedbax-login";
|
|
205
|
+
import { readAuthenticatedAppUser } from "./server/auth";
|
|
206
|
+
|
|
207
|
+
export const GET = createIdentityLoginHandler({
|
|
208
|
+
applicationOrigin: "https://app.example.com",
|
|
209
|
+
portalOrigin: "https://feedback.example.com",
|
|
210
|
+
issuer: "https://app.example.com",
|
|
211
|
+
secret: process.env.IDENTITY_HANDOFF_SECRET!,
|
|
212
|
+
authenticate: async (request) => {
|
|
213
|
+
const user = await readAuthenticatedAppUser(request);
|
|
214
|
+
return user ? { subject: user.id, displayName: user.displayName } : null;
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`readAuthenticatedAppUser` is your existing application's authentication adapter, not a Feedbax API. On Next.js, the `GET` export belongs in `app/feedbax-login/route.ts`; keep the factory and session lookup in server-only modules. On TanStack Start, call the same handler from a server route's GET handler. Other frameworks can adapt their request/response objects to the Web API. Keep the route behind your current session middleware and rate limits; Feedbax does not create or manage the product's accounts.
|
|
220
|
+
|
|
221
|
+
## Verify the entire sign-in loop
|
|
222
|
+
|
|
223
|
+
1. Run `feedbax doctor`, build, and deploy the configured portal. Confirm its HTTPS origin exactly matches `product.siteUrl` and the application's configured audience/callback.
|
|
224
|
+
2. Sign in to the product, open the portal, and choose Sign in. You should return with your server-provided display name. A handoff form never includes your API key or handoff secret.
|
|
225
|
+
3. Add a Vote, reload, and sign in from another browser with the same product account. The Vote should still be selected. Repeated add requests must not increase it twice.
|
|
226
|
+
4. Post a Comment and confirm Verified appears next to its app-provided name. A historical browser-only Comment still shows Unverified. Reply/resolve from Notion and confirm moderation is reflected after refresh.
|
|
227
|
+
5. Sign out of Feedbax. Its session should stop authorizing Votes/Comments immediately. This signs out only the portal session; product sign-out is managed by the product. An already-issued portal session expires after seven days unless explicitly revoked sooner.
|
|
228
|
+
6. Reuse a captured callback or change its state/audience/signature in a dedicated test environment. Feedbax must reject it. Do not copy production identity tokens into logs or issue reports.
|
|
229
|
+
|
|
230
|
+
## Authentication troubleshooting
|
|
231
|
+
|
|
232
|
+
| Symptom | Check |
|
|
233
|
+
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
234
|
+
| Product endpoint returns 401 | Use your existing session middleware and complete the product's login first; the `authenticate` adapter must read that session. |
|
|
235
|
+
| Invalid sign-in request on the product | Preserve the single `state` and `callback` parameters; allow only the configured exact callback. Do not rebuild the callback from arbitrary browser input. |
|
|
236
|
+
| Callback returns 403 | The form must POST directly from the configured login URL's origin. Check reverse-proxy origin handling and sandbox policies. |
|
|
237
|
+
| Callback returns 401 | Check identical handoff secrets, exact issuer/audience, current server clocks, a lifetime of at most 120 seconds, and an unused browser challenge. Start sign-in again after a failed or expired attempt. |
|
|
238
|
+
| Challenge cookie absent | Use HTTPS and the same browser. The challenge uses Secure, HttpOnly, SameSite=None, and the `__Host-` prefix. Cookie-blocking policies may require a normal top-level product/portal flow. |
|
|
239
|
+
| Doctor or participation returns unavailable | Check the dedicated `FEEDBAX_DB` binding, applied migration, remote account/database, and required Worker secrets. The profile fails closed. |
|
|
240
|
+
| Name differs from the current app name | A session reads the last server-asserted name; sign in again after a profile change. Old Comments retain their recorded attribution. |
|
|
241
|
+
| Anonymous vote totals differ after enabling Connected | Historical Notion counters are not verified Vote records; Connected counts only D1 Votes. |
|
|
242
|
+
|
|
243
|
+
## Keep the three server secrets distinct
|
|
244
|
+
|
|
245
|
+
| Secret | Stored where | Purpose |
|
|
246
|
+
| ---------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------- |
|
|
247
|
+
| `IDENTITY_HANDOFF_SECRET` | Product server and portal secret store | Sign/verify identity handoffs. |
|
|
248
|
+
| `PARTICIPATION_SIGNING_SECRET` | Portal secret store | Participation Passes and short-lived Comment capabilities. |
|
|
249
|
+
| `FEEDBAX_API_SECRET` / portal `FEEDBAX_API_KEY_HASH` | Plaintext on product server; hash on portal | Trusted Post submission from the feedback block or another integration. |
|
|
250
|
+
|
|
251
|
+
Generate independent random values. Store production values through your platform's secret manager. When rotating the handoff secret, coordinate both servers; existing portal sessions require separate revocation if they must be ended immediately.
|
|
252
|
+
|
|
253
|
+
## Following and notifications
|
|
254
|
+
|
|
255
|
+
Connected defaults to `processing: "small"`. Its five-minute timer only enqueues a constant work signal into a dedicated Cloudflare Queue; a consumer then prepares one Follow history page, observes one followed Post, attempts one notification email, and cleans up at most five images. This separates the Free timer's 10 ms CPU allowance from Queues' documented default consumer limit of 30 seconds. The queue contains no Participant identity, email, or content. Set `connected.processing: "standard"` for ten preparations/observed Posts, twenty emails, and fifty cleanups directly in the timer; that mode targets Workers Paid and needs no processing queue. Cloudflare Email Service separately requires Workers Paid for arbitrary recipients. Resend/custom delivery supports the small Free setup. See [Workers limits](https://developers.cloudflare.com/workers/platform/limits/), [Queues limits](https://developers.cloudflare.com/queues/platform/limits/), [Queues pricing](https://developers.cloudflare.com/queues/platform/pricing/), and [D1 limits](https://developers.cloudflare.com/d1/platform/limits/).
|
|
256
|
+
|
|
257
|
+
### Create and bind the processing queue
|
|
258
|
+
|
|
259
|
+
Create one queue for this Installation in the same Cloudflare account, before running the guided creator or deploying:
|
|
260
|
+
|
|
261
|
+
```sh
|
|
262
|
+
pnpm exec wrangler queues create your-installation-updates
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The guided creator asks for its name and writes both bindings. For a manual setup, add this beside the existing D1 bindings in `wrangler.jsonc`:
|
|
266
|
+
|
|
267
|
+
```json
|
|
268
|
+
{
|
|
269
|
+
"triggers": { "crons": ["*/5 * * * *"] },
|
|
270
|
+
"queues": {
|
|
271
|
+
"producers": [
|
|
272
|
+
{ "binding": "FEEDBAX_UPDATES", "queue": "your-installation-updates" }
|
|
273
|
+
],
|
|
274
|
+
"consumers": [
|
|
275
|
+
{
|
|
276
|
+
"queue": "your-installation-updates",
|
|
277
|
+
"max_batch_size": 1,
|
|
278
|
+
"max_batch_timeout": 1,
|
|
279
|
+
"max_concurrency": 1,
|
|
280
|
+
"max_retries": 3,
|
|
281
|
+
"retry_delay": 60
|
|
282
|
+
}
|
|
283
|
+
]
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Keep producer and consumer names identical, and use a dedicated queue for each Installation. Small mode fails closed when the binding is absent; it does not run the heavy work inline. `doctor` validates the paired bindings, bounds, remote queue existence, and all D1 migrations. Template-only output needs this configuration added manually. Local Wrangler simulates queues but does not prove remote capacity or email receipt.
|
|
289
|
+
|
|
290
|
+
At five-minute intervals, normal small processing produces 288 signals/day, usually 864 queue operations (write/read/delete). Three queue redeliveries per signal would raise that to 1,728 operations; other queues share the account's Free allowance of 10,000 operations/day. Free retention is 24 hours. Keep the batch and concurrency at one. A failed queue invocation is retried up to three times, at least a minute apart; D1 remains the authority for unfinished work and the next timer signal can resume it. Leases, transactional checkpoints, event deduplication, and provider delivery keys protect repeated attempts. This is not an exactly-once guarantee. Watch timer CPU, queue backlog/failed consumers, D1 quotas, and provider limits on the actual account. No plan upgrade or billing change is performed by these settings.
|
|
291
|
+
|
|
292
|
+
Connected Participants can explicitly Follow a Published Post from its detail view. Following is independent of voting; submitting, commenting, or voting does not silently subscribe a Participant. The header bell opens a private inbox with unread state, Mark read, Mark all read, pagination, and email preferences. Signing out hides these controls and private state is never publicly cached.
|
|
293
|
+
|
|
294
|
+
The timer signals the small consumer every five minutes; standard mode observes directly. Small processing checks one active followed Post, prioritizing the least recently checked; standard checks ten. Each observation reads one 50-Comment cursor page and resumes later pages on subsequent jobs. Normal small cadence budgets 288 observations and email attempts per day, with additional attempts possible after queue redelivery. Busy Installations and long histories have longer latency; delivery older than 24 hours is skipped. Status notifications compare each Follow's recorded status, so minute-rounded Notion timestamps cannot hide an observed transition. Intermediate states between observations may be coalesced.
|
|
295
|
+
|
|
296
|
+
A new Follow snapshots existing Comment IDs from its current minute, preventing both historical notices and missing later Comments with the same rounded timestamp. Small mode prepares every new Follow in the queue, reading one 50-Comment page per run; the request does not read Comment history. Standard mode prepares the first page immediately and resumes longer histories in the background. The button shows preparation with a cancel action until the baseline is complete. Updates begin when preparation completes; earlier Comments and status changes during preparation are excluded. Repeating Follow from another browser preserves the original baseline. Unfollowing cancels preparation and removes its private baseline. Participants do not receive notifications about Comments with their own recorded attribution.
|
|
297
|
+
|
|
298
|
+
In-app notifications are deduplicated by event and Participant, survive browser changes, and retain independent read state. Notifications and outbound delivery recheck that the Post is still Published. Stop following from the same Post to stop future updates. Email is independently opt-in through the inbox switch and can be disabled there or through the unsubscribe link in each email. Unsubscribe links show a confirmation page on GET and accept a one-click POST from supporting mail clients. A link scanner's GET does not change preferences.
|
|
299
|
+
|
|
300
|
+
### Verified delivery address
|
|
301
|
+
|
|
302
|
+
Application identity and email ownership are separate assertions. To offer email notifications, derive both of these additional signed claims from your application's authenticated server record:
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
...(user.emailVerified === true
|
|
306
|
+
? { email: user.email, emailVerified: true }
|
|
307
|
+
: {}),
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Never accept these values from the browser or copy the unverified Device Profile. Feedbax ignores an email unless the signed `emailVerified` claim is true. The contact address is stored privately in D1, is refreshed at the next successful handoff, and never appears in public identity, Post, Comment, or notification responses. Existing handoff implementations without these optional claims keep working for in-app notifications. A subsequent handoff without a verified address removes email eligibility.
|
|
311
|
+
|
|
312
|
+
### Choose one email sender
|
|
313
|
+
|
|
314
|
+
All choices share notification content, opt-in, unsubscribe, the D1 delivery queue, and a stable `Idempotency-Key`. The sender address belongs to your verified sending domain. Do not use a Participant's address as the sender.
|
|
315
|
+
|
|
316
|
+
**Cloudflare Email Service** uses the current structured Workers sending API. Onboard and verify your sending domain in Cloudflare Email Service, then configure:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
connected: {
|
|
320
|
+
issuer: "https://app.example.com",
|
|
321
|
+
loginUrl: "https://app.example.com/feedbax-login",
|
|
322
|
+
email: { provider: "cloudflare", from: "updates@example.com" },
|
|
323
|
+
},
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
```json
|
|
327
|
+
{
|
|
328
|
+
"send_email": [
|
|
329
|
+
{
|
|
330
|
+
"name": "FEEDBAX_EMAIL",
|
|
331
|
+
"allowed_sender_addresses": ["updates@example.com"]
|
|
332
|
+
}
|
|
333
|
+
]
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Use Email Service's outbound sending capability, not a restricted legacy inbound-routing destination. See [Workers sending API](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/) and [domain setup](https://developers.cloudflare.com/email-service/configuration/domains/). Local Wrangler simulation does not deliver real messages. Do not enable a remote email binding during general local testing; it sends real mail.
|
|
338
|
+
|
|
339
|
+
**Resend** requires a verified domain and a sending API key. Set `email: { provider: "resend", from: "updates@example.com" }` and store the key as `EMAIL_PROVIDER_SECRET` in `.dev.vars` and `pnpm exec wrangler secret put EMAIL_PROVIDER_SECRET`. No email binding is required. Feedbax posts to `https://api.resend.com/emails`, passing its stable delivery key in the `Idempotency-Key` header. See [Resend sending](https://resend.com/docs/api-reference/emails/send-email) and [idempotency](https://resend.com/docs/dashboard/emails/idempotency-keys).
|
|
340
|
+
|
|
341
|
+
**Custom HTTPS sender** connects your own backend or mail provider adapter:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
email: {
|
|
345
|
+
provider: "custom",
|
|
346
|
+
from: "updates@example.com",
|
|
347
|
+
endpoint: "https://app.example.com/internal/feedbax-email",
|
|
348
|
+
},
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Store a dedicated shared bearer secret as `EMAIL_PROVIDER_SECRET`. Your endpoint must validate `Authorization: Bearer <secret>`, accept the following JSON, deliver or durably enqueue it, and return 2xx only after acceptance. It must persist `Idempotency-Key` uniqueness for at least 24 hours and return the same successful result for retries without sending again. Keep this endpoint private to the authenticated server contract; a browser must never receive the secret.
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
interface NotificationEmail {
|
|
355
|
+
from: string;
|
|
356
|
+
to: string[]; // exactly one recipient per Feedbax request
|
|
357
|
+
subject: string;
|
|
358
|
+
text: string;
|
|
359
|
+
headers: {
|
|
360
|
+
"List-Unsubscribe": string;
|
|
361
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click";
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Preserve the unsubscribe headers, disable tracking unless your Product Team has deliberately chosen it, and never return provider credentials or recipient details to the portal. Feedbax requires HTTPS, refuses redirects, and times out custom/Resend requests after ten seconds. This adapter can use SMTP or another provider behind your server; Feedbax does not require a general plugin system or expose SMTP passwords in application configuration.
|
|
367
|
+
|
|
368
|
+
### Delivery limits and recovery
|
|
369
|
+
|
|
370
|
+
Each small queue job attempts at most one email; a standard scheduled invocation attempts at most twenty. D1 leases serialize workers, and failures use bounded exponential retry, starting at one minute and capped at six hours. Delivery is abandoned after eight failed attempts or 24 hours, whichever comes first. Resend's provider deduplication window is 24 hours; Cloudflare's documented binding has no equivalent idempotency parameter, so a crash or ambiguous timeout after acceptance can result in a duplicate email. Custom senders must supply their own durable deduplication. No adapter promises exactly-once inbox delivery, and provider acceptance does not prove final recipient delivery. In-app notifications remain available when email fails. Inspect provider suppression/bounce logs and the private D1 delivery state to investigate delivery failures; never publish these records.
|
|
371
|
+
|
|
372
|
+
## Image attachments
|
|
373
|
+
|
|
374
|
+
Set `connected.images: true` and bind a dedicated private R2 bucket:
|
|
375
|
+
|
|
376
|
+
```json
|
|
377
|
+
{
|
|
378
|
+
"r2_buckets": [
|
|
379
|
+
{ "binding": "FEEDBAX_IMAGES", "bucket_name": "your-installation-images" }
|
|
380
|
+
]
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Create the bucket with `pnpm exec wrangler r2 bucket create your-installation-images`. Keep its r2.dev endpoint and public custom-domain access disabled; only Feedbax serves its objects. Apply all included D1 migrations. `PARTICIPATION_SIGNING_SECRET` must contain at least 32 random characters; it already supports participation capabilities and also signs scoped image review and unsubscribe links using independent purposes.
|
|
385
|
+
|
|
386
|
+
Verified Participants can add up to four JPEG, PNG, or WebP images to a new Post or Comment. Each image is at most 5 MiB. The server checks MIME type, image signatures, size, ownership, and association permissions; SVG, video, and general file attachments are rejected. The form shows upload progress, previews, and removal controls and waits for uploads before submission. Text is still required. Images can be added on creation; replacing attachments on an existing Post or Comment is not part of this release.
|
|
387
|
+
|
|
388
|
+
Images attach to the newly created Notion content through D1 records. Pending uploads are private to their authenticated uploader and expire after 24 hours. Scheduled cleanup removes unused uploads and retries interrupted deletions. Public image requests recheck Post publication and, for Comment images, that the Comment still exists in an open Thread. The uploader can inspect pending images and their own existing Draft Post images privately; removing the associated content also removes uploader and review-link access. Unpublishing a Post or resolving/deleting a Comment stops public image access; downloads use no-store and cannot be embedded from another origin.
|
|
389
|
+
|
|
390
|
+
For a Draft Post, Feedbax writes read-only image review links into its private Notion page body. Team Members use them to inspect attachments before publication. These links are bearer capabilities: keep the page body private, do not paste its review URLs into public Description or Team Response fields, and restrict Notion workspace access normally. Public Post projections contain only the ordinary visibility-checked image route, never the review token. Rotating `PARTICIPATION_SIGNING_SECRET` invalidates existing private review links and email unsubscribe links as well as older participation capabilities.
|
|
391
|
+
|
|
392
|
+
Images retain their original bytes; Feedbax does not transcode them or strip EXIF. R2 and D1 need coordinated backups because the association records authorize access to the objects. Removing the R2 binding must be paired with disabling `connected.images`; missing storage fails closed. Images are not included in the stable trusted `/api/v1/posts` submission contract or the copied feedback block in this release.
|
|
393
|
+
|
|
394
|
+
## Expanded production acceptance
|
|
395
|
+
|
|
396
|
+
In a dedicated Installation, apply all D1 migrations, configure the timer and processing queue for small mode, verify the sender domain, and use a private R2 bucket. Run `feedbax doctor`, then verify:
|
|
397
|
+
|
|
398
|
+
1. Sign in from two browsers through the real product app, including a verified email assertion. Follow/unfollow the same Published Post and check continuity. Confirm a Device Profile cannot substitute for identity.
|
|
399
|
+
2. Change a followed Post's status and add a Product Team Comment directly in Notion. Wait for the timer and queue consumer (or the standard scheduled invocation). Inspect their separate outcomes and CPU limits. Confirm exactly one in-app event per observed change, private read state across browsers, and no access from another Participant.
|
|
400
|
+
3. Opt in to email for a test recipient, confirm provider acceptance and actual receipt, force a retry in the dedicated test adapter, and verify its documented deduplication behavior. Check unsubscribe GET is non-mutating and POST disables future email while retaining in-app updates.
|
|
401
|
+
4. Upload an image to a Draft Post. Confirm only the uploader and private Notion review link can access it; publish the Post and confirm public display, then unpublish and confirm public access stops. Repeat for a Comment and resolve it in Notion.
|
|
402
|
+
5. Reject a forged review link, wrong-owner image association, oversized/unsupported image, unsigned mutation, and cross-origin mutation. Check mobile previews, unread states, empty/error/retry states, and cleanup of abandoned uploads.
|
|
403
|
+
|
|
404
|
+
These checks require your real application, sender, and infrastructure. Local tests and builds do not constitute production delivery or release evidence.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Your Feedbax portal
|
|
2
|
+
|
|
3
|
+
This is an application you own. Product Teams manage Posts, statuses, Comments, and release notes in Notion; the portal runs on Cloudflare Workers.
|
|
4
|
+
|
|
5
|
+
[Feedbax on GitHub](https://github.com/jessequartey/feedbax) · [Setup and operations](https://feedbax-docs.jessefquartey.workers.dev/docs)
|
|
6
|
+
|
|
7
|
+
## Start locally
|
|
8
|
+
|
|
9
|
+
Use Node.js 22.14+ and pnpm 10. Guided setup already installs dependencies and configures the app:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Open the local address printed by Vite. If this directory was created with `--template-only`, run `pnpm install` and finish configuration first; copied source is not yet connected to Notion.
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
| File | Purpose |
|
|
20
|
+
| ------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| `src/feedbax.ts` | Public product settings, feature switches, Notion IDs/property mappings, and optional Connected settings |
|
|
22
|
+
| `.dev.vars` | Ignored local Notion token, API-key hash, participation signing secret, and enabled Turnstile/identity secrets |
|
|
23
|
+
| `wrangler.jsonc` | Worker name, hostnames, rate-limit bindings, and optional dedicated D1 binding |
|
|
24
|
+
| `src/components/ui` | Your copied shadcn primitives, utilities, and theme |
|
|
25
|
+
| `CONNECTED.md` | Complete existing-application authentication setup and verification |
|
|
26
|
+
|
|
27
|
+
The plaintext trusted API key printed during setup belongs on your product server. The portal stores its hash. Never expose either Notion credentials or signing secrets through browser configuration. Existing `src/feedbax.test-fixture.ts` is for tests only.
|
|
28
|
+
|
|
29
|
+
For template-only output, follow the [agent setup guide](https://github.com/jessequartey/feedbax/blob/main/docs/agent-setup.md) and supply real Notion mappings and secrets before diagnostics. For Connected, follow `CONNECTED.md`, implement the signed application endpoint, bind a dedicated D1 database, and apply the included migration.
|
|
30
|
+
|
|
31
|
+
## Check and deploy
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pnpm exec feedbax doctor
|
|
35
|
+
pnpm format:check
|
|
36
|
+
pnpm lint
|
|
37
|
+
pnpm type-check
|
|
38
|
+
pnpm test
|
|
39
|
+
pnpm build
|
|
40
|
+
pnpm deploy:dry-run
|
|
41
|
+
pnpm exec feedbax deploy
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`doctor` checks actual configuration, permissions, and enabled bindings. `deploy:dry-run` checks the bundle. The final command publishes the configured Worker and uploads only supported secrets. Confirm the live URL and enabled flows afterward.
|
|
45
|
+
|
|
46
|
+
## Customize and integrate
|
|
47
|
+
|
|
48
|
+
Keep the included Geist font and shadcn theme tokens consistent. Run `pnpm lint` after UI changes; it checks theme colors, unknown Tailwind classes, and inspectable component classes. Update individual primitives instead of reapplying a full preset.
|
|
49
|
+
|
|
50
|
+
Use the [feedback block guide](https://github.com/jessequartey/feedbax/blob/main/apps/docs/content/docs/integrations/feedback-block.mdx) to add feedback collection to your product. Its server helper uses a trusted submission key and new Posts remain unpublished until your Product Team moderates them in Notion. The portal’s standard footer links to Feedbax on GitHub.
|
|
51
|
+
|
|
52
|
+
Keep Comments and browser capabilities distinct from verified identity: Notion-only names/email are unverified, Votes are best-effort, and recent Comment editing uses short-lived browser capabilities. Connected adds signed identity, unique Votes, and durable attribution without moving content out of Notion.
|
|
53
|
+
|
|
54
|
+
See `AGENTS.md` for the short agent workflow.
|
|
55
|
+
|
|
56
|
+
## Connected social features
|
|
57
|
+
|
|
58
|
+
For following, in-app/email notifications, or image attachments, use the full Connected guide (CONNECTED.md in a generated app; docs/connected-profile.md in the source repository). Apply every included D1 migration, including the Follow baseline migration, configure the timer and dedicated processing queue for small mode, choose Cloudflare/Resend/custom email when required, and keep the R2 bucket private for images. Never use a Device Profile as verified email evidence. Run doctor and behavior checks; explicitly distinguish local tests from real sender delivery and production authentication acceptance.
|
package/template/components.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import js from "@eslint/js";
|
|
2
|
+
import { plugin as shadcn } from "@shadcn/lint";
|
|
2
3
|
import globals from "globals";
|
|
3
4
|
import tseslint from "typescript-eslint";
|
|
4
5
|
|
|
@@ -17,9 +18,31 @@ export default tseslint.config(
|
|
|
17
18
|
js.configs.recommended,
|
|
18
19
|
...tseslint.configs.recommended,
|
|
19
20
|
{
|
|
21
|
+
plugins: { shadcn },
|
|
20
22
|
languageOptions: {
|
|
21
23
|
globals: { ...globals.browser, ...globals.node },
|
|
22
24
|
parserOptions: { tsconfigRootDir: import.meta.dirname },
|
|
23
25
|
},
|
|
24
26
|
},
|
|
27
|
+
{
|
|
28
|
+
files: ["**/*.{jsx,tsx}"],
|
|
29
|
+
rules: {
|
|
30
|
+
"shadcn/no-raw-colors": "error",
|
|
31
|
+
// Cloudflare's script discovers these challenge containers by class name.
|
|
32
|
+
"shadcn/no-unknown-classes": ["error", { allow: ["cf-turnstile"] }],
|
|
33
|
+
"shadcn/require-static-classes": "error",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
// Shared primitives own their class forwarding; application usage is checked.
|
|
38
|
+
files: ["packages/ui/src/components/**", "src/components/ui/**"],
|
|
39
|
+
rules: { "shadcn/require-static-classes": "off" },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
// The upstream Sonner wrapper keeps this marker for toast customization.
|
|
43
|
+
files: ["**/components/sonner.tsx"],
|
|
44
|
+
rules: {
|
|
45
|
+
"shadcn/no-unknown-classes": ["error", { allow: ["toaster"] }],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
25
48
|
);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS participants (subject TEXT PRIMARY KEY NOT NULL, display_name TEXT NOT NULL);
|
|
2
|
+
CREATE TABLE IF NOT EXISTS identity_nonces (nonce TEXT PRIMARY KEY NOT NULL, expires_at INTEGER NOT NULL);
|
|
3
|
+
CREATE TABLE IF NOT EXISTS participant_sessions (token_hash TEXT PRIMARY KEY NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), expires_at INTEGER NOT NULL);
|
|
4
|
+
CREATE TABLE IF NOT EXISTS participant_votes (post_slug TEXT NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), PRIMARY KEY(post_slug, subject));
|
|
5
|
+
CREATE TABLE IF NOT EXISTS comment_attribution (comment_id TEXT PRIMARY KEY NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), display_name TEXT NOT NULL);
|
|
6
|
+
CREATE INDEX IF NOT EXISTS participant_sessions_expiry ON participant_sessions(expires_at);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS post_follows (post_slug TEXT NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), followed_at INTEGER NOT NULL, PRIMARY KEY(post_slug, subject));
|
|
2
|
+
CREATE INDEX IF NOT EXISTS post_follows_subject ON post_follows(subject);
|
|
3
|
+
CREATE TABLE IF NOT EXISTS participant_contacts (subject TEXT PRIMARY KEY REFERENCES participants(subject), email TEXT, email_enabled INTEGER NOT NULL DEFAULT 0);
|
|
4
|
+
CREATE TABLE IF NOT EXISTS post_observations (post_slug TEXT PRIMARY KEY, status TEXT NOT NULL, checked_at INTEGER NOT NULL, lease_until INTEGER NOT NULL DEFAULT 0, comment_cursor TEXT, lease_token TEXT);
|
|
5
|
+
CREATE TABLE IF NOT EXISTS post_events (id TEXT PRIMARY KEY, post_slug TEXT NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL, created_at INTEGER NOT NULL, actor_subject TEXT);
|
|
6
|
+
CREATE TABLE IF NOT EXISTS participant_notifications (id TEXT PRIMARY KEY, event_id TEXT NOT NULL REFERENCES post_events(id), subject TEXT NOT NULL REFERENCES participants(subject), created_at INTEGER NOT NULL, read_at INTEGER, email_state TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0, lease_token TEXT, UNIQUE(event_id, subject));
|
|
7
|
+
CREATE INDEX IF NOT EXISTS notifications_subject ON participant_notifications(subject, created_at, id);
|
|
8
|
+
CREATE INDEX IF NOT EXISTS notifications_delivery ON participant_notifications(email_state, next_attempt_at);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS image_attachments (id TEXT PRIMARY KEY, subject TEXT NOT NULL REFERENCES participants(subject), object_key TEXT NOT NULL UNIQUE, content_type TEXT NOT NULL, bytes INTEGER NOT NULL, created_at INTEGER NOT NULL, post_slug TEXT, comment_id TEXT, deleting INTEGER NOT NULL DEFAULT 0);
|
|
2
|
+
CREATE INDEX IF NOT EXISTS image_attachments_post ON image_attachments(post_slug,comment_id);
|
|
3
|
+
CREATE INDEX IF NOT EXISTS image_attachments_cleanup ON image_attachments(created_at) WHERE post_slug IS NULL;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ALTER TABLE post_follows ADD COLUMN observed_status TEXT;
|
|
2
|
+
ALTER TABLE post_follows ADD COLUMN baseline_token TEXT;
|
|
3
|
+
ALTER TABLE post_follows ADD COLUMN ready INTEGER NOT NULL DEFAULT 0;
|
|
4
|
+
ALTER TABLE post_follows ADD COLUMN baseline_cursor TEXT;
|
|
5
|
+
ALTER TABLE post_follows ADD COLUMN baseline_lease_until INTEGER NOT NULL DEFAULT 0;
|
|
6
|
+
ALTER TABLE post_follows ADD COLUMN baseline_checked_at INTEGER NOT NULL DEFAULT 0;
|
|
7
|
+
UPDATE post_follows SET observed_status=(SELECT status FROM post_observations WHERE post_observations.post_slug=post_follows.post_slug);
|
|
8
|
+
ALTER TABLE post_events ADD COLUMN status TEXT;
|
|
9
|
+
CREATE TABLE follow_comment_baselines (post_slug TEXT NOT NULL, subject TEXT NOT NULL, event_id TEXT NOT NULL, PRIMARY KEY(post_slug,subject,event_id), FOREIGN KEY(post_slug,subject) REFERENCES post_follows(post_slug,subject) ON DELETE CASCADE);
|