@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/index.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @somewhatintelligent/guestlist — the IdP as a library.
|
|
3
|
+
*
|
|
4
|
+
* A consumer's guestlist worker is a thin shim:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { createGuestlist, consoleEmail } from "@somewhatintelligent/guestlist";
|
|
8
|
+
*
|
|
9
|
+
* const gl = createGuestlist({
|
|
10
|
+
* cookiePrefix: "acme",
|
|
11
|
+
* passkeyRpName: "Acme",
|
|
12
|
+
* twoFactorIssuer: "Acme",
|
|
13
|
+
* corsDomains: ["acme.com", "acme.localhost"],
|
|
14
|
+
* sendEmail: (env) => consoleEmail(), // replace with your renderer/provider
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* export default { fetch: gl.fetch };
|
|
18
|
+
* export const Guestlist = gl.Guestlist;
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* HTTP serves only better-auth's own routes (+ OIDC well-known, the public
|
|
22
|
+
* avatar read, /health, /__version). Everything else is typed RPC on the
|
|
23
|
+
* `Guestlist` WorkerEntrypoint.
|
|
24
|
+
*/
|
|
25
|
+
import type { GuestlistEnv } from "./env";
|
|
26
|
+
import type { GuestlistConfig } from "./config";
|
|
27
|
+
import { createInstanceStore } from "./instances";
|
|
28
|
+
import { buildFetchHandler } from "./http";
|
|
29
|
+
import { buildGuestlistEntrypoint } from "./entrypoint";
|
|
30
|
+
|
|
31
|
+
export function createGuestlist<TEnv extends GuestlistEnv = GuestlistEnv>(
|
|
32
|
+
config: GuestlistConfig<TEnv>,
|
|
33
|
+
) {
|
|
34
|
+
const instances = createInstanceStore(config);
|
|
35
|
+
const fetch = buildFetchHandler(config, instances);
|
|
36
|
+
return {
|
|
37
|
+
fetch,
|
|
38
|
+
Guestlist: buildGuestlistEntrypoint(config, instances, fetch),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type { GuestlistEnv } from "./env";
|
|
43
|
+
export type { GuestlistConfig } from "./config";
|
|
44
|
+
export type { GuestlistRpc, GuestlistEntrypointClass } from "./entrypoint";
|
|
45
|
+
export type { RpcResult, RpcErr } from "./rpc";
|
|
46
|
+
|
|
47
|
+
// Config-shaping re-exports so consumers configure everything from this
|
|
48
|
+
// package (custom org roles are built with better-auth's own
|
|
49
|
+
// `createAccessControl` — import that from "better-auth/plugins/access").
|
|
50
|
+
export { defaultOrgAccess } from "@somewhatintelligent/auth";
|
|
51
|
+
export type {
|
|
52
|
+
PlatformAdminOptions,
|
|
53
|
+
PlatformOrgOptions,
|
|
54
|
+
PlatformRateLimitOptions,
|
|
55
|
+
PlatformSessionOptions,
|
|
56
|
+
SubscriptionPlan,
|
|
57
|
+
} from "@somewhatintelligent/auth";
|
|
58
|
+
|
|
59
|
+
// Email contract (templates and transport are consumer-owned).
|
|
60
|
+
export {
|
|
61
|
+
defineEmailHandlers,
|
|
62
|
+
consoleEmail,
|
|
63
|
+
type GuestlistEmail,
|
|
64
|
+
type GuestlistEmailKind,
|
|
65
|
+
type EmailSender,
|
|
66
|
+
type EmailRecipient,
|
|
67
|
+
} from "./email";
|
|
68
|
+
|
|
69
|
+
// Blob capability for the avatar surface.
|
|
70
|
+
export type {
|
|
71
|
+
GuestlistBlobStore,
|
|
72
|
+
BlobRegisterInput,
|
|
73
|
+
BlobRegisterResult,
|
|
74
|
+
BlobUploadPlan,
|
|
75
|
+
BlobReferenceState,
|
|
76
|
+
} from "./blobs";
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
AVATAR_MAX_BYTES,
|
|
80
|
+
AVATAR_CONTENT_TYPES,
|
|
81
|
+
type AvatarContentType,
|
|
82
|
+
} from "./ops/avatar";
|
|
83
|
+
|
|
84
|
+
// Schema codegen for consumer-owned migrations (see ./codegen.ts).
|
|
85
|
+
export { createCodegenAuth } from "./codegen";
|
package/src/instances.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-isolate instance wiring: env → { auth, db, sendEmail, blobs }.
|
|
3
|
+
*
|
|
4
|
+
* Workers hand the same `env` object to every invocation in an isolate, so
|
|
5
|
+
* a WeakMap keyed on it gives one better-auth/drizzle instance per isolate
|
|
6
|
+
* without module-level singletons (which would bind the library to a
|
|
7
|
+
* single env shape at import time and break multi-env test harnesses).
|
|
8
|
+
*/
|
|
9
|
+
import { createPlatformAuth, defaultOrgAccess } from "@somewhatintelligent/auth";
|
|
10
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
11
|
+
import { executionContext } from "@somewhatintelligent/kit/execution-context";
|
|
12
|
+
|
|
13
|
+
import type { GuestlistEnv } from "./env";
|
|
14
|
+
import type { GuestlistConfig } from "./config";
|
|
15
|
+
import type { EmailSender } from "./email";
|
|
16
|
+
import type { GuestlistBlobStore } from "./blobs";
|
|
17
|
+
import { toPlatformAuthSendEmail } from "./email";
|
|
18
|
+
import { createDb, type Database } from "./db";
|
|
19
|
+
import { log } from "./log";
|
|
20
|
+
|
|
21
|
+
export interface GuestlistInstances {
|
|
22
|
+
auth: ReturnType<typeof createPlatformAuth>;
|
|
23
|
+
db: Database;
|
|
24
|
+
sendEmail: EmailSender;
|
|
25
|
+
blobs: GuestlistBlobStore | null;
|
|
26
|
+
/** Valid org role names (from config.organization.roles or the stock three). */
|
|
27
|
+
orgRoleNames: readonly string[];
|
|
28
|
+
/** The org-creator role (config.organization.creatorRole or "owner") — the last-X guards key on it. */
|
|
29
|
+
orgCreatorRole: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type GuestlistAuth = GuestlistInstances["auth"];
|
|
33
|
+
|
|
34
|
+
function buildSocialProviders(env: GuestlistEnv) {
|
|
35
|
+
return {
|
|
36
|
+
...(env.GOOGLE_CLIENT_ID &&
|
|
37
|
+
env.GOOGLE_CLIENT_SECRET && {
|
|
38
|
+
google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET },
|
|
39
|
+
}),
|
|
40
|
+
...(env.MICROSOFT_CLIENT_ID &&
|
|
41
|
+
env.MICROSOFT_CLIENT_SECRET && {
|
|
42
|
+
microsoft: { clientId: env.MICROSOFT_CLIENT_ID, clientSecret: env.MICROSOFT_CLIENT_SECRET },
|
|
43
|
+
}),
|
|
44
|
+
...(env.FACEBOOK_CLIENT_ID &&
|
|
45
|
+
env.FACEBOOK_CLIENT_SECRET && {
|
|
46
|
+
facebook: { clientId: env.FACEBOOK_CLIENT_ID, clientSecret: env.FACEBOOK_CLIENT_SECRET },
|
|
47
|
+
}),
|
|
48
|
+
...(env.LINKEDIN_CLIENT_ID &&
|
|
49
|
+
env.LINKEDIN_CLIENT_SECRET && {
|
|
50
|
+
linkedin: { clientId: env.LINKEDIN_CLIENT_ID, clientSecret: env.LINKEDIN_CLIENT_SECRET },
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createInstanceStore<TEnv extends GuestlistEnv>(config: GuestlistConfig<TEnv>) {
|
|
56
|
+
const cache = new WeakMap<TEnv, GuestlistInstances>();
|
|
57
|
+
|
|
58
|
+
return function instances(env: TEnv): GuestlistInstances {
|
|
59
|
+
const hit = cache.get(env);
|
|
60
|
+
if (hit) return hit;
|
|
61
|
+
|
|
62
|
+
const db = createDb(env.DB, config.schema);
|
|
63
|
+
const sendEmail = config.sendEmail(env);
|
|
64
|
+
const blobs = config.blobs ? config.blobs(env) : null;
|
|
65
|
+
|
|
66
|
+
// Dormant unless BOTH secrets reach this worker AND the consumer
|
|
67
|
+
// configured billing — see the `stripe` field doc on
|
|
68
|
+
// CreatePlatformAuthOptions (@somewhatintelligent/auth). Absent = the plugin never
|
|
69
|
+
// enters the plugin array.
|
|
70
|
+
const stripe =
|
|
71
|
+
config.billing && env.STRIPE_SECRET_KEY && env.STRIPE_WEBHOOK_SIGNING_SECRET
|
|
72
|
+
? {
|
|
73
|
+
secretKey: env.STRIPE_SECRET_KEY,
|
|
74
|
+
webhookSecret: env.STRIPE_WEBHOOK_SIGNING_SECRET,
|
|
75
|
+
plans: config.billing.plans,
|
|
76
|
+
}
|
|
77
|
+
: undefined;
|
|
78
|
+
|
|
79
|
+
const auth = createPlatformAuth({
|
|
80
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
81
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
82
|
+
authDomain: env.AUTH_DOMAIN,
|
|
83
|
+
identityUrl: env.IDENTITY_URL,
|
|
84
|
+
requireEmailVerification: env.ENVIRONMENT === "production",
|
|
85
|
+
cookiePrefix: config.cookiePrefix,
|
|
86
|
+
passkeyRpName: config.passkeyRpName,
|
|
87
|
+
twoFactorIssuer: config.twoFactorIssuer,
|
|
88
|
+
database: drizzleAdapter(db, { provider: "sqlite", schema: config.schema }),
|
|
89
|
+
socialProviders: buildSocialProviders(env),
|
|
90
|
+
stripe,
|
|
91
|
+
organization: config.organization,
|
|
92
|
+
admin: config.admin,
|
|
93
|
+
session: config.session,
|
|
94
|
+
rateLimit: config.rateLimit,
|
|
95
|
+
sendEmail: toPlatformAuthSendEmail(sendEmail),
|
|
96
|
+
backgroundTasks: {
|
|
97
|
+
handler: (promise) => {
|
|
98
|
+
const ctx = executionContext.getStore();
|
|
99
|
+
if (!ctx) return;
|
|
100
|
+
ctx.waitUntil(
|
|
101
|
+
promise.catch((err) => log.warn("auth.bg_task_failed", { error: String(err) })),
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const built: GuestlistInstances = {
|
|
108
|
+
auth,
|
|
109
|
+
db,
|
|
110
|
+
sendEmail,
|
|
111
|
+
blobs,
|
|
112
|
+
// Fall back to the SAME defaults createPlatformAuth applies, from the
|
|
113
|
+
// same exported constant — a hand-copied role list here would drift.
|
|
114
|
+
orgRoleNames: Object.keys(config.organization?.roles ?? defaultOrgAccess.roles),
|
|
115
|
+
orgCreatorRole: config.organization?.creatorRole ?? "owner",
|
|
116
|
+
};
|
|
117
|
+
cache.set(env, built);
|
|
118
|
+
return built;
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Guestlist log helpers. Two emission shapes:
|
|
2
|
+
//
|
|
3
|
+
// - `emitHttp(...)` — canonical platform line shape for HTTP requests
|
|
4
|
+
// (mirrors the kit's `withCanonicalLog` schema: service/event/operation/
|
|
5
|
+
// outcome/duration_ms/request_id/actor_*). Used from Elysia's
|
|
6
|
+
// `onError` + `onAfterHandle` hooks. Each call emits one line.
|
|
7
|
+
// `request_id` is read from the active request context (opened at the
|
|
8
|
+
// fetch boundary in index.ts via `withRequestContext`), so every line
|
|
9
|
+
// emitted during a request shares the same id.
|
|
10
|
+
//
|
|
11
|
+
// - `log.info/warn/error(event, fields)` — looser shape for ad-hoc
|
|
12
|
+
// emissions (Better Auth's `backgroundTasks.handler` failure
|
|
13
|
+
// notification, e.g.) that don't have a request_id at emit time and
|
|
14
|
+
// aren't part of an inbound HTTP lifecycle.
|
|
15
|
+
import { withCanonicalLog } from "@somewhatintelligent/kit/log";
|
|
16
|
+
import { extractRequestId, getActorId, getActorKind, getCallerApp } from "@somewhatintelligent/kit/request-context";
|
|
17
|
+
|
|
18
|
+
type Level = "info" | "warn" | "error";
|
|
19
|
+
type Fields = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
function emit(level: Level, event: string, fields: Fields = {}) {
|
|
22
|
+
const line = { level, event, time: new Date().toISOString(), ...fields };
|
|
23
|
+
if (level === "error") console.error(line);
|
|
24
|
+
else if (level === "warn") console.warn(line);
|
|
25
|
+
else console.log(line);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const log = {
|
|
29
|
+
info: (event: string, fields?: Fields) => emit("info", event, fields),
|
|
30
|
+
warn: (event: string, fields?: Fields) => emit("warn", event, fields),
|
|
31
|
+
error: (event: string, fields?: Fields) => emit("error", event, fields),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface EmitHttpOpts {
|
|
35
|
+
request: Request;
|
|
36
|
+
// Captured at request start by the WeakMap state in index.ts.
|
|
37
|
+
startMs: number;
|
|
38
|
+
// Optional; falls back to the request URL pathname if absent.
|
|
39
|
+
path?: string;
|
|
40
|
+
// Final HTTP status — required for the success path, optional for errors
|
|
41
|
+
// where Elysia hasn't yet resolved a status when onError fires.
|
|
42
|
+
status?: number;
|
|
43
|
+
// Error path only.
|
|
44
|
+
errorCode?: string;
|
|
45
|
+
errorMessage?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Emit one canonical http line. Uses kit's `withCanonicalLog` so the
|
|
49
|
+
// shape stays in lockstep with every other platform component (roadie,
|
|
50
|
+
// promoter, apps). `duration_ms` is computed from `startMs` and passed
|
|
51
|
+
// via `line.add` to override the kit's auto-computed value (which would
|
|
52
|
+
// reflect only the time spent inside this helper).
|
|
53
|
+
export async function emitHttp(opts: EmitHttpOpts): Promise<void> {
|
|
54
|
+
const { request, startMs, status, errorCode, errorMessage } = opts;
|
|
55
|
+
const url = new URL(request.url);
|
|
56
|
+
const path = opts.path ?? url.pathname;
|
|
57
|
+
const requestId = extractRequestId(request);
|
|
58
|
+
// Read caller-app + actor from the request-context ALS opened at the
|
|
59
|
+
// fetch boundary in index.ts. These were forwarded as headers by the
|
|
60
|
+
// guestlist client (caller-asserted, log-correlation only). Falls back to
|
|
61
|
+
// anonymous when the caller didn't set them (auth flows, direct browser
|
|
62
|
+
// hits to public endpoints).
|
|
63
|
+
await withCanonicalLog(
|
|
64
|
+
{
|
|
65
|
+
service: "guestlist",
|
|
66
|
+
event: "http",
|
|
67
|
+
operation: `guestlist.http.${request.method.toLowerCase()}`,
|
|
68
|
+
requestId,
|
|
69
|
+
callerApp: getCallerApp() ?? undefined,
|
|
70
|
+
actorKind: getActorKind() ?? "anonymous",
|
|
71
|
+
actorId: getActorId() ?? null,
|
|
72
|
+
},
|
|
73
|
+
async (line) => {
|
|
74
|
+
line.add({
|
|
75
|
+
method: request.method,
|
|
76
|
+
path,
|
|
77
|
+
...(status !== undefined && { status }),
|
|
78
|
+
...(errorCode !== undefined && { error_code: errorCode }),
|
|
79
|
+
...(errorMessage !== undefined && { error_message: errorMessage }),
|
|
80
|
+
duration_ms: Date.now() - startMs,
|
|
81
|
+
});
|
|
82
|
+
if (errorCode) {
|
|
83
|
+
line.outcome(
|
|
84
|
+
typeof status === "number" && status < 500 ? `http_${status}` : "internal_error",
|
|
85
|
+
);
|
|
86
|
+
} else if (typeof status === "number") {
|
|
87
|
+
if (status >= 500) line.outcome("internal_error");
|
|
88
|
+
else if (status >= 400) line.outcome(`http_${status}`);
|
|
89
|
+
else line.outcome("ok");
|
|
90
|
+
} else {
|
|
91
|
+
line.outcome("ok");
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
}
|
package/src/ops/admin.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator admin operations: stats, sessions, API keys, OAuth clients.
|
|
3
|
+
* Exposed only as WorkerEntrypoint methods; the entrypoint admin-gates
|
|
4
|
+
* every call before these run.
|
|
5
|
+
*/
|
|
6
|
+
import { count, desc, eq } from "drizzle-orm";
|
|
7
|
+
|
|
8
|
+
import type { GuestlistInstances } from "../instances";
|
|
9
|
+
import { user, session, apikey, oauthClient, oauthAccessToken, oauthConsent } from "../schema";
|
|
10
|
+
import { cookieHeaders, err, type RpcResult } from "../rpc";
|
|
11
|
+
|
|
12
|
+
export async function stats(inst: GuestlistInstances) {
|
|
13
|
+
const { db } = inst;
|
|
14
|
+
const [userCount, sessionCount, clientCount] = await Promise.all([
|
|
15
|
+
db.select({ count: count() }).from(user),
|
|
16
|
+
db.select({ count: count() }).from(session),
|
|
17
|
+
db.select({ count: count() }).from(oauthClient),
|
|
18
|
+
]);
|
|
19
|
+
return {
|
|
20
|
+
ok: true as const,
|
|
21
|
+
users: userCount[0]?.count ?? 0,
|
|
22
|
+
sessions: sessionCount[0]?.count ?? 0,
|
|
23
|
+
clients: clientCount[0]?.count ?? 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function listSessions(inst: GuestlistInstances) {
|
|
28
|
+
const rows = await inst.db
|
|
29
|
+
.select({
|
|
30
|
+
id: session.id,
|
|
31
|
+
userId: session.userId,
|
|
32
|
+
ipAddress: session.ipAddress,
|
|
33
|
+
userAgent: session.userAgent,
|
|
34
|
+
createdAt: session.createdAt,
|
|
35
|
+
expiresAt: session.expiresAt,
|
|
36
|
+
userName: user.name,
|
|
37
|
+
userEmail: user.email,
|
|
38
|
+
userImage: user.image,
|
|
39
|
+
})
|
|
40
|
+
.from(session)
|
|
41
|
+
.leftJoin(user, eq(session.userId, user.id))
|
|
42
|
+
.orderBy(desc(session.createdAt))
|
|
43
|
+
.limit(100);
|
|
44
|
+
return { ok: true as const, sessions: rows };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function listApiKeys(inst: GuestlistInstances) {
|
|
48
|
+
const rows = await inst.db
|
|
49
|
+
.select({
|
|
50
|
+
id: apikey.id,
|
|
51
|
+
name: apikey.name,
|
|
52
|
+
prefix: apikey.prefix,
|
|
53
|
+
enabled: apikey.enabled,
|
|
54
|
+
createdAt: apikey.createdAt,
|
|
55
|
+
ownerEmail: user.email,
|
|
56
|
+
})
|
|
57
|
+
.from(apikey)
|
|
58
|
+
.leftJoin(user, eq(apikey.referenceId, user.id))
|
|
59
|
+
.orderBy(desc(apikey.createdAt))
|
|
60
|
+
.limit(100);
|
|
61
|
+
return { ok: true as const, apiKeys: rows };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function listClients(inst: GuestlistInstances) {
|
|
65
|
+
const rows = await inst.db
|
|
66
|
+
.select()
|
|
67
|
+
.from(oauthClient)
|
|
68
|
+
.orderBy(desc(oauthClient.createdAt))
|
|
69
|
+
.limit(100);
|
|
70
|
+
return { ok: true as const, clients: rows };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CreateClientInput {
|
|
74
|
+
name: string;
|
|
75
|
+
redirectUris: string[];
|
|
76
|
+
skipConsent?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function createClient(
|
|
80
|
+
inst: GuestlistInstances,
|
|
81
|
+
cookie: string,
|
|
82
|
+
input: CreateClientInput,
|
|
83
|
+
): Promise<RpcResult<{ clientId: string; clientSecret: string }>> {
|
|
84
|
+
if (input.name.length < 2) return err("validation", "name must be at least 2 chars");
|
|
85
|
+
if (input.redirectUris.length < 1) return err("validation", "at least one redirect URI");
|
|
86
|
+
const res = await inst.auth.api.adminCreateOAuthClient({
|
|
87
|
+
// BA re-checks the admin session from these headers.
|
|
88
|
+
headers: cookieHeaders(cookie),
|
|
89
|
+
body: {
|
|
90
|
+
client_name: input.name,
|
|
91
|
+
redirect_uris: input.redirectUris,
|
|
92
|
+
skip_consent: input.skipConsent,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
return { ok: true, clientId: res.client_id, clientSecret: res.client_secret as string };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function getClient(inst: GuestlistInstances, id: string) {
|
|
99
|
+
const rows = await inst.db.select().from(oauthClient).where(eq(oauthClient.id, id)).limit(1);
|
|
100
|
+
const c = rows[0];
|
|
101
|
+
if (!c) return err("not_found");
|
|
102
|
+
const [tokenCount, consentCount] = await Promise.all([
|
|
103
|
+
inst.db
|
|
104
|
+
.select({ count: count() })
|
|
105
|
+
.from(oauthAccessToken)
|
|
106
|
+
.where(eq(oauthAccessToken.clientId, c.clientId)),
|
|
107
|
+
inst.db
|
|
108
|
+
.select({ count: count() })
|
|
109
|
+
.from(oauthConsent)
|
|
110
|
+
.where(eq(oauthConsent.clientId, c.clientId)),
|
|
111
|
+
]);
|
|
112
|
+
return {
|
|
113
|
+
ok: true as const,
|
|
114
|
+
client: c,
|
|
115
|
+
tokenCount: tokenCount[0]?.count ?? 0,
|
|
116
|
+
consentCount: consentCount[0]?.count ?? 0,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface UpdateClientInput {
|
|
121
|
+
name?: string;
|
|
122
|
+
redirectUris?: string[];
|
|
123
|
+
skipConsent?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function updateClient(
|
|
127
|
+
inst: GuestlistInstances,
|
|
128
|
+
cookie: string,
|
|
129
|
+
id: string,
|
|
130
|
+
input: UpdateClientInput,
|
|
131
|
+
): Promise<RpcResult<Record<never, never>>> {
|
|
132
|
+
const rows = await inst.db
|
|
133
|
+
.select({ clientId: oauthClient.clientId })
|
|
134
|
+
.from(oauthClient)
|
|
135
|
+
.where(eq(oauthClient.id, id))
|
|
136
|
+
.limit(1);
|
|
137
|
+
const c = rows[0];
|
|
138
|
+
if (!c) return err("not_found");
|
|
139
|
+
await inst.auth.api.adminUpdateOAuthClient({
|
|
140
|
+
headers: cookieHeaders(cookie),
|
|
141
|
+
body: {
|
|
142
|
+
client_id: c.clientId,
|
|
143
|
+
update: {
|
|
144
|
+
...(input.name !== undefined && { client_name: input.name }),
|
|
145
|
+
...(input.redirectUris !== undefined && { redirect_uris: input.redirectUris }),
|
|
146
|
+
...(input.skipConsent !== undefined && { skip_consent: input.skipConsent }),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function rotateClientSecret(
|
|
154
|
+
inst: GuestlistInstances,
|
|
155
|
+
cookie: string,
|
|
156
|
+
id: string,
|
|
157
|
+
): Promise<RpcResult<{ clientSecret: string }>> {
|
|
158
|
+
const rows = await inst.db
|
|
159
|
+
.select({ clientId: oauthClient.clientId })
|
|
160
|
+
.from(oauthClient)
|
|
161
|
+
.where(eq(oauthClient.id, id))
|
|
162
|
+
.limit(1);
|
|
163
|
+
const c = rows[0];
|
|
164
|
+
if (!c) return err("not_found");
|
|
165
|
+
const res = await inst.auth.api.rotateClientSecret({
|
|
166
|
+
headers: cookieHeaders(cookie),
|
|
167
|
+
body: { client_id: c.clientId },
|
|
168
|
+
});
|
|
169
|
+
return { ok: true, clientSecret: res.client_secret as string };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function deleteClient(
|
|
173
|
+
inst: GuestlistInstances,
|
|
174
|
+
cookie: string,
|
|
175
|
+
id: string,
|
|
176
|
+
): Promise<RpcResult<Record<never, never>>> {
|
|
177
|
+
const rows = await inst.db
|
|
178
|
+
.select({ clientId: oauthClient.clientId, referenceId: oauthClient.referenceId })
|
|
179
|
+
.from(oauthClient)
|
|
180
|
+
.where(eq(oauthClient.id, id))
|
|
181
|
+
.limit(1);
|
|
182
|
+
const c = rows[0];
|
|
183
|
+
if (!c) return err("not_found");
|
|
184
|
+
if (c.referenceId?.startsWith("managed:")) return err("managed_client");
|
|
185
|
+
await inst.auth.api.deleteOAuthClient({
|
|
186
|
+
headers: cookieHeaders(cookie),
|
|
187
|
+
body: { client_id: c.clientId },
|
|
188
|
+
});
|
|
189
|
+
return { ok: true };
|
|
190
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avatar operations over the injected `GuestlistBlobStore`.
|
|
3
|
+
*
|
|
4
|
+
* Mutations (register/confirm/remove) are WorkerEntrypoint methods — the
|
|
5
|
+
* app fronts them with its own server fn / route and forwards the user's
|
|
6
|
+
* Cookie header. The only HTTP piece is the public read broker
|
|
7
|
+
* `/u/avatar/:refId` (a 302 to a short-lived presigned GET), because
|
|
8
|
+
* browsers hit it directly from `<img src>`.
|
|
9
|
+
*
|
|
10
|
+
* Flow: register → browser PUTs bytes straight to storage → confirm
|
|
11
|
+
* (finalize + write URL onto user.image via BA + release prior ref).
|
|
12
|
+
* Guestlist never streams bytes.
|
|
13
|
+
*/
|
|
14
|
+
import { executionContext } from "@somewhatintelligent/kit/execution-context";
|
|
15
|
+
|
|
16
|
+
import type { GuestlistInstances } from "../instances";
|
|
17
|
+
import type { GuestlistEnv } from "../env";
|
|
18
|
+
import { err, cookieHeaders, type Authed } from "../rpc";
|
|
19
|
+
|
|
20
|
+
// Single source of truth for the blob referenceId shape.
|
|
21
|
+
const AVATAR_REFID_RE = /^[A-Za-z0-9_-]{8,128}$/;
|
|
22
|
+
const AVATAR_HASH_RE = /^[a-f0-9]{64}$/;
|
|
23
|
+
export const AVATAR_MAX_BYTES = 8 * 1024 * 1024;
|
|
24
|
+
export const AVATAR_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
|
|
25
|
+
export type AvatarContentType = (typeof AVATAR_CONTENT_TYPES)[number];
|
|
26
|
+
|
|
27
|
+
// Match the avatar URL's path shape only — host varies across environments
|
|
28
|
+
// (worktree dev origins differ from production), so locking the regex to a
|
|
29
|
+
// single host would break dev. `formatAvatarUrl` produces; this parses back.
|
|
30
|
+
const AVATAR_PATH_RE = /^\/u\/avatar\/([A-Za-z0-9_-]{8,128})$/;
|
|
31
|
+
|
|
32
|
+
export function formatAvatarUrl(env: GuestlistEnv, refId: string): string {
|
|
33
|
+
return `${env.BETTER_AUTH_URL}/u/avatar/${refId}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseAvatarRefId(image: string | null | undefined): string | null {
|
|
37
|
+
if (!image) return null;
|
|
38
|
+
let path: string;
|
|
39
|
+
try {
|
|
40
|
+
path = new URL(image).pathname;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const match = AVATAR_PATH_RE.exec(path);
|
|
45
|
+
return match ? (match[1] ?? null) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fire-and-forget release of a blob reference. Hands the promise to CF's
|
|
50
|
+
* ExecutionContext so it survives past the response without blocking the
|
|
51
|
+
* caller; swallowed on failure (the store's own GC is the backstop).
|
|
52
|
+
*/
|
|
53
|
+
function deferRelease(inst: GuestlistInstances, refId: string): void {
|
|
54
|
+
if (!inst.blobs) return;
|
|
55
|
+
const cleanup = inst.blobs.release(refId).catch(() => {});
|
|
56
|
+
executionContext.getStore()?.waitUntil(cleanup);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function registerAvatarUpload(
|
|
60
|
+
inst: GuestlistInstances,
|
|
61
|
+
authed: Authed,
|
|
62
|
+
input: { hash: string; size: number; contentType: AvatarContentType },
|
|
63
|
+
) {
|
|
64
|
+
if (!inst.blobs) return err("avatars_disabled");
|
|
65
|
+
if (!AVATAR_HASH_RE.test(input.hash)) return err("validation", "hash must be sha-256 hex");
|
|
66
|
+
if (!Number.isInteger(input.size) || input.size < 1 || input.size > AVATAR_MAX_BYTES) {
|
|
67
|
+
return err("validation", `size must be 1..${AVATAR_MAX_BYTES}`);
|
|
68
|
+
}
|
|
69
|
+
if (!AVATAR_CONTENT_TYPES.includes(input.contentType)) {
|
|
70
|
+
return err("validation", "unsupported content type");
|
|
71
|
+
}
|
|
72
|
+
const result = await inst.blobs.register({
|
|
73
|
+
hash: input.hash,
|
|
74
|
+
size: input.size,
|
|
75
|
+
contentType: input.contentType,
|
|
76
|
+
ownerUserId: authed.user.id,
|
|
77
|
+
});
|
|
78
|
+
// size_exceeds_limit / invalid_hash / size_mismatch — caller's fault.
|
|
79
|
+
if (!result.ok) return err(result.error);
|
|
80
|
+
return { ok: true as const, referenceId: result.referenceId, upload: result.upload };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function confirmAvatar(
|
|
84
|
+
inst: GuestlistInstances,
|
|
85
|
+
env: GuestlistEnv,
|
|
86
|
+
authed: Authed,
|
|
87
|
+
cookie: string,
|
|
88
|
+
input: { referenceId: string },
|
|
89
|
+
) {
|
|
90
|
+
if (!inst.blobs) return err("avatars_disabled");
|
|
91
|
+
if (!AVATAR_REFID_RE.test(input.referenceId)) return err("validation", "bad referenceId");
|
|
92
|
+
// Ownership check: a user can't confirm a ref registered for someone else.
|
|
93
|
+
const ref = await inst.blobs.getReference(input.referenceId);
|
|
94
|
+
if (!ref) return err("not_found");
|
|
95
|
+
if (ref.ownerUserId !== authed.user.id) return err("forbidden");
|
|
96
|
+
if (ref.state === "pending") {
|
|
97
|
+
const fin = await inst.blobs.finalize(input.referenceId);
|
|
98
|
+
if (!fin.ok) return err("finalize_failed", fin.error);
|
|
99
|
+
} else if (ref.state === "deleted") {
|
|
100
|
+
return err("deleted");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const newImage = formatAvatarUrl(env, input.referenceId);
|
|
104
|
+
|
|
105
|
+
let baHeaders: Headers;
|
|
106
|
+
try {
|
|
107
|
+
// `returnHeaders: true` exposes BA's session-cache refresh cookies so
|
|
108
|
+
// the caller can forward them to the browser; without this the JWT
|
|
109
|
+
// cache sticks around for its 5-min TTL with the old user.image.
|
|
110
|
+
({ headers: baHeaders } = await inst.auth.api.updateUser({
|
|
111
|
+
headers: cookieHeaders(cookie),
|
|
112
|
+
body: { image: newImage },
|
|
113
|
+
returnHeaders: true,
|
|
114
|
+
}));
|
|
115
|
+
} catch (e) {
|
|
116
|
+
// Roll back the orphaned reference so refcount stays correct.
|
|
117
|
+
deferRelease(inst, input.referenceId);
|
|
118
|
+
return err("persist_failed", e instanceof Error ? e.message : String(e));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const oldRefId = parseAvatarRefId(authed.user.image);
|
|
122
|
+
if (oldRefId && oldRefId !== input.referenceId) deferRelease(inst, oldRefId);
|
|
123
|
+
return { ok: true as const, image: newImage, setCookies: baHeaders.getSetCookie() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function removeAvatar(
|
|
127
|
+
inst: GuestlistInstances,
|
|
128
|
+
authed: Authed,
|
|
129
|
+
cookie: string,
|
|
130
|
+
) {
|
|
131
|
+
const { headers: baHeaders } = await inst.auth.api.updateUser({
|
|
132
|
+
headers: cookieHeaders(cookie),
|
|
133
|
+
body: { image: null },
|
|
134
|
+
returnHeaders: true,
|
|
135
|
+
});
|
|
136
|
+
const oldRefId = parseAvatarRefId(authed.user.image);
|
|
137
|
+
if (oldRefId) deferRelease(inst, oldRefId);
|
|
138
|
+
return { ok: true as const, image: null, setCookies: baHeaders.getSetCookie() };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Public read broker backing `GET|HEAD /u/avatar/:refId` — the one avatar
|
|
143
|
+
* surface that stays HTTP (browsers hit it from `<img>`). Returns a 302 to
|
|
144
|
+
* a short-lived presigned GET; edge-cached for 5 min, shorter than the
|
|
145
|
+
* 10-min presign TTL so a cache hit can never return an expired URL.
|
|
146
|
+
*/
|
|
147
|
+
export async function avatarReadRedirect(
|
|
148
|
+
inst: GuestlistInstances,
|
|
149
|
+
refId: string,
|
|
150
|
+
): Promise<Response> {
|
|
151
|
+
if (!inst.blobs || !AVATAR_REFID_RE.test(refId)) return new Response(null, { status: 404 });
|
|
152
|
+
const url = await inst.blobs.readUrl(refId, { lifetimeSeconds: 600 });
|
|
153
|
+
if (!url) return new Response(null, { status: 404 });
|
|
154
|
+
return new Response(null, {
|
|
155
|
+
status: 302,
|
|
156
|
+
headers: {
|
|
157
|
+
location: url,
|
|
158
|
+
"cache-control": "public, max-age=300, immutable",
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|