@flaredev/cli 0.11.0 → 0.12.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.
Files changed (45) hide show
  1. package/dist/index.js +4777 -3372
  2. package/package.json +2 -2
  3. package/templates/app/.dev.vars.example +20 -0
  4. package/templates/app/app/dashboard/import-actions.ts +2 -2
  5. package/templates/app/app/dashboard/invitations/actions.ts +62 -0
  6. package/templates/app/app/dashboard/invitations/page.tsx +121 -0
  7. package/templates/app/app/invite/[token]/route.ts +28 -0
  8. package/templates/app/app/sign-up/page.tsx +61 -42
  9. package/templates/app/components/dashboard/fields/file-field.tsx +132 -12
  10. package/templates/app/components/dashboard/invite-form.tsx +86 -0
  11. package/templates/app/components/dashboard/record-detail.tsx +13 -2
  12. package/templates/app/components/dashboard/resource-form-page.tsx +9 -1
  13. package/templates/app/components/dashboard/resource-form.tsx +3 -1
  14. package/templates/app/components/dashboard/resource-table.tsx +7 -1
  15. package/templates/app/lib/api.ts +41 -3
  16. package/templates/app/lib/auth-config.ts +60 -38
  17. package/templates/app/lib/auth.ts +67 -2
  18. package/templates/app/lib/dashboard-nav.ts +5 -0
  19. package/templates/app/lib/invitations.ts +142 -0
  20. package/templates/app/lib/rate-limit.ts +71 -0
  21. package/templates/app/lib/resource/handlers.ts +9 -2
  22. package/templates/app/lib/resource/http.ts +24 -13
  23. package/templates/app/lib/resource/store.ts +79 -27
  24. package/templates/app/lib/storage/cloudinary.ts +146 -0
  25. package/templates/app/lib/storage/uploadthing.ts +122 -0
  26. package/templates/app/lib/storage.ts +72 -6
  27. package/templates/app/lib/user-titles.ts +52 -0
  28. package/templates/app/worker/index.ts +4 -1
  29. package/templates/app/wrangler.jsonc +11 -0
  30. package/templates/locks/cloudflare/package-lock.json +570 -690
  31. package/templates/locks/cloudflare/pnpm-lock.yaml +1061 -1091
  32. package/templates/locks/cloudflare/yarn.lock +363 -381
  33. package/templates/locks/next/package-lock.json +283 -243
  34. package/templates/locks/next/pnpm-lock.yaml +890 -870
  35. package/templates/locks/next/yarn.lock +338 -295
  36. package/templates/next/README.md +35 -3
  37. package/templates/next/lib/auth.ts +65 -2
  38. package/templates/next/lib/dashboard.ts +3 -1
  39. package/templates/next/lib/invitations.ts +119 -0
  40. package/templates/next/lib/rate-limit.ts +83 -0
  41. package/templates/next/lib/resource/prisma-rows.ts +52 -9
  42. package/templates/next/lib/storage/disk.ts +128 -0
  43. package/templates/next/lib/storage/r2.ts +86 -0
  44. package/templates/next/lib/storage.ts +83 -66
  45. package/templates/next/lib/user-titles.ts +49 -0
