@somewhatintelligent/guestlist 0.0.1-beta.1783705886.297c5be
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/package.json +54 -0
- package/src/__tests__/email.test.ts +85 -0
- package/src/blobs.ts +56 -0
- package/src/client/avatar.ts +125 -0
- package/src/client/guestlist.ts +189 -0
- package/src/client/index.ts +17 -0
- package/src/client/plugins.ts +24 -0
- package/src/client/react.ts +32 -0
- package/src/codegen.ts +57 -0
- package/src/config.ts +82 -0
- package/src/cors.ts +64 -0
- package/src/db.ts +18 -0
- package/src/email.ts +93 -0
- package/src/entrypoint.ts +261 -0
- package/src/env.ts +42 -0
- package/src/http.ts +122 -0
- package/src/index.ts +85 -0
- package/src/instances.ts +120 -0
- package/src/log.ts +95 -0
- package/src/ops/admin.ts +190 -0
- package/src/ops/avatar.ts +161 -0
- package/src/ops/orgs.ts +326 -0
- package/src/ops/users.ts +96 -0
- package/src/rpc.ts +64 -0
- package/src/schema.ts +538 -0
- package/src/version.gen.ts +7 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createGuestlist`'s injected configuration. This is the entire
|
|
3
|
+
* consumer-facing knob surface — there is no central config store; the
|
|
4
|
+
* consumer decides where these values come from (a local module, env, a
|
|
5
|
+
* constant — their business).
|
|
6
|
+
*
|
|
7
|
+
* Capabilities that need runtime resources (`sendEmail` reading an API-key
|
|
8
|
+
* secret, `blobs` holding an R2 binding) are factories receiving the
|
|
9
|
+
* worker env, invoked once per isolate and cached.
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
PlatformAdminOptions,
|
|
13
|
+
PlatformOrgOptions,
|
|
14
|
+
PlatformRateLimitOptions,
|
|
15
|
+
PlatformSessionOptions,
|
|
16
|
+
SubscriptionPlan,
|
|
17
|
+
} from "@somewhatintelligent/auth";
|
|
18
|
+
import type { GuestlistEnv } from "./env";
|
|
19
|
+
import type { EmailSender } from "./email";
|
|
20
|
+
import type { GuestlistBlobStore } from "./blobs";
|
|
21
|
+
import type { GuestlistSchema } from "./db";
|
|
22
|
+
|
|
23
|
+
export interface GuestlistConfig<TEnv extends GuestlistEnv = GuestlistEnv> {
|
|
24
|
+
/**
|
|
25
|
+
* The consumer's GENERATED drizzle schema module (`schema.gen.ts` from
|
|
26
|
+
* `@better-auth/cli generate` — see ./codegen.ts), i.e. the exact schema
|
|
27
|
+
* the consumer's migrations were generated from. The drizzle instance
|
|
28
|
+
* and better-auth adapter run on this: drizzle writes enumerate every
|
|
29
|
+
* schema column, so schema and DDL must be the same artifact.
|
|
30
|
+
*/
|
|
31
|
+
schema: GuestlistSchema;
|
|
32
|
+
/** Cookie name prefix (e.g. `"acme"`); shared with the session reader. */
|
|
33
|
+
cookiePrefix: string;
|
|
34
|
+
/** Friendly app name shown by the WebAuthn user agent during passkey ceremonies. */
|
|
35
|
+
passkeyRpName: string;
|
|
36
|
+
/** Issuer string surfaced by authenticator apps during 2FA enrollment. */
|
|
37
|
+
twoFactorIssuer: string;
|
|
38
|
+
/**
|
|
39
|
+
* Domains whose origins (apex + every subdomain) may make credentialed
|
|
40
|
+
* CORS requests — typically `[baseDomain, devDomain]`.
|
|
41
|
+
*/
|
|
42
|
+
corsDomains: string[];
|
|
43
|
+
/**
|
|
44
|
+
* Email transport. Called with `GuestlistEmail` events; the consumer
|
|
45
|
+
* owns rendering + provider. See ./email.ts for the contract and
|
|
46
|
+
* `defineEmailHandlers` for the strict per-kind helper.
|
|
47
|
+
*/
|
|
48
|
+
sendEmail: (env: TEnv) => EmailSender;
|
|
49
|
+
/**
|
|
50
|
+
* Blob storage for the avatar surface. Omit ⇒ avatar routes are not
|
|
51
|
+
* mounted and `/u/avatar/*` 404s (feature off, not broken).
|
|
52
|
+
*/
|
|
53
|
+
blobs?: (env: TEnv) => GuestlistBlobStore;
|
|
54
|
+
/**
|
|
55
|
+
* Stripe subscription billing (better-auth `stripe` plugin). The plugin
|
|
56
|
+
* activates only when this is set AND the env carries both
|
|
57
|
+
* `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SIGNING_SECRET`; otherwise it
|
|
58
|
+
* is left out of the plugin array entirely (dormant by default). Price
|
|
59
|
+
* ids come from the consumer's billing catalog (`@somewhatintelligent/stripe`);
|
|
60
|
+
* billing presence — not plan count — is what adds the `subscription`
|
|
61
|
+
* table to the generated schema, so keep this set (or unset) in the
|
|
62
|
+
* codegen config too.
|
|
63
|
+
*/
|
|
64
|
+
billing?: { plans: SubscriptionPlan[] };
|
|
65
|
+
/**
|
|
66
|
+
* Organization plugin overrides — access control, custom roles, org
|
|
67
|
+
* policy. Omit for the platform stock (`defaultOrgAccess`, operator-
|
|
68
|
+
* provisioned orgs). Custom roles never affect the generated schema.
|
|
69
|
+
*/
|
|
70
|
+
organization?: PlatformOrgOptions;
|
|
71
|
+
/** Admin plugin overrides — default role, admin roles, impersonation. */
|
|
72
|
+
admin?: PlatformAdminOptions;
|
|
73
|
+
/** Session policy overrides — lifetime, cookie-cache age. */
|
|
74
|
+
session?: PlatformSessionOptions;
|
|
75
|
+
/** Rate-limit overrides — default bucket + per-path rules (merged over defaults). */
|
|
76
|
+
rateLimit?: PlatformRateLimitOptions;
|
|
77
|
+
/**
|
|
78
|
+
* Worker name reported by `/__version` + canonical log lines.
|
|
79
|
+
* @default "guestlist"
|
|
80
|
+
*/
|
|
81
|
+
serviceName?: string;
|
|
82
|
+
}
|
package/src/cors.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentialed CORS for guestlist, replacing @elysiajs/cors with the same
|
|
3
|
+
* semantics the fleet was tuned to.
|
|
4
|
+
*
|
|
5
|
+
* The origin patterns are tested against the full `Origin` header value
|
|
6
|
+
* (scheme + host, e.g. "https://example.com"), not just the hostname — so
|
|
7
|
+
* each pattern is anchored start-to-end and accounts for the bare apex
|
|
8
|
+
* itself, not only its subdomains. A suffix-only `\.${domain}$` pattern
|
|
9
|
+
* requires a literal "." immediately before the domain, which every
|
|
10
|
+
* subdomain has but the bare apex does not (the character before it is
|
|
11
|
+
* "/" from "https://"), silently locking real top-level-origin requests
|
|
12
|
+
* (e.g. a same-page fetch/XHR from the apex) out of CORS in production.
|
|
13
|
+
*/
|
|
14
|
+
const escapeDot = (d: string) => d.replace(/\./g, "\\.");
|
|
15
|
+
|
|
16
|
+
export function originPattern(domain: string): RegExp {
|
|
17
|
+
return new RegExp(`^https?://([a-z0-9-]+\\.)*${escapeDot(domain)}$`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ALLOWED_HEADERS = "content-type, authorization";
|
|
21
|
+
const ALLOWED_METHODS = "GET, POST, PUT, PATCH, DELETE, OPTIONS";
|
|
22
|
+
|
|
23
|
+
export interface CorsPolicy {
|
|
24
|
+
/** Domains whose origin (apex + every subdomain, http/https) is allowed. */
|
|
25
|
+
domains: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createCors(policy: CorsPolicy) {
|
|
29
|
+
const patterns = policy.domains.map(originPattern);
|
|
30
|
+
|
|
31
|
+
function allowed(origin: string | null): origin is string {
|
|
32
|
+
return origin !== null && patterns.some((p) => p.test(origin));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
/** Non-null for CORS preflight requests: the complete response. */
|
|
37
|
+
preflight(request: Request): Response | null {
|
|
38
|
+
if (request.method !== "OPTIONS") return null;
|
|
39
|
+
const origin = request.headers.get("origin");
|
|
40
|
+
if (!allowed(origin)) return new Response(null, { status: 204 });
|
|
41
|
+
return new Response(null, {
|
|
42
|
+
status: 204,
|
|
43
|
+
headers: {
|
|
44
|
+
"access-control-allow-origin": origin,
|
|
45
|
+
"access-control-allow-credentials": "true",
|
|
46
|
+
"access-control-allow-headers": ALLOWED_HEADERS,
|
|
47
|
+
"access-control-allow-methods": ALLOWED_METHODS,
|
|
48
|
+
vary: "origin",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/** Stamp CORS response headers when the request's Origin is allowed. */
|
|
54
|
+
apply(request: Request, response: Response): Response {
|
|
55
|
+
const origin = request.headers.get("origin");
|
|
56
|
+
if (!allowed(origin)) return response;
|
|
57
|
+
const out = new Response(response.body, response);
|
|
58
|
+
out.headers.set("access-control-allow-origin", origin);
|
|
59
|
+
out.headers.set("access-control-allow-credentials", "true");
|
|
60
|
+
out.headers.append("vary", "origin");
|
|
61
|
+
return out;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drizzle instance (and better-auth's adapter) run on the CONSUMER'S
|
|
3
|
+
* generated schema — the one their migrations were generated from — never
|
|
4
|
+
* a package-fixed schema. Drizzle inserts enumerate every column in the
|
|
5
|
+
* schema object, so a package schema containing columns the consumer's
|
|
6
|
+
* DDL doesn't have (e.g. `stripe_customer_id` with billing off) would
|
|
7
|
+
* fail every write. The package's own ./schema stays as query-layer table
|
|
8
|
+
* definitions for the ops' reads/writes on always-present tables.
|
|
9
|
+
*/
|
|
10
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
11
|
+
|
|
12
|
+
export type GuestlistSchema = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
export function createDb(binding: D1Database, schema: GuestlistSchema) {
|
|
15
|
+
return drizzle(binding, { schema });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Database = ReturnType<typeof createDb>;
|
package/src/email.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email contract between guestlist and its consumer.
|
|
3
|
+
*
|
|
4
|
+
* Guestlist emits email EVENTS — data only, no subject, no HTML, no
|
|
5
|
+
* provider opinion. The consumer injects a single `EmailSender` function
|
|
6
|
+
* and owns rendering + transport entirely (templates are consumer
|
|
7
|
+
* behavior, never shipped here). The only email-adjacent code this
|
|
8
|
+
* package ships is the `consoleEmail` dev sink.
|
|
9
|
+
*
|
|
10
|
+
* Delivery semantics: guestlist awaits the function inside the auth flow;
|
|
11
|
+
* a rejection is how better-auth hooks learn a send failed — consumer
|
|
12
|
+
* implementations must not swallow errors.
|
|
13
|
+
*/
|
|
14
|
+
import type { PlatformAuthSendEmail } from "@somewhatintelligent/auth";
|
|
15
|
+
|
|
16
|
+
export interface EmailRecipient {
|
|
17
|
+
email: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Discriminated union of every email guestlist can ask the consumer to send. */
|
|
22
|
+
export type GuestlistEmail =
|
|
23
|
+
| { kind: "verification"; to: EmailRecipient; url: string }
|
|
24
|
+
| { kind: "reset-password"; to: EmailRecipient; url: string }
|
|
25
|
+
| { kind: "email-change"; to: EmailRecipient; url: string; newEmail: string }
|
|
26
|
+
| { kind: "delete-account"; to: EmailRecipient; url: string }
|
|
27
|
+
| { kind: "magic-link"; to: EmailRecipient; url: string }
|
|
28
|
+
| {
|
|
29
|
+
kind: "org-invitation";
|
|
30
|
+
to: EmailRecipient;
|
|
31
|
+
url: string;
|
|
32
|
+
organizationName: string;
|
|
33
|
+
role: string;
|
|
34
|
+
inviter: { name: string; email: string };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type GuestlistEmailKind = GuestlistEmail["kind"];
|
|
38
|
+
|
|
39
|
+
export type EmailSender = (msg: GuestlistEmail) => Promise<void>;
|
|
40
|
+
|
|
41
|
+
type HandlerMap = {
|
|
42
|
+
[K in GuestlistEmailKind]: (msg: Extract<GuestlistEmail, { kind: K }>) => Promise<void>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Strict per-kind helper: requires a handler for EVERY kind in the union,
|
|
47
|
+
* so upgrading to a package version that adds a new email kind surfaces as
|
|
48
|
+
* a compile error ("you have an email you're not rendering") instead of a
|
|
49
|
+
* silently-unsent message. Consumers who prefer looseness skip this and
|
|
50
|
+
* write their own switch with a `default`.
|
|
51
|
+
*/
|
|
52
|
+
export function defineEmailHandlers(handlers: HandlerMap): EmailSender {
|
|
53
|
+
return (msg) => (handlers[msg.kind] as (m: GuestlistEmail) => Promise<void>)(msg);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Dev sink — logs the event instead of sending. The only shipped "transport". */
|
|
57
|
+
export function consoleEmail(): EmailSender {
|
|
58
|
+
return async (msg) => {
|
|
59
|
+
console.log({
|
|
60
|
+
event: "guestlist.email",
|
|
61
|
+
kind: msg.kind,
|
|
62
|
+
to: msg.to.email,
|
|
63
|
+
url: msg.url,
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Internal: adapt the public single-function contract onto better-auth's
|
|
70
|
+
* per-kind callback shape (`PlatformAuthSendEmail` in @somewhatintelligent/auth).
|
|
71
|
+
*/
|
|
72
|
+
export function toPlatformAuthSendEmail(send: EmailSender): PlatformAuthSendEmail {
|
|
73
|
+
return {
|
|
74
|
+
verification: ({ user, url }) =>
|
|
75
|
+
send({ kind: "verification", to: { email: user.email, name: user.name }, url }),
|
|
76
|
+
resetPassword: ({ user, url }) =>
|
|
77
|
+
send({ kind: "reset-password", to: { email: user.email, name: user.name }, url }),
|
|
78
|
+
changeEmail: ({ user, newEmail, url }) =>
|
|
79
|
+
send({ kind: "email-change", to: { email: user.email, name: user.name }, url, newEmail }),
|
|
80
|
+
deleteAccount: ({ user, url }) =>
|
|
81
|
+
send({ kind: "delete-account", to: { email: user.email, name: user.name }, url }),
|
|
82
|
+
magicLink: ({ email, url }) => send({ kind: "magic-link", to: { email }, url }),
|
|
83
|
+
invitation: (data) =>
|
|
84
|
+
send({
|
|
85
|
+
kind: "org-invitation",
|
|
86
|
+
to: { email: data.email },
|
|
87
|
+
url: data.inviteUrl,
|
|
88
|
+
organizationName: data.organizationName,
|
|
89
|
+
role: data.role,
|
|
90
|
+
inviter: { name: data.inviterName, email: data.inviterEmail },
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Guestlist` WorkerEntrypoint — the ONLY spelling of every guestlist
|
|
3
|
+
* operation other than better-auth's own HTTP routes. Consumers bind it
|
|
4
|
+
* with `"entrypoint": "Guestlist"` on the service binding and call typed
|
|
5
|
+
* RPC; there is no HTTP route for any of these operations.
|
|
6
|
+
*
|
|
7
|
+
* Methods acting on behalf of a user take the caller-forwarded raw
|
|
8
|
+
* `Cookie` header — the cookie stays the sole credential (binding
|
|
9
|
+
* reachability alone grants nothing user- or admin-scoped).
|
|
10
|
+
*/
|
|
11
|
+
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
12
|
+
import { executionContext } from "@somewhatintelligent/kit/execution-context";
|
|
13
|
+
import type { PlatformSession } from "@somewhatintelligent/auth";
|
|
14
|
+
|
|
15
|
+
import type { GuestlistEnv } from "./env";
|
|
16
|
+
import type { GuestlistConfig } from "./config";
|
|
17
|
+
import type { GuestlistInstances } from "./instances";
|
|
18
|
+
import { resolveUser, resolveAdmin, unauthorized, isErr, type Authed, type RpcErr } from "./rpc";
|
|
19
|
+
import * as adminOps from "./ops/admin";
|
|
20
|
+
import * as orgOps from "./ops/orgs";
|
|
21
|
+
import * as userOps from "./ops/users";
|
|
22
|
+
import * as avatarOps from "./ops/avatar";
|
|
23
|
+
|
|
24
|
+
type WithCookie<T = Record<never, never>> = T & { cookie: string };
|
|
25
|
+
|
|
26
|
+
export function buildGuestlistEntrypoint<TEnv extends GuestlistEnv>(
|
|
27
|
+
config: GuestlistConfig<TEnv>,
|
|
28
|
+
instances: (env: TEnv) => GuestlistInstances,
|
|
29
|
+
fetchHandler: ExportedHandlerFetchHandler<TEnv>,
|
|
30
|
+
) {
|
|
31
|
+
return class Guestlist extends WorkerEntrypoint<TEnv> {
|
|
32
|
+
/**
|
|
33
|
+
* Same HTTP surface as the default export, so ONE service binding
|
|
34
|
+
* (`"entrypoint": "Guestlist"`) serves both RPC and the better-auth
|
|
35
|
+
* HTTP proxy (bouncer's `/api` mount, apps' auth proxying).
|
|
36
|
+
*/
|
|
37
|
+
override fetch(request: Request): Promise<Response> {
|
|
38
|
+
return Promise.resolve(fetchHandler(request as never, this.env, this.ctx));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#inst(): GuestlistInstances {
|
|
42
|
+
return instances(this.env);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Seed the ExecutionContext ALS so ops can `waitUntil` background work. */
|
|
46
|
+
#run<T>(fn: () => Promise<T>): Promise<T> {
|
|
47
|
+
return executionContext.run(this.ctx, fn);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async #user(cookie: string) {
|
|
51
|
+
return resolveUser(this.#inst().auth, cookie);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async #admin(cookie: string) {
|
|
55
|
+
return resolveAdmin(this.#inst().auth, cookie);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------- session ----------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve (and possibly refresh) the session for the given Cookie
|
|
62
|
+
* header. `setCookies` carries any BA cookie refreshes; callers at an
|
|
63
|
+
* HTTP boundary must forward them onto their outgoing response.
|
|
64
|
+
*/
|
|
65
|
+
async getSession(input: {
|
|
66
|
+
cookie: string;
|
|
67
|
+
}): Promise<{ session: PlatformSession | null; setCookies: string[] }> {
|
|
68
|
+
return this.#run(async () => {
|
|
69
|
+
const { headers, response } = await this.#inst().auth.api.getSession({
|
|
70
|
+
headers: new Headers({ cookie: input.cookie }),
|
|
71
|
+
returnHeaders: true,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
session: (response as PlatformSession | null) ?? null,
|
|
75
|
+
setCookies: headers.getSetCookie(),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Which social providers are configured (sign-in button visibility). */
|
|
81
|
+
async getProviders() {
|
|
82
|
+
const env = this.env;
|
|
83
|
+
return {
|
|
84
|
+
social: {
|
|
85
|
+
google: !!env.GOOGLE_CLIENT_ID && !!env.GOOGLE_CLIENT_SECRET,
|
|
86
|
+
microsoft: !!env.MICROSOFT_CLIENT_ID && !!env.MICROSOFT_CLIENT_SECRET,
|
|
87
|
+
facebook: !!env.FACEBOOK_CLIENT_ID && !!env.FACEBOOK_CLIENT_SECRET,
|
|
88
|
+
linkedin: !!env.LINKEDIN_CLIENT_ID && !!env.LINKEDIN_CLIENT_SECRET,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------- user directory (session-gated) ----------
|
|
94
|
+
|
|
95
|
+
async searchUsers(input: WithCookie<{ query: string; limit?: number }>) {
|
|
96
|
+
return this.#run(async () => {
|
|
97
|
+
const authed = await this.#user(input.cookie);
|
|
98
|
+
if (!authed) return unauthorized;
|
|
99
|
+
return userOps.searchUsers(this.#inst(), input);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async getUsersByIds(input: WithCookie<{ ids: string[] }>) {
|
|
104
|
+
return this.#run(async () => {
|
|
105
|
+
const authed = await this.#user(input.cookie);
|
|
106
|
+
if (!authed) return unauthorized;
|
|
107
|
+
return userOps.getUsersByIds(this.#inst(), input.ids);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async getConnections(input: WithCookie) {
|
|
112
|
+
return this.#run(async () => {
|
|
113
|
+
const authed = await this.#user(input.cookie);
|
|
114
|
+
if (!authed) return unauthorized;
|
|
115
|
+
return userOps.getConnections(this.#inst(), authed);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------- avatar (session-gated; public READ stays HTTP /u/avatar/:refId) ----------
|
|
120
|
+
|
|
121
|
+
async registerAvatarUpload(
|
|
122
|
+
input: WithCookie<{ hash: string; size: number; contentType: avatarOps.AvatarContentType }>,
|
|
123
|
+
) {
|
|
124
|
+
return this.#run(async () => {
|
|
125
|
+
const authed = await this.#user(input.cookie);
|
|
126
|
+
if (!authed) return unauthorized;
|
|
127
|
+
return avatarOps.registerAvatarUpload(this.#inst(), authed, input);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async confirmAvatar(input: WithCookie<{ referenceId: string }>) {
|
|
132
|
+
return this.#run(async () => {
|
|
133
|
+
const authed = await this.#user(input.cookie);
|
|
134
|
+
if (!authed) return unauthorized;
|
|
135
|
+
return avatarOps.confirmAvatar(this.#inst(), this.env, authed, input.cookie, input);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async removeAvatar(input: WithCookie) {
|
|
140
|
+
return this.#run(async () => {
|
|
141
|
+
const authed = await this.#user(input.cookie);
|
|
142
|
+
if (!authed) return unauthorized;
|
|
143
|
+
return avatarOps.removeAvatar(this.#inst(), authed, input.cookie);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------- operator admin (admin-gated) ----------
|
|
148
|
+
|
|
149
|
+
async adminStats(input: WithCookie) {
|
|
150
|
+
return this.#gated(input.cookie, () => adminOps.stats(this.#inst()));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async adminListSessions(input: WithCookie) {
|
|
154
|
+
return this.#gated(input.cookie, () => adminOps.listSessions(this.#inst()));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async adminListApiKeys(input: WithCookie) {
|
|
158
|
+
return this.#gated(input.cookie, () => adminOps.listApiKeys(this.#inst()));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async adminListClients(input: WithCookie) {
|
|
162
|
+
return this.#gated(input.cookie, () => adminOps.listClients(this.#inst()));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async adminCreateClient(input: WithCookie<adminOps.CreateClientInput>) {
|
|
166
|
+
return this.#gated(input.cookie, () =>
|
|
167
|
+
adminOps.createClient(this.#inst(), input.cookie, input),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async adminGetClient(input: WithCookie<{ id: string }>) {
|
|
172
|
+
return this.#gated(input.cookie, () => adminOps.getClient(this.#inst(), input.id));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async adminUpdateClient(input: WithCookie<{ id: string } & adminOps.UpdateClientInput>) {
|
|
176
|
+
return this.#gated(input.cookie, () =>
|
|
177
|
+
adminOps.updateClient(this.#inst(), input.cookie, input.id, input),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async adminRotateClientSecret(input: WithCookie<{ id: string }>) {
|
|
182
|
+
return this.#gated(input.cookie, () =>
|
|
183
|
+
adminOps.rotateClientSecret(this.#inst(), input.cookie, input.id),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async adminDeleteClient(input: WithCookie<{ id: string }>) {
|
|
188
|
+
return this.#gated(input.cookie, () =>
|
|
189
|
+
adminOps.deleteClient(this.#inst(), input.cookie, input.id),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------- operator orgs (admin-gated) ----------
|
|
194
|
+
|
|
195
|
+
async adminCreateOrg(input: WithCookie<{ name: string; slug: string; ownerUserId: string }>) {
|
|
196
|
+
return this.#gated(input.cookie, () => orgOps.createOrg(this.#inst(), input));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async adminListOrgs(input: WithCookie) {
|
|
200
|
+
return this.#gated(input.cookie, () => orgOps.listOrgs(this.#inst()));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async adminGetOrg(input: WithCookie<{ id: string }>) {
|
|
204
|
+
return this.#gated(input.cookie, () => orgOps.getOrg(this.#inst(), input.id));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async adminSearchUsersByEmail(input: WithCookie<{ email: string }>) {
|
|
208
|
+
return this.#gated(input.cookie, () =>
|
|
209
|
+
orgOps.searchUsersByEmail(this.#inst(), input.email),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async adminAddOrgMember(
|
|
214
|
+
input: WithCookie<{ orgId: string; userId: string; role: string }>,
|
|
215
|
+
) {
|
|
216
|
+
return this.#gated(input.cookie, () => orgOps.addMember(this.#inst(), input.orgId, input));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async adminUpdateOrgMemberRole(
|
|
220
|
+
input: WithCookie<{ orgId: string; userId: string; role: string }>,
|
|
221
|
+
) {
|
|
222
|
+
return this.#gated(input.cookie, () =>
|
|
223
|
+
orgOps.updateMemberRole(this.#inst(), input.orgId, input.userId, input.role),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async adminRemoveOrgMember(input: WithCookie<{ orgId: string; userId: string }>) {
|
|
228
|
+
return this.#gated(input.cookie, () =>
|
|
229
|
+
orgOps.removeMember(this.#inst(), input.orgId, input.userId),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async adminCreateOrgInvitation(
|
|
234
|
+
input: WithCookie<{ orgId: string; email: string; role: string }>,
|
|
235
|
+
) {
|
|
236
|
+
return this.#gated(input.cookie, (operator) =>
|
|
237
|
+
orgOps.createInvitation(this.#inst(), operator, input.orgId, input),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async adminCancelOrgInvitation(input: WithCookie<{ orgId: string; invitationId: string }>) {
|
|
242
|
+
return this.#gated(input.cookie, () =>
|
|
243
|
+
orgOps.cancelInvitation(this.#inst(), input.orgId, input.invitationId),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------- internals ----------
|
|
248
|
+
|
|
249
|
+
async #gated<T>(cookie: string, fn: (operator: Authed) => Promise<T>): Promise<T | RpcErr> {
|
|
250
|
+
return this.#run(async () => {
|
|
251
|
+
const gate = await this.#admin(cookie);
|
|
252
|
+
if (isErr(gate)) return gate;
|
|
253
|
+
return fn(gate);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export type GuestlistEntrypointClass = ReturnType<typeof buildGuestlistEntrypoint>;
|
|
260
|
+
/** The RPC surface, for consumers typing their binding: `Service<GuestlistRpc>`. */
|
|
261
|
+
export type GuestlistRpc = InstanceType<GuestlistEntrypointClass>;
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The env bindings/vars `createGuestlist` requires from the consumer's
|
|
3
|
+
* worker. Declared as an explicit interface (not a wrangler-generated
|
|
4
|
+
* ambient) so every consumer sees the same shape regardless of which
|
|
5
|
+
* compilation unit walks this source: TypeScript ambient types are
|
|
6
|
+
* compilation-unit-scoped, so a consumer importing our types would
|
|
7
|
+
* otherwise resolve `env.*` against THEIR `Cloudflare.Env`.
|
|
8
|
+
*
|
|
9
|
+
* The consumer's generated `worker-configuration.d.ts` should be asserted
|
|
10
|
+
* against this shape in their shim (`satisfies`-style) so a wrong
|
|
11
|
+
* `wrangler.jsonc` is a type error, not a runtime throw.
|
|
12
|
+
*
|
|
13
|
+
* Email transport and blob storage are NOT env bindings here — they are
|
|
14
|
+
* injected capabilities on `GuestlistConfig` (`sendEmail`, `blobs`); the
|
|
15
|
+
* factories receive this env, so a consumer's implementation can read any
|
|
16
|
+
* extra vars/bindings it needs off its own wider env type.
|
|
17
|
+
*/
|
|
18
|
+
export interface GuestlistEnv {
|
|
19
|
+
DB: D1Database;
|
|
20
|
+
ENVIRONMENT: string;
|
|
21
|
+
/** Canonical user-facing origin — BA base URL; also the avatar URL host. */
|
|
22
|
+
BETTER_AUTH_URL: string;
|
|
23
|
+
/** Public address of the identity app (OAuth login/consent pages, invite links). */
|
|
24
|
+
IDENTITY_URL: string;
|
|
25
|
+
/** Cookie Domain attribute — `.{apex}` for cross-subdomain session sharing. */
|
|
26
|
+
AUTH_DOMAIN: string;
|
|
27
|
+
BETTER_AUTH_SECRET: string;
|
|
28
|
+
GOOGLE_CLIENT_ID?: string;
|
|
29
|
+
GOOGLE_CLIENT_SECRET?: string;
|
|
30
|
+
MICROSOFT_CLIENT_ID?: string;
|
|
31
|
+
MICROSOFT_CLIENT_SECRET?: string;
|
|
32
|
+
FACEBOOK_CLIENT_ID?: string;
|
|
33
|
+
FACEBOOK_CLIENT_SECRET?: string;
|
|
34
|
+
LINKEDIN_CLIENT_ID?: string;
|
|
35
|
+
LINKEDIN_CLIENT_SECRET?: string;
|
|
36
|
+
// Stripe subscription billing — optional, gates the better-auth `stripe`
|
|
37
|
+
// plugin (@somewhatintelligent/auth server.ts). Both must be present (plus
|
|
38
|
+
// `billing.plans` in GuestlistConfig) for the plugin to enter the
|
|
39
|
+
// plugin array at all; absent = byte-identical to a build with no Stripe.
|
|
40
|
+
STRIPE_SECRET_KEY?: string;
|
|
41
|
+
STRIPE_WEBHOOK_SIGNING_SECRET?: string;
|
|
42
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guestlist's entire HTTP surface. Deliberately tiny: better-auth's own
|
|
3
|
+
* handler (`/api/auth/*`), the OIDC well-known metadata (also
|
|
4
|
+
* better-auth-provided, via @better-auth/oauth-provider), the public
|
|
5
|
+
* avatar read broker (`/u/avatar/:refId` — browsers hit it from `<img>`
|
|
6
|
+
* tags, so it cannot be RPC), and ops endpoints (`/health`, `/__version`).
|
|
7
|
+
*
|
|
8
|
+
* Every other operation lives on the `Guestlist` WorkerEntrypoint (RPC
|
|
9
|
+
* over service bindings) — see ./entrypoint.ts. There is intentionally no
|
|
10
|
+
* router here beyond this table.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
oauthProviderOpenIdConfigMetadata,
|
|
14
|
+
oauthProviderAuthServerMetadata,
|
|
15
|
+
} from "@better-auth/oauth-provider";
|
|
16
|
+
import { extractRequestId, withRequestContext } from "@somewhatintelligent/kit/request-context";
|
|
17
|
+
import { handleVersionRequest } from "@somewhatintelligent/kit/version";
|
|
18
|
+
import { PKG } from "./version.gen";
|
|
19
|
+
import { executionContext } from "@somewhatintelligent/kit/execution-context";
|
|
20
|
+
|
|
21
|
+
import type { GuestlistEnv } from "./env";
|
|
22
|
+
import type { GuestlistConfig } from "./config";
|
|
23
|
+
import type { GuestlistInstances } from "./instances";
|
|
24
|
+
import { createCors } from "./cors";
|
|
25
|
+
import { avatarReadRedirect } from "./ops/avatar";
|
|
26
|
+
import { emitHttp } from "./log";
|
|
27
|
+
|
|
28
|
+
const AVATAR_PATH_RE = /^\/u\/avatar\/([A-Za-z0-9_-]+)$/;
|
|
29
|
+
|
|
30
|
+
export function buildFetchHandler<TEnv extends GuestlistEnv>(
|
|
31
|
+
config: GuestlistConfig<TEnv>,
|
|
32
|
+
instances: (env: TEnv) => GuestlistInstances,
|
|
33
|
+
): ExportedHandlerFetchHandler<TEnv> {
|
|
34
|
+
const cors = createCors({ domains: config.corsDomains });
|
|
35
|
+
const serviceName = config.serviceName ?? "guestlist";
|
|
36
|
+
|
|
37
|
+
async function route(request: Request, env: TEnv): Promise<Response> {
|
|
38
|
+
const inst = instances(env);
|
|
39
|
+
const { auth } = inst;
|
|
40
|
+
const url = new URL(request.url);
|
|
41
|
+
const path = url.pathname;
|
|
42
|
+
|
|
43
|
+
const preflight = cors.preflight(request);
|
|
44
|
+
if (preflight) return preflight;
|
|
45
|
+
|
|
46
|
+
// Better-auth's own route surface.
|
|
47
|
+
if (path === "/api/auth/.well-known/openid-configuration") {
|
|
48
|
+
return oauthProviderOpenIdConfigMetadata(auth)(request);
|
|
49
|
+
}
|
|
50
|
+
if (path.startsWith("/api/auth/")) {
|
|
51
|
+
return auth.handler(request);
|
|
52
|
+
}
|
|
53
|
+
// OIDC discovery (better-auth-provided handlers at the host root).
|
|
54
|
+
if (path === "/.well-known/openid-configuration") {
|
|
55
|
+
return oauthProviderOpenIdConfigMetadata(auth)(request);
|
|
56
|
+
}
|
|
57
|
+
if (path === "/.well-known/oauth-authorization-server/api/auth") {
|
|
58
|
+
return oauthProviderAuthServerMetadata(auth)(request);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Public avatar read broker — browsers reach this from `<img src>`.
|
|
62
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
63
|
+
const avatar = AVATAR_PATH_RE.exec(path);
|
|
64
|
+
if (avatar) return avatarReadRedirect(inst, avatar[1]!);
|
|
65
|
+
if (path === "/health") {
|
|
66
|
+
return Response.json({ status: "ok", service: serviceName });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return new Response(null, { status: 404 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (request, env, ctx) => {
|
|
74
|
+
// Version endpoint, answered at the boundary. Two spellings: /__version
|
|
75
|
+
// (direct / service-binding calls) and /api/__version (bouncer mounts
|
|
76
|
+
// guestlist at /api in PASSTHROUGH mode — the prefix is not stripped).
|
|
77
|
+
const version = handleVersionRequest(request, {
|
|
78
|
+
pkg: PKG,
|
|
79
|
+
worker: serviceName,
|
|
80
|
+
env: env as Record<string, unknown>,
|
|
81
|
+
paths: ["/__version", "/api/__version"],
|
|
82
|
+
});
|
|
83
|
+
if (version) return Promise.resolve(version);
|
|
84
|
+
|
|
85
|
+
// Two ALS scopes at the boundary:
|
|
86
|
+
// 1. executionContext — seeds CF's ExecutionContext for better-auth's
|
|
87
|
+
// backgroundTasks handler + avatar deferRelease.
|
|
88
|
+
// 2. withRequestContext — correlation context for canonical log lines.
|
|
89
|
+
// The x-caller-app / x-actor-* headers are caller-asserted and are
|
|
90
|
+
// LOG CORRELATION ONLY; authoritative identity always flows through
|
|
91
|
+
// cookies → BA get-session → DB. Never read them anywhere but here.
|
|
92
|
+
return executionContext.run(ctx, () =>
|
|
93
|
+
withRequestContext(
|
|
94
|
+
{
|
|
95
|
+
requestId: extractRequestId(request),
|
|
96
|
+
callerApp: request.headers.get("x-caller-app") ?? undefined,
|
|
97
|
+
actorKind: request.headers.get("x-actor-kind") ?? undefined,
|
|
98
|
+
actorId: request.headers.get("x-actor-id") ?? undefined,
|
|
99
|
+
},
|
|
100
|
+
async () => {
|
|
101
|
+
const startMs = Date.now();
|
|
102
|
+
const path = new URL(request.url).pathname;
|
|
103
|
+
try {
|
|
104
|
+
const response = cors.apply(request, await route(request, env));
|
|
105
|
+
await emitHttp({ request, startMs, path, status: response.status });
|
|
106
|
+
return response;
|
|
107
|
+
} catch (e) {
|
|
108
|
+
await emitHttp({
|
|
109
|
+
request,
|
|
110
|
+
startMs,
|
|
111
|
+
path,
|
|
112
|
+
status: 500,
|
|
113
|
+
errorCode: "internal",
|
|
114
|
+
errorMessage: e instanceof Error ? e.message : String(e),
|
|
115
|
+
});
|
|
116
|
+
return new Response(null, { status: 500 });
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
}
|