web-comments 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -6
- package/dist/auth/bearer-token.js +43 -0
- package/dist/auth/cloudflare-access.js +90 -0
- package/dist/auth/dev-user.js +21 -0
- package/dist/auth/session-endpoint.js +47 -0
- package/dist/chunks/chunk-6VWNCJ3A.js +72 -0
- package/dist/chunks/chunk-GHNSLFY2.js +72 -0
- package/dist/chunks/chunk-LBIURYPH.js +192 -0
- package/dist/chunks/chunk-PLAC65HG.js +71 -0
- package/dist/chunks/chunk-WSFCRVEQ.js +9 -0
- package/dist/index.js +44 -0
- package/dist/react.js +19 -0
- package/dist/server.js +501 -0
- package/dist/store/d1.js +16 -0
- package/dist/store/drizzle/mysql.js +13 -0
- package/dist/store/drizzle/pg.js +13 -0
- package/dist/store/drizzle/sqlite.js +13 -0
- package/dist/store/drizzle.js +10 -0
- package/dist/store/memory.js +75 -0
- package/dist/store/r2.js +25 -0
- package/dist/store/s3.js +43 -0
- package/dist/types/auth/bearer-token.d.ts +13 -0
- package/dist/types/auth/cloudflare-access.d.ts +33 -0
- package/dist/types/auth/dev-user.d.ts +6 -0
- package/dist/types/auth/session-endpoint.d.ts +25 -0
- package/dist/types/react.d.ts +8 -0
- package/dist/types/server/auth.d.ts +16 -0
- package/dist/types/server/index.d.ts +71 -0
- package/dist/types/server/store.d.ts +59 -0
- package/dist/types/server/validate.d.ts +20 -0
- package/dist/types/shared/types.d.ts +108 -0
- package/dist/types/store/d1.d.ts +9 -0
- package/dist/types/store/drizzle/index.d.ts +22 -0
- package/dist/types/store/drizzle/mysql.d.ts +506 -0
- package/dist/types/store/drizzle/pg.d.ts +476 -0
- package/dist/types/store/drizzle/sqlite.d.ts +506 -0
- package/dist/types/store/memory.d.ts +10 -0
- package/dist/types/store/r2.d.ts +20 -0
- package/dist/types/store/s3.d.ts +21 -0
- package/dist/types/widget/anchor.d.ts +9 -0
- package/dist/types/widget/api.d.ts +43 -0
- package/dist/types/widget/app.d.ts +29 -0
- package/dist/types/widget/capture.d.ts +33 -0
- package/dist/types/widget/format.d.ts +15 -0
- package/dist/types/widget/index.d.ts +41 -0
- package/dist/types/widget/styles.d.ts +6 -0
- package/dist/types/widget/thread.d.ts +32 -0
- package/dist/types/widget/tracker.d.ts +68 -0
- package/dist/widget.global.js +44 -0
- package/package.json +132 -6
- package/types/widget.global.d.ts +5 -0
- package/index.d.ts +0 -2
- package/index.js +0 -6
package/README.md
CHANGED
|
@@ -1,14 +1,196 @@
|
|
|
1
1
|
# web-comments
|
|
2
2
|
|
|
3
|
-
Figma-style comments for any web page
|
|
3
|
+
Figma-style comments for any web page. Press <kbd>C</kbd>, click an element, write. Teammates reply in the thread and resolve it when it's done. Comments stay attached to the element they were left on and follow it as the page scrolls or reflows.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Every comment thread records the element (selector, text, HTML, position), the URL, a screenshot of the element and one of the page as the commenter saw it.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
import { increment } from "web-comments";
|
|
7
|
+
Three parts are open interfaces, so it fits whatever you already run:
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
- **Sign-in**: auth plugins answer "who is this?" from what already sits in front of your app: your own session endpoint, Cloudflare Access, a bearer token, or anything you write. The server never stores passwords or sessions.
|
|
10
|
+
- **Database**: a `CommentsStore`. Built in: Drizzle over SQLite (D1, libSQL/Turso), Postgres (PlanetScale Postgres, Neon, Supabase, ...) and MySQL (PlanetScale, ...), plus an in-memory store. Or implement the interface over anything.
|
|
11
|
+
- **Screenshots**: an `AssetStore`. Built in: R2 and S3 (and S3-compatible), plus in-memory. Or implement `put`/`get` over any storage or HTTP API.
|
|
12
|
+
|
|
13
|
+
The **widget** is a toolbar docked on the right of the page, built with [React Spectrum 2](https://react-spectrum.adobe.com/) and kept in a shadow root, so neither your CSS nor Spectrum's leaks across. Only people the API accepts see it; for everyone else the page is untouched.
|
|
14
|
+
|
|
15
|
+
## Server
|
|
16
|
+
|
|
17
|
+
A [Hono](https://hono.dev) app, so it runs on Cloudflare Workers, Node, Bun, Deno and anything else with `fetch`.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { createComments } from "web-comments/server";
|
|
21
|
+
import { sessionEndpoint } from "web-comments/auth/session-endpoint";
|
|
22
|
+
import { d1Store } from "web-comments/store/d1";
|
|
23
|
+
import { r2Assets } from "web-comments/store/r2";
|
|
24
|
+
import widgetScript from "web-comments/widget.global.js"; // wrangler rule: { type: "Text", globs: ["**/*.global.js"] }
|
|
25
|
+
|
|
26
|
+
const comments = createComments<Env>({
|
|
27
|
+
auth: [
|
|
28
|
+
sessionEndpoint({
|
|
29
|
+
url: (request) => new URL("/api/me", request.url),
|
|
30
|
+
identity: (me: any) => ({ id: `app:${me.id}`, name: me.name, email: me.email, kind: "human" }),
|
|
31
|
+
}),
|
|
32
|
+
],
|
|
33
|
+
store: (env) => d1Store(env.DB),
|
|
34
|
+
assets: (env) => r2Assets(env.SCREENSHOTS),
|
|
35
|
+
widgetScript, // optional: serve the widget at /__comments/widget.js
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export default {
|
|
39
|
+
fetch(request, env, ctx) {
|
|
40
|
+
if (comments.matches(request)) return comments.fetch(request, env, ctx);
|
|
41
|
+
return app.fetch(request, env, ctx);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Mount it on the same origin as the pages being commented on, so the browser sends its cookies without any CORS setup. Other origins have to be listed in `allowedOrigins`; anything else is refused before it is read.
|
|
47
|
+
|
|
48
|
+
| Option | |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `auth` | Auth plugins, tried in order. The first one that recognises the request wins. |
|
|
51
|
+
| `store` | `(env) => CommentsStore`: where comments are kept. |
|
|
52
|
+
| `assets` | `(env) => AssetStore`: where screenshots are kept. Without it, threads keep none. |
|
|
53
|
+
| `authorize` | Who may use comments once identified. Default: everyone identified. |
|
|
54
|
+
| `basePath` | Default `/__comments`. |
|
|
55
|
+
| `site` | `(request, env) => string`, to keep several sites apart in one store. |
|
|
56
|
+
| `allowedOrigins` | Other origins that may call the API with credentials. |
|
|
57
|
+
| `widgetScript` | The contents of `dist/widget.global.js`, served at `{basePath}/widget.js`. |
|
|
58
|
+
| `maxScreenshotBytes` | Default 5 MB per screenshot. |
|
|
59
|
+
| `onEvent` | Called after every change (`thread.created`, `comment.created`, `thread.resolved`, `thread.moved`, ...), for integrations. `thread.created` already carries the screenshots. |
|
|
60
|
+
|
|
61
|
+
### Sign-in
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
interface AuthPlugin<Env> {
|
|
65
|
+
name: string;
|
|
66
|
+
authenticate(request: Request, env: Env): Promise<Identity | null>; // null: not mine, try the next one
|
|
67
|
+
loginUrl?(request: Request): string | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Identity {
|
|
71
|
+
id: string; // prefixed by its source, e.g. "app:42"
|
|
72
|
+
name: string;
|
|
73
|
+
email?: string;
|
|
74
|
+
avatarUrl?: string;
|
|
75
|
+
kind: "human" | "agent";
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Built in:
|
|
80
|
+
|
|
81
|
+
- `sessionEndpoint` asks your app's own "who is signed in?" endpoint, passing along the request's cookie and authorization headers, and maps its answer to an identity. Any sign-in system that can say who is signed in works this way. Answers are cached briefly per credential; 401/403 means signed out.
|
|
82
|
+
- `cloudflareAccess` verifies the JWT that Cloudflare Access adds to every request it lets through (signature, issuer, audience). Service tokens can be named and are treated as agents.
|
|
83
|
+
- `bearerToken` matches `Authorization: Bearer <token>` against a list, for agents and scripts.
|
|
84
|
+
- `devUser` treats every request as one fixed person. Local development only.
|
|
85
|
+
|
|
86
|
+
Or write your own: a function that reads the request.
|
|
87
|
+
|
|
88
|
+
### Database
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { drizzleStore } from "web-comments/store/drizzle";
|
|
92
|
+
import { drizzle } from "drizzle-orm/neon-http"; // or d1, libsql, pglite, postgres-js, planetscale-serverless, mysql2, ...
|
|
93
|
+
|
|
94
|
+
store: () => drizzleStore({ db: drizzle(process.env.DATABASE_URL!), dialect: "pg" }), // "sqlite" | "pg" | "mysql"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Uses [Drizzle ORM](https://orm.drizzle.team) 1.0 (a peer dependency). The tables (all prefixed `wc_`) are created on first use; to manage them in your own migrations instead, import the schema from `web-comments/store/drizzle/{sqlite,pg,mysql}` into your drizzle-kit config and pass `createTables: false`. The MySQL schema avoids foreign keys, which PlanetScale doesn't allow.
|
|
98
|
+
|
|
99
|
+
`d1Store(env.DB)` is the same over a D1 binding. `memoryStore()` keeps everything in memory, for tests.
|
|
100
|
+
|
|
101
|
+
To keep comments anywhere else, implement `CommentsStore` (exported from `web-comments/server`). It only saves and loads; the server owns the rules (permissions, ids, timestamps, what deleting a thread's first comment means). `web-comments/store/memory` is a complete implementation in about seventy lines.
|
|
102
|
+
|
|
103
|
+
### Screenshots
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { s3Assets } from "web-comments/store/s3";
|
|
107
|
+
|
|
108
|
+
assets: (env) =>
|
|
109
|
+
s3Assets({
|
|
110
|
+
endpoint: "https://s3.us-east-1.amazonaws.com", // or https://<account>.r2.cloudflarestorage.com, MinIO, ...
|
|
111
|
+
region: "us-east-1",
|
|
112
|
+
bucket: "comment-screenshots",
|
|
113
|
+
accessKeyId: env.S3_KEY_ID,
|
|
114
|
+
secretAccessKey: env.S3_SECRET,
|
|
115
|
+
}),
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`r2Assets(bucket)` uses a Workers R2 binding; `s3Assets` signs requests with SigV4 over `fetch`, so it runs anywhere. Screenshots are served through the API (`{basePath}/assets/...`) to people it has identified, so buckets can stay private. To keep them elsewhere, implement `AssetStore`: `put(key, bytes, contentType)`, `get(key)` and optionally `delete(key)`.
|
|
119
|
+
|
|
120
|
+
### What a thread records
|
|
121
|
+
|
|
122
|
+
| | |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `anchor` | CSS selector, the start of the element's text, its tag, and where inside it the comment was placed. |
|
|
125
|
+
| `context.element` | The element's HTML (cut to 4 KB) and its box in the viewport. |
|
|
126
|
+
| `context.page` | Title, viewport size, scroll position, device pixel ratio, user agent. |
|
|
127
|
+
| `url`, `page` | The full address, and the page key the widget filed it under. |
|
|
128
|
+
| `screenshots.element` | The element in its surroundings, outlined, cut from the page screenshot. |
|
|
129
|
+
| `screenshots.page` | The viewport as it was when the comment was placed, without the widget. |
|
|
130
|
+
|
|
131
|
+
Screenshots are drawn from the DOM in the browser (no screen-capture prompt). Cross-origin images that don't allow CORS can't be drawn and come out blank; a comment never waits more than a few seconds for its screenshots.
|
|
132
|
+
|
|
133
|
+
## Widget
|
|
134
|
+
|
|
135
|
+
The toolbar sits along the right edge: comment mode (<kbd>C</kbd>), this page's comments, and who you're signed in as. By default the page is moved over to make room for it (`margin-right` on `<html>`), so nothing is covered; `dock: "overlay"` draws it on top instead. The comments panel floats beside it. If the API answers 401 or 403, nothing is shown and the page is left alone.
|
|
136
|
+
|
|
137
|
+
The widget bundles its own React 19 and Spectrum (about 200 KB gzipped), so it works next to any framework or React version. Spectrum's CSS is injected into the shadow root; its `@font-face` rules (Adobe Clean, from Adobe Fonts) go into the document, since fonts can't be declared inside a shadow root.
|
|
138
|
+
|
|
139
|
+
Script tag, served by the server above:
|
|
140
|
+
|
|
141
|
+
```html
|
|
142
|
+
<script src="/__comments/widget.js" defer></script>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
ES module:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { mountComments } from "web-comments";
|
|
149
|
+
|
|
150
|
+
const comments = mountComments({ endpoint: "/__comments" });
|
|
151
|
+
// comments.open(threadId), comments.setCommentMode(true), comments.unmount()
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
React:
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
import { Comments } from "web-comments/react";
|
|
158
|
+
|
|
159
|
+
<Comments page={() => routePattern(location.pathname)} />
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
| Option | |
|
|
163
|
+
| --- | --- |
|
|
164
|
+
| `endpoint` | Where the API is mounted. Default `/__comments`. |
|
|
165
|
+
| `page` | Which page a comment belongs to. Default: the path. Return a route pattern to share comments across ids. |
|
|
166
|
+
| `headers` | Extra request headers, e.g. a bearer token your app holds. |
|
|
167
|
+
| `dock` | `push` (default) moves the page over to make room for the toolbar; `overlay` draws it on top. |
|
|
168
|
+
| `shortcut` | Key that toggles comment mode. Default `c`; `false` to turn it off. |
|
|
169
|
+
| `theme` | `auto` (default), `light`, `dark`. |
|
|
170
|
+
| `linkParam` | Query parameter of thread links. Default `comment`. |
|
|
171
|
+
|
|
172
|
+
### How comments find their element
|
|
173
|
+
|
|
174
|
+
When a comment is made, the widget records a CSS selector for the element, the start of its text, and where inside the element the click landed. It prefers names the page gave on purpose (`data-comment-anchor`, `data-testid`, `id`, form field `name`) and skips ids that look generated. If the selector later points at a different element, it falls back to the text. If neither finds anything, the comment is listed as "Not on the page" instead of being drawn in the wrong place. Authors can drag their pins onto another element to re-anchor them.
|
|
175
|
+
|
|
176
|
+
## Demo
|
|
177
|
+
|
|
178
|
+
`demo/` is a small dashboard on a Worker with D1 and R2, behind Cloudflare Access.
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
pnpm deploy:demo
|
|
182
|
+
cp demo/.dev.vars.example demo/.dev.vars # optional: direct local requests become "Local Developer"
|
|
183
|
+
pnpm dev:demo # 127.0.0.1:$PORT (default 8795), with a local D1 and R2
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Development
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
pnpm install
|
|
190
|
+
pnpm test # the server against the memory, D1 (Miniflare) and Postgres (PGlite) stores; auth; assets; anchoring
|
|
191
|
+
pnpm typecheck
|
|
192
|
+
pnpm check # biome
|
|
193
|
+
pnpm build
|
|
12
194
|
```
|
|
13
195
|
|
|
14
196
|
## License
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import "../chunks/chunk-WSFCRVEQ.js";
|
|
2
|
+
|
|
3
|
+
// src/auth/bearer-token.ts
|
|
4
|
+
var MIN_TOKEN_LENGTH = 24;
|
|
5
|
+
function bearerToken(tokens) {
|
|
6
|
+
return {
|
|
7
|
+
name: "bearer-token",
|
|
8
|
+
async authenticate(request, env) {
|
|
9
|
+
const header = request.headers.get("authorization");
|
|
10
|
+
const match = header?.match(/^Bearer\s+(\S+)$/i);
|
|
11
|
+
if (!match?.[1]) return null;
|
|
12
|
+
const presented = await digest(match[1]);
|
|
13
|
+
for (const entry of typeof tokens === "function" ? tokens(env) : tokens) {
|
|
14
|
+
if (entry.token.length < MIN_TOKEN_LENGTH) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`bearer token "${entry.name}" is shorter than ${MIN_TOKEN_LENGTH} characters`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
if (equal(presented, await digest(entry.token))) {
|
|
20
|
+
return {
|
|
21
|
+
id: `token:${entry.name}`,
|
|
22
|
+
name: entry.name,
|
|
23
|
+
kind: entry.kind ?? "agent",
|
|
24
|
+
...entry.email ? { email: entry.email } : {}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function digest(value) {
|
|
33
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)));
|
|
34
|
+
}
|
|
35
|
+
function equal(a, b) {
|
|
36
|
+
if (a.length !== b.length) return false;
|
|
37
|
+
let diff = 0;
|
|
38
|
+
for (let i = 0; i < a.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
|
|
39
|
+
return diff === 0;
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
bearerToken
|
|
43
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import "../chunks/chunk-WSFCRVEQ.js";
|
|
2
|
+
|
|
3
|
+
// src/auth/cloudflare-access.ts
|
|
4
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
5
|
+
var IDENTITY_TTL_MS = 60 * 60 * 1e3;
|
|
6
|
+
function cloudflareAccess(options) {
|
|
7
|
+
const verifiers = /* @__PURE__ */ new Map();
|
|
8
|
+
function verifierFor(env) {
|
|
9
|
+
const resolved = typeof options === "function" ? options(env) : options;
|
|
10
|
+
const key = `${resolved.teamDomain} ${[resolved.audience].flat().join(",")}`;
|
|
11
|
+
let verifier = verifiers.get(key);
|
|
12
|
+
if (!verifier) {
|
|
13
|
+
verifier = createVerifier(resolved);
|
|
14
|
+
verifiers.set(key, verifier);
|
|
15
|
+
}
|
|
16
|
+
return verifier;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
name: "cloudflare-access",
|
|
20
|
+
async authenticate(request, env) {
|
|
21
|
+
const token = request.headers.get("cf-access-jwt-assertion") ?? cookie(request, "CF_Authorization");
|
|
22
|
+
if (!token) return null;
|
|
23
|
+
return verifierFor(env)(request, token);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function createVerifier(options) {
|
|
28
|
+
const team = `https://${options.teamDomain.replace(/^https?:\/\//, "").replace(/\/+$/, "")}`;
|
|
29
|
+
const keys = options.keys ?? createRemoteJWKSet(new URL(`${team}/cdn-cgi/access/certs`));
|
|
30
|
+
const names = /* @__PURE__ */ new Map();
|
|
31
|
+
async function displayName(request, token, sub) {
|
|
32
|
+
const cached = names.get(sub);
|
|
33
|
+
if (cached && cached.until > Date.now()) return cached.name;
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(new URL("/cdn-cgi/access/get-identity", request.url), {
|
|
36
|
+
headers: { cookie: `CF_Authorization=${token}` }
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok) return void 0;
|
|
39
|
+
const identity = await response.json();
|
|
40
|
+
if (typeof identity.name !== "string" || !identity.name.trim()) return void 0;
|
|
41
|
+
const name = identity.name.trim();
|
|
42
|
+
names.set(sub, { name, until: Date.now() + IDENTITY_TTL_MS });
|
|
43
|
+
return name;
|
|
44
|
+
} catch {
|
|
45
|
+
return void 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return async (request, token) => {
|
|
49
|
+
let claims;
|
|
50
|
+
try {
|
|
51
|
+
({ payload: claims } = await jwtVerify(token, keys, {
|
|
52
|
+
issuer: team,
|
|
53
|
+
audience: options.audience
|
|
54
|
+
}));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (claims.common_name) {
|
|
59
|
+
const service = options.serviceTokens?.[claims.common_name];
|
|
60
|
+
if (!service) return null;
|
|
61
|
+
return {
|
|
62
|
+
id: `access-service:${claims.common_name}`,
|
|
63
|
+
name: service.name,
|
|
64
|
+
kind: service.kind ?? "agent"
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (!claims.email || !claims.sub) return null;
|
|
68
|
+
const name = options.fetchIdentity && await displayName(request, token, claims.sub) || nameFromEmail(claims.email);
|
|
69
|
+
return { id: `access:${claims.sub}`, name, email: claims.email, kind: "human" };
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function cookie(request, name) {
|
|
73
|
+
const header = request.headers.get("cookie");
|
|
74
|
+
if (!header) return void 0;
|
|
75
|
+
for (const part of header.split(";")) {
|
|
76
|
+
const [key, ...value] = part.trim().split("=");
|
|
77
|
+
if (key === name) return value.join("=");
|
|
78
|
+
}
|
|
79
|
+
return void 0;
|
|
80
|
+
}
|
|
81
|
+
function nameFromEmail(email) {
|
|
82
|
+
const local = email.split("@")[0] ?? email;
|
|
83
|
+
const words = local.split(/[._-]+/).filter(Boolean);
|
|
84
|
+
if (words.length === 0) return email;
|
|
85
|
+
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
86
|
+
}
|
|
87
|
+
export {
|
|
88
|
+
cloudflareAccess,
|
|
89
|
+
nameFromEmail
|
|
90
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import "../chunks/chunk-WSFCRVEQ.js";
|
|
2
|
+
|
|
3
|
+
// src/auth/dev-user.ts
|
|
4
|
+
function devUser(identity = {}) {
|
|
5
|
+
const who = {
|
|
6
|
+
id: identity.id ?? "dev:local",
|
|
7
|
+
name: identity.name ?? "Developer",
|
|
8
|
+
kind: identity.kind ?? "human",
|
|
9
|
+
...identity.email ? { email: identity.email } : {},
|
|
10
|
+
...identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
name: "dev-user",
|
|
14
|
+
async authenticate() {
|
|
15
|
+
return who;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export {
|
|
20
|
+
devUser
|
|
21
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import "../chunks/chunk-WSFCRVEQ.js";
|
|
2
|
+
|
|
3
|
+
// src/auth/session-endpoint.ts
|
|
4
|
+
var DEFAULT_FORWARD = ["cookie", "authorization"];
|
|
5
|
+
var MAX_CACHED = 1e3;
|
|
6
|
+
function sessionEndpoint(options) {
|
|
7
|
+
const forward = (options.forward ?? DEFAULT_FORWARD).map((name) => name.toLowerCase());
|
|
8
|
+
const cacheFor = options.cacheFor ?? 3e4;
|
|
9
|
+
const cache = /* @__PURE__ */ new Map();
|
|
10
|
+
return {
|
|
11
|
+
name: "session-endpoint",
|
|
12
|
+
...options.loginUrl ? { loginUrl: options.loginUrl } : {},
|
|
13
|
+
async authenticate(request, env) {
|
|
14
|
+
const headers = new Headers({ accept: "application/json" });
|
|
15
|
+
for (const name of forward) {
|
|
16
|
+
const value = request.headers.get(name);
|
|
17
|
+
if (value) headers.set(name, value);
|
|
18
|
+
}
|
|
19
|
+
if (!forward.some((name) => headers.has(name))) return null;
|
|
20
|
+
const key = cacheFor > 0 ? await digest(forward.map((name) => headers.get(name) ?? "").join("\n")) : "";
|
|
21
|
+
const cached = key ? cache.get(key) : void 0;
|
|
22
|
+
if (cached && cached.until > Date.now()) return cached.identity;
|
|
23
|
+
const url = typeof options.url === "function" ? options.url(request, env) : options.url;
|
|
24
|
+
const response = await fetch(url, { headers, redirect: "manual" });
|
|
25
|
+
let identity;
|
|
26
|
+
if (response.status === 401 || response.status === 403 || response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
|
|
27
|
+
identity = null;
|
|
28
|
+
} else if (!response.ok) {
|
|
29
|
+
throw new Error(`session endpoint answered ${response.status}`);
|
|
30
|
+
} else {
|
|
31
|
+
identity = await options.identity(await response.json(), request);
|
|
32
|
+
}
|
|
33
|
+
if (key) {
|
|
34
|
+
if (cache.size >= MAX_CACHED) cache.delete(cache.keys().next().value);
|
|
35
|
+
cache.set(key, { identity, until: Date.now() + cacheFor });
|
|
36
|
+
}
|
|
37
|
+
return identity;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function digest(value) {
|
|
42
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
43
|
+
return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
44
|
+
}
|
|
45
|
+
export {
|
|
46
|
+
sessionEndpoint
|
|
47
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__export
|
|
3
|
+
} from "./chunk-WSFCRVEQ.js";
|
|
4
|
+
|
|
5
|
+
// src/store/drizzle/pg.ts
|
|
6
|
+
var pg_exports = {};
|
|
7
|
+
__export(pg_exports, {
|
|
8
|
+
comments: () => comments,
|
|
9
|
+
createTablesSql: () => createTablesSql,
|
|
10
|
+
threads: () => threads,
|
|
11
|
+
users: () => users
|
|
12
|
+
});
|
|
13
|
+
import { index, pgTable, text } from "drizzle-orm/pg-core";
|
|
14
|
+
var threads = pgTable(
|
|
15
|
+
"wc_threads",
|
|
16
|
+
{
|
|
17
|
+
id: text("id").primaryKey(),
|
|
18
|
+
site: text("site").notNull(),
|
|
19
|
+
page: text("page").notNull(),
|
|
20
|
+
url: text("url").notNull(),
|
|
21
|
+
anchor: text("anchor").notNull(),
|
|
22
|
+
context: text("context"),
|
|
23
|
+
screenshots: text("screenshots").notNull(),
|
|
24
|
+
status: text("status").notNull(),
|
|
25
|
+
authorId: text("author_id").notNull(),
|
|
26
|
+
authorName: text("author_name").notNull(),
|
|
27
|
+
authorAvatar: text("author_avatar"),
|
|
28
|
+
authorKind: text("author_kind").notNull(),
|
|
29
|
+
createdAt: text("created_at").notNull(),
|
|
30
|
+
updatedAt: text("updated_at").notNull(),
|
|
31
|
+
resolvedAt: text("resolved_at"),
|
|
32
|
+
resolvedBy: text("resolved_by")
|
|
33
|
+
},
|
|
34
|
+
(t) => [index("wc_threads_page").on(t.site, t.page, t.createdAt)]
|
|
35
|
+
);
|
|
36
|
+
var comments = pgTable(
|
|
37
|
+
"wc_comments",
|
|
38
|
+
{
|
|
39
|
+
id: text("id").primaryKey(),
|
|
40
|
+
threadId: text("thread_id").notNull(),
|
|
41
|
+
body: text("body").notNull(),
|
|
42
|
+
authorId: text("author_id").notNull(),
|
|
43
|
+
authorName: text("author_name").notNull(),
|
|
44
|
+
authorAvatar: text("author_avatar"),
|
|
45
|
+
authorKind: text("author_kind").notNull(),
|
|
46
|
+
createdAt: text("created_at").notNull()
|
|
47
|
+
},
|
|
48
|
+
(t) => [index("wc_comments_thread").on(t.threadId, t.createdAt)]
|
|
49
|
+
);
|
|
50
|
+
var users = pgTable("wc_users", {
|
|
51
|
+
id: text("id").primaryKey(),
|
|
52
|
+
name: text("name").notNull(),
|
|
53
|
+
email: text("email"),
|
|
54
|
+
avatarUrl: text("avatar_url"),
|
|
55
|
+
kind: text("kind").notNull(),
|
|
56
|
+
lastSeenAt: text("last_seen_at").notNull()
|
|
57
|
+
});
|
|
58
|
+
var createTablesSql = [
|
|
59
|
+
`CREATE TABLE IF NOT EXISTS wc_threads (id TEXT PRIMARY KEY, site TEXT NOT NULL, page TEXT NOT NULL, url TEXT NOT NULL, anchor TEXT NOT NULL, context TEXT, screenshots TEXT NOT NULL, status TEXT NOT NULL, author_id TEXT NOT NULL, author_name TEXT NOT NULL, author_avatar TEXT, author_kind TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, resolved_at TEXT, resolved_by TEXT)`,
|
|
60
|
+
`CREATE INDEX IF NOT EXISTS wc_threads_page ON wc_threads (site, page, created_at)`,
|
|
61
|
+
`CREATE TABLE IF NOT EXISTS wc_comments (id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, body TEXT NOT NULL, author_id TEXT NOT NULL, author_name TEXT NOT NULL, author_avatar TEXT, author_kind TEXT NOT NULL, created_at TEXT NOT NULL)`,
|
|
62
|
+
`CREATE INDEX IF NOT EXISTS wc_comments_thread ON wc_comments (thread_id, created_at)`,
|
|
63
|
+
`CREATE TABLE IF NOT EXISTS wc_users (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT, avatar_url TEXT, kind TEXT NOT NULL, last_seen_at TEXT NOT NULL)`
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export {
|
|
67
|
+
threads,
|
|
68
|
+
comments,
|
|
69
|
+
users,
|
|
70
|
+
createTablesSql,
|
|
71
|
+
pg_exports
|
|
72
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__export
|
|
3
|
+
} from "./chunk-WSFCRVEQ.js";
|
|
4
|
+
|
|
5
|
+
// src/store/drizzle/sqlite.ts
|
|
6
|
+
var sqlite_exports = {};
|
|
7
|
+
__export(sqlite_exports, {
|
|
8
|
+
comments: () => comments,
|
|
9
|
+
createTablesSql: () => createTablesSql,
|
|
10
|
+
threads: () => threads,
|
|
11
|
+
users: () => users
|
|
12
|
+
});
|
|
13
|
+
import { index, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
14
|
+
var threads = sqliteTable(
|
|
15
|
+
"wc_threads",
|
|
16
|
+
{
|
|
17
|
+
id: text("id").primaryKey(),
|
|
18
|
+
site: text("site").notNull(),
|
|
19
|
+
page: text("page").notNull(),
|
|
20
|
+
url: text("url").notNull(),
|
|
21
|
+
anchor: text("anchor").notNull(),
|
|
22
|
+
context: text("context"),
|
|
23
|
+
screenshots: text("screenshots").notNull(),
|
|
24
|
+
status: text("status").notNull(),
|
|
25
|
+
authorId: text("author_id").notNull(),
|
|
26
|
+
authorName: text("author_name").notNull(),
|
|
27
|
+
authorAvatar: text("author_avatar"),
|
|
28
|
+
authorKind: text("author_kind").notNull(),
|
|
29
|
+
createdAt: text("created_at").notNull(),
|
|
30
|
+
updatedAt: text("updated_at").notNull(),
|
|
31
|
+
resolvedAt: text("resolved_at"),
|
|
32
|
+
resolvedBy: text("resolved_by")
|
|
33
|
+
},
|
|
34
|
+
(t) => [index("wc_threads_page").on(t.site, t.page, t.createdAt)]
|
|
35
|
+
);
|
|
36
|
+
var comments = sqliteTable(
|
|
37
|
+
"wc_comments",
|
|
38
|
+
{
|
|
39
|
+
id: text("id").primaryKey(),
|
|
40
|
+
threadId: text("thread_id").notNull(),
|
|
41
|
+
body: text("body").notNull(),
|
|
42
|
+
authorId: text("author_id").notNull(),
|
|
43
|
+
authorName: text("author_name").notNull(),
|
|
44
|
+
authorAvatar: text("author_avatar"),
|
|
45
|
+
authorKind: text("author_kind").notNull(),
|
|
46
|
+
createdAt: text("created_at").notNull()
|
|
47
|
+
},
|
|
48
|
+
(t) => [index("wc_comments_thread").on(t.threadId, t.createdAt)]
|
|
49
|
+
);
|
|
50
|
+
var users = sqliteTable("wc_users", {
|
|
51
|
+
id: text("id").primaryKey(),
|
|
52
|
+
name: text("name").notNull(),
|
|
53
|
+
email: text("email"),
|
|
54
|
+
avatarUrl: text("avatar_url"),
|
|
55
|
+
kind: text("kind").notNull(),
|
|
56
|
+
lastSeenAt: text("last_seen_at").notNull()
|
|
57
|
+
});
|
|
58
|
+
var createTablesSql = [
|
|
59
|
+
`CREATE TABLE IF NOT EXISTS wc_threads (id TEXT PRIMARY KEY, site TEXT NOT NULL, page TEXT NOT NULL, url TEXT NOT NULL, anchor TEXT NOT NULL, context TEXT, screenshots TEXT NOT NULL, status TEXT NOT NULL, author_id TEXT NOT NULL, author_name TEXT NOT NULL, author_avatar TEXT, author_kind TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, resolved_at TEXT, resolved_by TEXT)`,
|
|
60
|
+
`CREATE INDEX IF NOT EXISTS wc_threads_page ON wc_threads (site, page, created_at)`,
|
|
61
|
+
`CREATE TABLE IF NOT EXISTS wc_comments (id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, body TEXT NOT NULL, author_id TEXT NOT NULL, author_name TEXT NOT NULL, author_avatar TEXT, author_kind TEXT NOT NULL, created_at TEXT NOT NULL)`,
|
|
62
|
+
`CREATE INDEX IF NOT EXISTS wc_comments_thread ON wc_comments (thread_id, created_at)`,
|
|
63
|
+
`CREATE TABLE IF NOT EXISTS wc_users (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT, avatar_url TEXT, kind TEXT NOT NULL, last_seen_at TEXT NOT NULL)`
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export {
|
|
67
|
+
threads,
|
|
68
|
+
comments,
|
|
69
|
+
users,
|
|
70
|
+
createTablesSql,
|
|
71
|
+
sqlite_exports
|
|
72
|
+
};
|