@@ -0,0 +1,71 @@
1
+ // This file is yours.
2
+ //
3
+ // Flare copies the code that turns a resource descriptor into a working endpoint into
4
+ // your app rather than hiding it in node_modules, for the same reason shadcn/ui copies
5
+ // a component: you cannot fix, read or reason about what you cannot see.
6
+ //
7
+ // `flare diff` shows how your copy differs from the version Flare ships, and
8
+ // `flare update` applies the parts you choose. Neither runs unless you ask.
9
+
10
+ /**
11
+ * Where a resource's rate limits are counted — the Cloudflare half.
12
+ *
13
+ * A descriptor says `rateLimit: { create: "10/min" }`; the generated route asks here
14
+ * whether this call is within it. The counters live in a Durable Object, one instance per
15
+ * resource and action holding a map of caller → count, so a limited call costs one object
16
+ * request rather than one per caller.
17
+ *
18
+ * **If the binding is missing, nothing is limited.** Say so loudly once rather than
19
+ * failing every request: an app that will not serve anything is worse than one that logs
20
+ * that a limit is not being enforced, and `flare doctor` reports it as a problem. The
21
+ * binding is in wrangler.jsonc as FLARE_RATE_LIMIT.
22
+ */
23
+ import { env } from "cloudflare:workers";
24
+ import { rateLimitKey, type Rate, type RateLimitDecision } from "@flaredev/core";
25
+ import type { RateLimitNamespace } from "@flaredev/core/rate-limit/server";
26
+
27
+ /** Allowed, with no count kept — what an unconfigured limiter answers. */
28
+ const UNLIMITED: RateLimitDecision = { allowed: true, remaining: Number.MAX_SAFE_INTEGER, retryAfter: 0 };
29
+
30
+ let warned = false;
31
+
32
+ /** The FLARE_RATE_LIMIT binding, or undefined when the app has none. */
33
+ function namespace(): RateLimitNamespace | undefined {
34
+ const binding = (env as { FLARE_RATE_LIMIT?: RateLimitNamespace } | undefined)?.FLARE_RATE_LIMIT;
35
+ if (binding) return binding;
36
+ if (!warned) {
37
+ warned = true;
38
+ console.warn(
39
+ "[flare] A resource declares a rateLimit, but there is no FLARE_RATE_LIMIT binding, so nothing is being limited. " +
40
+ "Add it to wrangler.jsonc (flare doctor will show you).",
41
+ );
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ /**
47
+ * Count one call and say whether it was within the limit.
48
+ *
49
+ * `caller` is the signed-in account's id where there is one and the client IP otherwise,
50
+ * so a limit is per person rather than per everyone behind one connection.
51
+ */
52
+ export async function checkRateLimit(
53
+ resource: string,
54
+ action: string,
55
+ caller: string,
56
+ rate: Rate,
57
+ ): Promise<RateLimitDecision> {
58
+ const binding = namespace();
59
+ if (!binding) return UNLIMITED;
60
+ try {
61
+ // One object per resource and action; the caller is a key inside it.
62
+ const stub = binding.get(binding.idFromName(`${resource}:${action}`));
63
+ return await stub.hit(rateLimitKey(resource, action, caller), rate);
64
+ } catch (error) {
65
+ // A counter that is unreachable must not take the endpoint down with it. Letting the
66
+ // call through is the safer failure: the alternative is an outage in the limiter
67
+ // becoming an outage in the app.
68
+ console.error("[flare] rate limit counter failed, allowing the request:", error);
69
+ return UNLIMITED;
70
+ }
71
+ }
@@ -13,6 +13,7 @@ import {
13
13
  crossOrigin,
14
14
  drain,
15
15
  failureResponse,
16
+ PROBLEM_CODES,
16
17
  problem,
17
18
  readJson,
18
19
  respond,
@@ -41,6 +42,12 @@ export interface ResourceHandlerOptions {
41
42
  authorize: Authorize;
42
43
  /** Who is making the request, for the descriptor's hooks. */
43
44
  currentUser?: ResourceStoreOptions["currentUser"];
45
+ /**
46
+ * Serve every row, whoever is asking. See {@link ResourceStoreOptions.system} — the
47
+ * store needs this or `currentUser`, and a route that genuinely has no session is the
48
+ * one case where it is the honest answer.
49
+ */
50
+ system?: ResourceStoreOptions["system"];
44
51
  /** Called after a write succeeds, for cache invalidation. */
45
52
  onChange?: (event: ChangeEvent) => void | Promise<void>;
46
53
  }
@@ -52,7 +59,7 @@ export function createResourceHandlers(options: ResourceHandlerOptions) {
52
59
 
53
60
  /** The cross-origin guard and the JSON read, in the order a write needs them. */
54
61
  const readBody = async (request: Request): Promise<{ body: unknown } | { response: Response }> =>
55
- crossOrigin(request) ? { response: problem(403, "Cross-origin request blocked.") } : readJson(request);
62
+ crossOrigin(request) ? { response: problem(403, "Cross-origin request blocked.", PROBLEM_CODES.crossOriginBlocked) } : readJson(request);
56
63
 
57
64
  const guard = async (context: AuthorizeContext) => (await authorize(context)) ?? undefined;
58
65
 
@@ -99,7 +106,7 @@ export function createResourceHandlers(options: ResourceHandlerOptions) {
99
106
  item(request, context, "update", (id) => withBody(request, async (body) => respond(await store.replace(id, body)))),
100
107
  DELETE: (request: Request, context: RouteContext) =>
101
108
  item(request, context, "delete", async (id) => {
102
- if (crossOrigin(request)) return problem(403, "Cross-origin request blocked.");
109
+ if (crossOrigin(request)) return problem(403, "Cross-origin request blocked.", PROBLEM_CODES.crossOriginBlocked);
103
110
  const result = await store.delete(id);
104
111
  return result.ok ? new Response(null, { status: 204 }) : failureResponse(result);
105
112
  }),
@@ -9,6 +9,11 @@
9
9
  // `flare update` applies the parts you choose. Neither runs unless you ask.
10
10
 
11
11
  import type { Resource } from "@flaredev/core";
12
+ import { PROBLEM_CODES, problemResponse, type ProblemCode, type ProblemIssue } from "@flaredev/core";
13
+
14
+ // Re-exported so a generated route reads every error code from `@/lib/resource` like
15
+ // everything else it uses. The route never imports the framework directly.
16
+ export { ISSUE_CODES, PROBLEM_CODES, type IssueCode, type ProblemCode, type ProblemIssue } from "@flaredev/core";
12
17
  import type { Failure, ResourceAction, Result } from "./store";
13
18
 
14
19
  export interface AuthorizeContext {
@@ -24,22 +29,26 @@ export type Authorize = (context: AuthorizeContext) => Response | void | Promise
24
29
  /** The context Next.js and vinext hand a route under `[id]`. */
25
30
  export type RouteContext = { params: Promise<{ id: string }> };
26
31
 
27
- /** A JSON error response. */
28
- export const problem = (status: number, error: string, extra: object = {}) => Response.json({ error, ...extra }, { status });
32
+ /**
33
+ * A JSON error response, in the one shape the whole API uses:
34
+ *
35
+ * { "error": "…", "code": "unique_violation", "issues": [{ "path": "…", "message": "…", "code": "unique" }] }
36
+ *
37
+ * `error` is the sentence a person reads and its wording may change; `code` is for a
38
+ * program and does not. `issues` is there whenever the answer is about fields. See
39
+ * PROBLEM_CODES for the codes, and the API reference for the contract.
40
+ */
41
+ export const problem = (status: number, error: string, code: ProblemCode, issues?: ProblemIssue[]) =>
42
+ problemResponse(status, error, code, issues);
29
43
 
30
44
  /**
31
45
  * A store failure as HTTP.
32
46
  *
33
- * The status is decided in store.ts, where the reason is known: 422 with field issues
34
- * for validation, 409 with the column for a unique conflict, 404 for a missing row.
47
+ * The status and the code are decided in store.ts, where the reason is known: 422 with
48
+ * field issues for validation, 409 naming the column for a unique conflict, 404 for a
49
+ * missing row.
35
50
  */
36
- export function failureResponse(failure: Failure): Response {
37
- if (failure.queryIssues) return problem(failure.status, failure.error, { issues: failure.queryIssues });
38
- const extra: Record<string, unknown> = {};
39
- if (failure.issues) extra.issues = failure.issues;
40
- if (failure.field) extra.field = failure.field;
41
- return problem(failure.status, failure.error, extra);
42
- }
51
+ export const failureResponse = (failure: Failure): Response => problemResponse(failure.status, failure.error, failure.code, failure.issues);
43
52
 
44
53
  /** A store result as HTTP: the data, or the failure above. */
45
54
  export const respond = <T>(result: Result<T>, init?: ResponseInit) => (result.ok ? Response.json(result.data, init) : failureResponse(result));
@@ -80,11 +89,13 @@ export const crossOrigin = (request: Request) => {
80
89
  */
81
90
  export async function readJson(request: Request): Promise<{ body: unknown } | { response: Response }> {
82
91
  const type = request.headers.get("content-type") ?? "";
83
- if (!/^application\/json\b/i.test(type)) return { response: problem(415, "Send JSON with Content-Type: application/json.") };
92
+ if (!/^application\/json\b/i.test(type)) {
93
+ return { response: problem(415, "Send JSON with Content-Type: application/json.", PROBLEM_CODES.unsupportedMediaType) };
94
+ }
84
95
  try {
85
96
  return { body: await request.json() };
86
97
  } catch {
87
- return { response: problem(400, "Request body isn't valid JSON.") };
98
+ return { response: problem(400, "Request body isn't valid JSON.", PROBLEM_CODES.invalidJson) };
88
99
  }
89
100
  }
90
101
 
@@ -9,6 +9,7 @@
9
9
  // `flare update` applies the parts you choose. Neither runs unless you ask.
10
10
 
11
11
  import { columnName, conditionHolds, fromMinorUnits, ownershipFilter, storedFields, toMinorUnits, type Policy, type Resource } from "@flaredev/core";
12
+ import { ISSUE_CODES, PROBLEM_CODES, type ProblemCode, type ProblemIssue } from "@flaredev/core";
12
13
  import { createValidators } from "@flaredev/core";
13
14
  import { encodeCursor, isSearchable, parseListQuery, type QueryIssue } from "./query";
14
15
  import type { ResourceRows, Row } from "./rows";
@@ -24,14 +25,19 @@ export interface FieldIssue {
24
25
 
25
26
  export type Failure = {
26
27
  ok: false;
27
- status: 400 | 403 | 404 | 409 | 422;
28
+ status: 400 | 401 | 403 | 404 | 409 | 422;
29
+ /** The sentence a person reads. Its wording is not part of the contract. */
28
30
  error: string;
29
- /** Validation issues per field. */
30
- issues?: FieldIssue[];
31
- /** Invalid query parameters. */
32
- queryIssues?: QueryIssue[];
33
- /** Field that caused a conflict (e.g. a unique violation). */
34
- field?: string;
31
+ /** Why, as a value a program can switch on. See PROBLEM_CODES. */
32
+ code: ProblemCode;
33
+ /**
34
+ * Which fields, whenever the answer is about fields at all: one issue for a unique
35
+ * conflict, one per failed rule for a validation error, one per bad parameter for a
36
+ * query. There used to be three ways of saying this — `issues` with a `path`, `issues`
37
+ * with a `param`, and a bare `field` — and a client had to handle whichever one the
38
+ * guard that refused it happened to use.
39
+ */
40
+ issues?: ProblemIssue[];
35
41
  };
36
42
 
37
43
  export type Result<T> = { ok: true; data: T } | Failure;
@@ -105,10 +111,21 @@ export interface ResourceStoreOptions {
105
111
  * perform the action at all is decided before the store is reached, by `authorize` on
106
112
  * the API and by the dashboard's own check.
107
113
  *
108
- * Ownership applies only when `currentUser` is also given. A store built without one
109
- * has no session to scope to, which is how a seed or a script writes rows for anybody.
114
+ * Ownership applies unless `system` says otherwise.
110
115
  */
111
116
  policy?: Policy | null;
117
+ /**
118
+ * Run unconfined, on purpose.
119
+ *
120
+ * A store with no session cannot scope anything to an owner, which is right for a
121
+ * seed, a cron job or a migration script and wrong for anything serving a request.
122
+ * The two used to look identical — leave `currentUser` off and an `own` policy simply
123
+ * stopped applying — so the dangerous case was the one you got by forgetting.
124
+ *
125
+ * Now it has to be said. Pass `currentUser`, or pass `system: true`; a store with
126
+ * neither throws where it is built rather than returning everybody's rows at runtime.
127
+ */
128
+ system?: true;
112
129
  /**
113
130
  * Called after a write succeeds, for cache invalidation. Runs before the
114
131
  * operation returns, so a caller that revalidates tags can't hand back a
@@ -129,6 +146,16 @@ export function createResourceStore(options: ResourceStoreOptions) {
129
146
  const validators = createValidators(resource);
130
147
  const fields = storedFields(resource);
131
148
 
149
+ // Before anything else, because the failure it prevents is silent. A store with no
150
+ // session and no `system` cannot confine an `own` policy, so it would hand back every
151
+ // row — correct for a seed, a hole in a server action somebody wrote in a hurry.
152
+ if (!options.currentUser && !options.system) {
153
+ throw new Error(
154
+ `Resource "${resource.name}": a store needs \`currentUser\`, or \`system: true\` to say it is meant to run unconfined. ` +
155
+ `Without one of them an \`own\` policy cannot be applied, and every row would be returned.`,
156
+ );
157
+ }
158
+
132
159
  // Drizzle can say up front whether the table matches the descriptor; a Prisma client
133
160
  // can't be asked the same question without a round trip, so that check stays here.
134
161
  const columns = "columns" in rows ? (rows.columns as Record<string, { name: string }>) : undefined;
@@ -146,7 +173,13 @@ export function createResourceStore(options: ResourceStoreOptions) {
146
173
  ? Object.entries(columns).find(([, column]) => column.name === name)?.[0]
147
174
  : ["id", "createdAt", "updatedAt", ...fields.map(([key]) => key)].find((key) => columnName(key) === name || key === name);
148
175
 
149
- const fail = (status: Failure["status"], error: string, extra: Partial<Failure> = {}): Failure => ({ ok: false, status, error, ...extra });
176
+ const fail = (status: Failure["status"], error: string, code: ProblemCode, issues?: ProblemIssue[]): Failure => ({
177
+ ok: false,
178
+ status,
179
+ error,
180
+ code,
181
+ ...(issues?.length ? { issues } : {}),
182
+ });
150
183
 
151
184
  /** The same wording whichever database refused the write. */
152
185
  function constraintFailure(error: unknown, action: ResourceAction): Failure | undefined {
@@ -155,14 +188,17 @@ export function createResourceStore(options: ResourceStoreOptions) {
155
188
  if (hit.kind === "unique") {
156
189
  const key = hit.column ? keyForColumn(hit.column) : undefined;
157
190
  const label = key ? (resource.fields[key]?.label ?? key) : "Value";
158
- return fail(409, `${label} is already taken.`, key ? { field: key } : {});
191
+ const error = `${label} is already taken.`;
192
+ // The column goes in as an issue, so a form reads one shape whether a rule or the
193
+ // database refused the value.
194
+ return fail(409, error, PROBLEM_CODES.uniqueViolation, key ? [{ path: key, message: error, code: ISSUE_CODES.unique }] : undefined);
159
195
  }
160
196
  if (hit.kind === "foreignKey") {
161
197
  return action === "delete"
162
- ? fail(409, `This ${resource.label.toLowerCase()} is still referenced by other records.`)
163
- : fail(422, "A related record doesn't exist.");
198
+ ? fail(409, `This ${resource.label.toLowerCase()} is still referenced by other records.`, PROBLEM_CODES.stillReferenced)
199
+ : fail(422, "A related record doesn't exist.", PROBLEM_CODES.relatedRecordMissing);
164
200
  }
165
- return fail(422, "A value is outside its allowed options.");
201
+ return fail(422, "A value is outside its allowed options.", PROBLEM_CODES.valueNotAllowed);
166
202
  }
167
203
 
168
204
  async function guarded<T>(action: ResourceAction, work: () => Promise<Result<T>>): Promise<Result<T>> {
@@ -196,9 +232,14 @@ export function createResourceStore(options: ResourceStoreOptions) {
196
232
  }
197
233
 
198
234
  const invalid = (issues: { path: PropertyKey[]; message: string }[]) =>
199
- fail(422, "Validation failed.", { issues: issues.map((issue) => ({ path: issue.path.map(String).join("."), message: issue.message })) });
235
+ fail(
236
+ 422,
237
+ "Validation failed.",
238
+ PROBLEM_CODES.validationFailed,
239
+ issues.map((issue) => ({ path: issue.path.map(String).join("."), message: issue.message, code: ISSUE_CODES.invalid })),
240
+ );
200
241
 
201
- const notFound = () => fail(404, `${resource.label} not found.`);
242
+ const notFound = () => fail(404, `${resource.label} not found.`, PROBLEM_CODES.notFound);
202
243
 
203
244
  /**
204
245
  * Money fields, and the currency each one counts in.
@@ -305,15 +346,17 @@ export function createResourceStore(options: ResourceStoreOptions) {
305
346
  /**
306
347
  * The owner this call is confined to: `null` to touch every row, or a Failure.
307
348
  *
308
- * Only when a `currentUser` was given — see the option. A policy that restricts rows
309
- * and a request with no session is a 403, not an unrestricted query: the API refuses
310
- * that earlier, but the dashboard and server actions come through here too, and a
311
- * missing session must never widen what a query returns.
349
+ * Unconfined only when the store said `system` — see the option. A policy that
350
+ * restricts rows and a request with no session is a 403, not an unrestricted query:
351
+ * the API refuses that earlier, but the dashboard and server actions come through here
352
+ * too, and a missing session must never widen what a query returns.
312
353
  */
313
354
  async function confine(): Promise<{ field: string; value: string } | Failure | null> {
314
- if (!ownership || !options.currentUser) return null;
315
- const user = await options.currentUser();
316
- if (!user) return fail(403, "Sign in required.");
355
+ // `system` is the only way past an ownership policy, and the constructor has already
356
+ // insisted that a store without one has a `currentUser` to confine to.
357
+ if (!ownership || options.system) return null;
358
+ const user = await options.currentUser!();
359
+ if (!user) return fail(401, "Sign in required.", PROBLEM_CODES.unauthenticated);
317
360
  return ownershipFilter(options.policy, user);
318
361
  }
319
362
 
@@ -331,7 +374,16 @@ export function createResourceStore(options: ResourceStoreOptions) {
331
374
  /** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
332
375
  async list(params: URLSearchParams, options: { maxPerPage?: number } = {}): Promise<Result<ListResult>> {
333
376
  const parsed = parseListQuery(resource, params, options);
334
- if ("issues" in parsed) return fail(400, "Invalid query.", { queryIssues: parsed.issues });
377
+ // A bad parameter is an issue like any other; it used to arrive under `param`
378
+ // rather than `path`, which was the fourth shape a client had to know about.
379
+ if ("issues" in parsed) {
380
+ return fail(
381
+ 400,
382
+ "Invalid query.",
383
+ PROBLEM_CODES.invalidQuery,
384
+ parsed.issues.map((issue) => ({ path: issue.param, message: issue.message, code: ISSUE_CODES.invalidParameter })),
385
+ );
386
+ }
335
387
  const { page, perPage, sort, q, cursor } = parsed.query;
336
388
 
337
389
  const limit = await confine();
@@ -502,9 +554,9 @@ export function createResourceStore(options: ResourceStoreOptions) {
502
554
  const sent = (supplied as Record<string, unknown>)[limit.field];
503
555
  if (sent !== limit.value) {
504
556
  const label = resource.fields[limit.field]?.label ?? limit.field;
505
- return fail(422, `You can't change which user this ${resource.label.toLowerCase()} belongs to.`, {
506
- issues: [{ path: limit.field, message: `${label} can't be changed.` }],
507
- });
557
+ return fail(422, `You can't change which user this ${resource.label.toLowerCase()} belongs to.`, PROBLEM_CODES.validationFailed, [
558
+ { path: limit.field, message: `${label} can't be changed.`, code: ISSUE_CODES.immutable },
559
+ ]);
508
560
  }
509
561
  }
510
562
 
@@ -0,0 +1,146 @@
1
+ import type { StorageBucket, StorageObjectBody } from "@flaredev/core";
2
+
3
+ /**
4
+ * Files on Cloudinary.
5
+ *
6
+ * Cloudinary is a CDN with an image pipeline attached, so this bucket serves reads from
7
+ * `res.cloudinary.com` rather than streaming them back through the app — see
8
+ * `publicUrl` below, and read what it says about privacy before choosing this one.
9
+ *
10
+ * Plain `fetch` and Web Crypto, no SDK: the signing is a sorted query string and a
11
+ * hash, and the upload is a multipart POST.
12
+ */
13
+
14
+ export interface CloudinaryOptions {
15
+ cloudName: string;
16
+ apiKey: string;
17
+ apiSecret: string;
18
+ /**
19
+ * Serve reads straight from Cloudinary's CDN. Default true, because it is the reason
20
+ * to use Cloudinary. See {@link StorageBucket.publicUrl}: those files are readable by
21
+ * anyone with the link. Set it false to keep every read signed and proxied, and
22
+ * Cloudinary becomes storage with a good image pipeline you reach for deliberately.
23
+ */
24
+ publicDelivery?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Cloudinary stores an asset under one of three resource types and the type is part of
29
+ * its URL, so upload and read have to agree on which. The key's extension decides, and
30
+ * it decides the same way in both directions — `createObjectKey` keeps the extension
31
+ * from the uploaded filename, so in practice this is the type the file really is.
32
+ *
33
+ * A key with no extension is `raw`, which serves the bytes back unchanged. That is the
34
+ * safe answer rather than the clever one.
35
+ */
36
+ const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|ico|tiff?|heic|heif)$/i;
37
+ const VIDEO = /\.(mp4|webm|mov|m4v|avi|mkv|ogv|mp3|m4a|wav|ogg|aac|flac)$/i;
38
+
39
+ export function resourceType(key: string): "image" | "video" | "raw" {
40
+ if (IMAGE.test(key)) return "image";
41
+ // Cloudinary files audio under "video"; it is one pipeline.
42
+ if (VIDEO.test(key)) return "video";
43
+ return "raw";
44
+ }
45
+
46
+ /** Hex SHA-1, which is what Cloudinary's signatures use by default. */
47
+ async function sha1Hex(input: string): Promise<string> {
48
+ const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(input));
49
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
50
+ }
51
+
52
+ /**
53
+ * Cloudinary's signature: every signed parameter as `name=value`, sorted by name,
54
+ * joined with `&`, the API secret appended with no separator, then SHA-1.
55
+ *
56
+ * `file`, `api_key`, `resource_type` and `cloud_name` are never signed.
57
+ */
58
+ export async function sign(params: Record<string, string>, apiSecret: string): Promise<string> {
59
+ const toSign = Object.keys(params)
60
+ .sort()
61
+ .map((name) => `${name}=${params[name]}`)
62
+ .join("&");
63
+ return sha1Hex(toSign + apiSecret);
64
+ }
65
+
66
+ export function cloudinaryBucket(options: CloudinaryOptions): StorageBucket {
67
+ const { cloudName, apiKey, apiSecret } = options;
68
+ const publicDelivery = options.publicDelivery ?? true;
69
+ const api = (type: string, action: string) => `https://api.cloudinary.com/v1_1/${cloudName}/${type}/${action}`;
70
+
71
+ /**
72
+ * The key, as a Cloudinary public id.
73
+ *
74
+ * An `image` or `video` public id carries no extension — Cloudinary appends the
75
+ * format, and leaving it on produces `photo.png.png`. A `raw` asset keeps it, because
76
+ * for raw the public id is the whole filename.
77
+ */
78
+ const publicId = (key: string) => (resourceType(key) === "raw" ? key : key.replace(/\.[^./]+$/, ""));
79
+
80
+ /** The delivery URL for a key, which is public whether or not it is handed out. */
81
+ const deliveryUrl = (key: string) =>
82
+ `https://res.cloudinary.com/${cloudName}/${resourceType(key)}/upload/${publicId(key)
83
+ .split("/")
84
+ .map(encodeURIComponent)
85
+ .join("/")}`;
86
+
87
+ return {
88
+ async get(key: string): Promise<StorageObjectBody | null> {
89
+ const response = await fetch(deliveryUrl(key));
90
+ if (!response.ok || !response.body) return null;
91
+ return {
92
+ body: response.body,
93
+ size: Number(response.headers.get("content-length") ?? 0),
94
+ httpEtag: response.headers.get("etag") ?? "",
95
+ httpMetadata: { contentType: response.headers.get("content-type") ?? undefined },
96
+ };
97
+ },
98
+
99
+ async put(key, value, putOptions) {
100
+ const timestamp = String(Math.floor(Date.now() / 1000));
101
+ // `overwrite` and `invalidate` so re-uploading a key replaces what the CDN serves
102
+ // rather than leaving the old bytes cached at the edge under the same URL.
103
+ const signed: Record<string, string> = {
104
+ public_id: publicId(key),
105
+ timestamp,
106
+ overwrite: "true",
107
+ invalidate: "true",
108
+ };
109
+
110
+ const form = new FormData();
111
+ const body = value === null ? new ArrayBuffer(0) : await new Response(value as BodyInit).arrayBuffer();
112
+ const contentType = putOptions?.httpMetadata?.contentType;
113
+ form.append("file", new Blob([body], contentType ? { type: contentType } : undefined), key.split("/").pop() ?? "file");
114
+ form.append("api_key", apiKey);
115
+ form.append("signature", await sign(signed, apiSecret));
116
+ for (const [name, entry] of Object.entries(signed)) form.append(name, entry);
117
+
118
+ const response = await fetch(api(resourceType(key), "upload"), { method: "POST", body: form });
119
+ if (!response.ok) {
120
+ const detail = await response.text().catch(() => "");
121
+ throw new Error(`Cloudinary upload failed for "${key}": ${response.status} ${detail.slice(0, 300)}`);
122
+ }
123
+ },
124
+
125
+ async delete(keys) {
126
+ for (const key of [keys].flat()) {
127
+ const timestamp = String(Math.floor(Date.now() / 1000));
128
+ const signed = { public_id: publicId(key), timestamp, invalidate: "true" };
129
+ const form = new FormData();
130
+ form.append("api_key", apiKey);
131
+ form.append("signature", await sign(signed, apiSecret));
132
+ for (const [name, entry] of Object.entries(signed)) form.append(name, entry);
133
+
134
+ const response = await fetch(api(resourceType(key), "destroy"), { method: "POST", body: form });
135
+ // `destroy` answers 200 with result "not found" for a key that was never there,
136
+ // which is the outcome asked for. Only a transport-level failure is an error.
137
+ if (!response.ok) {
138
+ const detail = await response.text().catch(() => "");
139
+ throw new Error(`Cloudinary delete failed for "${key}": ${response.status} ${detail.slice(0, 300)}`);
140
+ }
141
+ }
142
+ },
143
+
144
+ publicUrl: publicDelivery ? (key: string) => deliveryUrl(key) : undefined,
145
+ };
146
+ }
@@ -0,0 +1,122 @@
1
+ import type { StorageBucket, StorageObjectBody } from "@flaredev/core";
2
+
3
+ /**
4
+ * Files on UploadThing.
5
+ *
6
+ * One wrinkle shapes this whole file: UploadThing mints its own file key on upload, and
7
+ * a bucket is asked to store bytes under a key it was *given*. The two are reconciled
8
+ * with `customId`, which UploadThing accepts at upload, addresses in a delivery URL,
9
+ * and filters by on delete. So Flare's key stays Flare's key and never has to be
10
+ * stored anywhere, which is what would otherwise be needed.
11
+ *
12
+ * A customId is one path segment, so the slashes in `uploads/2026/09/…` are encoded on
13
+ * the way in. {@link customIdFor} is the only place that knows the encoding and both
14
+ * directions go through it.
15
+ *
16
+ * Requires the `uploadthing` package:
17
+ *
18
+ * pnpm add uploadthing
19
+ */
20
+
21
+ export interface UploadThingOptions {
22
+ /** From the UploadThing dashboard. Defaults to `UPLOADTHING_TOKEN`. */
23
+ token?: string;
24
+ /**
25
+ * The app id, which is the subdomain files are served from. Defaults to
26
+ * `UPLOADTHING_APP_ID`.
27
+ */
28
+ appId: string;
29
+ /**
30
+ * Serve reads from UploadThing's CDN. Default true, which is the reason to use it.
31
+ * See {@link StorageBucket.publicUrl}: those files are readable by anyone with the
32
+ * link. Set it false and reads are signed and proxied through this app instead.
33
+ */
34
+ publicDelivery?: boolean;
35
+ }
36
+
37
+ /**
38
+ * A Flare key as an UploadThing customId.
39
+ *
40
+ * Percent-encoding with `_` in place of `%`, because `%` in a customId would be decoded
41
+ * again by whatever reads the URL. Every character outside `[A-Za-z0-9.-]` becomes `_`
42
+ * and two hex digits — including `_` itself, which is what makes it reversible.
43
+ *
44
+ * Reversible matters more than it looks. A lossy encoding maps two different keys onto
45
+ * one id, and then an upload overwrites a file it has never heard of and a delete
46
+ * removes somebody else's. The first draft of this mapped `a/b.png` and `a__b.png` to
47
+ * the same id.
48
+ */
49
+ export function customIdFor(key: string): string {
50
+ return key.replace(/[^A-Za-z0-9.-]/g, (char) => `_${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
51
+ }
52
+
53
+ /**
54
+ * The part of UTApi this driver uses.
55
+ *
56
+ * Described here rather than imported, so an app that does not use this driver still
57
+ * typechecks without the package installed — which is every app that picked one of the
58
+ * other three.
59
+ */
60
+ interface UTApiLike {
61
+ uploadFiles(file: unknown): Promise<{ error: { message: string } | null }>;
62
+ deleteFiles(keys: string[], options: { keyType: "customId" }): Promise<unknown>;
63
+ }
64
+
65
+ /**
66
+ * The SDK, loaded only if this driver is actually used.
67
+ *
68
+ * The specifier is a variable so that neither TypeScript nor the bundler tries to
69
+ * resolve a package that an app using a different driver has no reason to install.
70
+ */
71
+ const SDK = "uploadthing/server";
72
+
73
+ async function api(token: string | undefined): Promise<UTApiLike> {
74
+ let module: { UTApi: new (options: Record<string, unknown>) => UTApiLike };
75
+ try {
76
+ module = (await import(SDK)) as never;
77
+ } catch {
78
+ throw new Error('STORAGE_DRIVER is "uploadthing" but the `uploadthing` package is not installed. Run: pnpm add uploadthing');
79
+ }
80
+ return new module.UTApi(token ? { token } : {});
81
+ }
82
+
83
+ export function uploadThingBucket(options: UploadThingOptions): StorageBucket {
84
+ const publicDelivery = options.publicDelivery ?? true;
85
+ const deliveryUrl = (key: string) => `https://${options.appId}.ufs.sh/f/${customIdFor(key)}`;
86
+
87
+ return {
88
+ async get(key: string): Promise<StorageObjectBody | null> {
89
+ const response = await fetch(deliveryUrl(key));
90
+ if (!response.ok || !response.body) return null;
91
+ return {
92
+ body: response.body,
93
+ size: Number(response.headers.get("content-length") ?? 0),
94
+ httpEtag: response.headers.get("etag") ?? "",
95
+ httpMetadata: { contentType: response.headers.get("content-type") ?? undefined },
96
+ };
97
+ },
98
+
99
+ async put(key, value, putOptions) {
100
+ const body = value === null ? new ArrayBuffer(0) : await new Response(value as BodyInit).arrayBuffer();
101
+ const contentType = putOptions?.httpMetadata?.contentType;
102
+ const name = key.split("/").pop() ?? "file";
103
+
104
+ // UTApi takes a Blob with a `name`, and reads `customId` off it. A global `File`
105
+ // is exactly that on Node 20+, which is the floor Flare builds on anyway.
106
+ const file = new File([body], name, contentType ? { type: contentType } : undefined) as File & { customId?: string };
107
+ file.customId = customIdFor(key);
108
+
109
+ const { error } = await (await api(options.token)).uploadFiles(file);
110
+ if (error) throw new Error(`UploadThing upload failed for "${key}": ${error.message}`);
111
+ },
112
+
113
+ async delete(keys) {
114
+ const ids = [keys].flat().map(customIdFor);
115
+ if (ids.length === 0) return;
116
+ // Deleting what was never there is not an error, which is the outcome asked for.
117
+ await (await api(options.token)).deleteFiles(ids, { keyType: "customId" });
118
+ },
119
+
120
+ publicUrl: publicDelivery ? (key: string) => deliveryUrl(key) : undefined,
121
+ };
122
+ }