@flaredev/cli 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +4858 -3395
- package/package.json +2 -2
- package/templates/app/.dev.vars.example +19 -0
- package/templates/app/app/dashboard/import-actions.ts +2 -2
- package/templates/app/app/dashboard/invitations/actions.ts +62 -0
- package/templates/app/app/dashboard/invitations/page.tsx +121 -0
- package/templates/app/app/invite/[token]/route.ts +28 -0
- package/templates/app/app/sign-up/page.tsx +61 -42
- package/templates/app/components/dashboard/fields/file-field.tsx +132 -12
- package/templates/app/components/dashboard/invite-form.tsx +86 -0
- package/templates/app/components/dashboard/record-detail.tsx +13 -2
- package/templates/app/components/dashboard/resource-form-page.tsx +9 -1
- package/templates/app/components/dashboard/resource-form.tsx +3 -1
- package/templates/app/components/dashboard/resource-table.tsx +7 -1
- package/templates/app/lib/api.ts +41 -3
- package/templates/app/lib/auth-config.ts +60 -38
- package/templates/app/lib/auth.ts +67 -2
- package/templates/app/lib/dashboard-nav.ts +5 -0
- package/templates/app/lib/invitations.ts +142 -0
- package/templates/app/lib/rate-limit.ts +71 -0
- package/templates/app/lib/resource/handlers.ts +9 -2
- package/templates/app/lib/resource/http.ts +24 -13
- package/templates/app/lib/resource/store.ts +79 -27
- package/templates/app/lib/storage/cloudinary.ts +146 -0
- package/templates/app/lib/storage/uploadthing.ts +160 -0
- package/templates/app/lib/storage.ts +72 -6
- package/templates/app/lib/user-titles.ts +52 -0
- package/templates/app/worker/index.ts +4 -1
- package/templates/app/wrangler.jsonc +11 -0
- package/templates/locks/cloudflare/package-lock.json +571 -691
- package/templates/locks/cloudflare/pnpm-lock.yaml +1065 -1095
- package/templates/locks/cloudflare/yarn.lock +363 -381
- package/templates/locks/next/package-lock.json +284 -243
- package/templates/locks/next/pnpm-lock.yaml +898 -878
- package/templates/locks/next/yarn.lock +338 -295
- package/templates/next/README.md +35 -3
- package/templates/next/lib/auth.ts +65 -2
- package/templates/next/lib/dashboard.ts +3 -1
- package/templates/next/lib/invitations.ts +119 -0
- package/templates/next/lib/rate-limit.ts +83 -0
- package/templates/next/lib/resource/prisma-rows.ts +52 -9
- package/templates/next/lib/storage/disk.ts +128 -0
- package/templates/next/lib/storage/r2.ts +86 -0
- package/templates/next/lib/storage.ts +83 -66
- 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
|
-
/**
|
|
28
|
-
|
|
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
|
|
34
|
-
* for validation, 409
|
|
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
|
|
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))
|
|
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
309
|
-
* and a request with no session is a 403, not an unrestricted query:
|
|
310
|
-
* that earlier, but the dashboard and server actions come through here
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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,160 @@
|
|
|
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.
|
|
26
|
+
*
|
|
27
|
+
* Almost never passed: it is already inside the token, and {@link appIdFromToken}
|
|
28
|
+
* reads it from there. Pass it only to serve from a different subdomain than the
|
|
29
|
+
* token names.
|
|
30
|
+
*/
|
|
31
|
+
appId?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Serve reads from UploadThing's CDN. Default true, which is the reason to use it.
|
|
34
|
+
* See {@link StorageBucket.publicUrl}: those files are readable by anyone with the
|
|
35
|
+
* link. Set it false and reads are signed and proxied through this app instead.
|
|
36
|
+
*/
|
|
37
|
+
publicDelivery?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A Flare key as an UploadThing customId.
|
|
42
|
+
*
|
|
43
|
+
* Percent-encoding with `_` in place of `%`, because `%` in a customId would be decoded
|
|
44
|
+
* again by whatever reads the URL. Every character outside `[A-Za-z0-9.-]` becomes `_`
|
|
45
|
+
* and two hex digits — including `_` itself, which is what makes it reversible.
|
|
46
|
+
*
|
|
47
|
+
* Reversible matters more than it looks. A lossy encoding maps two different keys onto
|
|
48
|
+
* one id, and then an upload overwrites a file it has never heard of and a delete
|
|
49
|
+
* removes somebody else's. The first draft of this mapped `a/b.png` and `a__b.png` to
|
|
50
|
+
* the same id.
|
|
51
|
+
*/
|
|
52
|
+
export function customIdFor(key: string): string {
|
|
53
|
+
return key.replace(/[^A-Za-z0-9.-]/g, (char) => `_${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The app id carried inside an UploadThing token.
|
|
58
|
+
*
|
|
59
|
+
* A v7 token is base64 JSON — `{ apiKey, appId, regions }` — so the id of the app it
|
|
60
|
+
* belongs to is already in your hands the moment you have the token. Asking for
|
|
61
|
+
* `UPLOADTHING_APP_ID` as well was asking for a value that cannot disagree with the
|
|
62
|
+
* token without being wrong, and the failure it produced was a quiet one: uploads
|
|
63
|
+
* succeeded and every read 404'd from a subdomain belonging to another app.
|
|
64
|
+
*
|
|
65
|
+
* Only the `appId` is read here. The apiKey stays in the token, which is what the SDK
|
|
66
|
+
* itself is given.
|
|
67
|
+
*/
|
|
68
|
+
export function appIdFromToken(token: string): string {
|
|
69
|
+
let decoded: string;
|
|
70
|
+
try {
|
|
71
|
+
decoded = typeof atob === "function" ? atob(token) : Buffer.from(token, "base64").toString("utf8");
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error("UPLOADTHING_TOKEN is not valid base64. Copy it again from https://uploadthing.com/dashboard.");
|
|
74
|
+
}
|
|
75
|
+
let parsed: unknown;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(decoded);
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error("UPLOADTHING_TOKEN does not decode to JSON. Copy it again from https://uploadthing.com/dashboard.");
|
|
80
|
+
}
|
|
81
|
+
const appId = (parsed as { appId?: unknown } | null)?.appId;
|
|
82
|
+
if (typeof appId !== "string" || appId === "") {
|
|
83
|
+
throw new Error("UPLOADTHING_TOKEN has no appId in it. Copy it again from https://uploadthing.com/dashboard.");
|
|
84
|
+
}
|
|
85
|
+
return appId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The part of UTApi this driver uses.
|
|
90
|
+
*
|
|
91
|
+
* Described here rather than imported, so an app that does not use this driver still
|
|
92
|
+
* typechecks without the package installed — which is every app that picked one of the
|
|
93
|
+
* other three.
|
|
94
|
+
*/
|
|
95
|
+
interface UTApiLike {
|
|
96
|
+
uploadFiles(file: unknown): Promise<{ error: { message: string } | null }>;
|
|
97
|
+
deleteFiles(keys: string[], options: { keyType: "customId" }): Promise<unknown>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The SDK, loaded only if this driver is actually used.
|
|
102
|
+
*
|
|
103
|
+
* The specifier is a variable so that neither TypeScript nor the bundler tries to
|
|
104
|
+
* resolve a package that an app using a different driver has no reason to install.
|
|
105
|
+
*/
|
|
106
|
+
const SDK = "uploadthing/server";
|
|
107
|
+
|
|
108
|
+
async function api(token: string | undefined): Promise<UTApiLike> {
|
|
109
|
+
let module: { UTApi: new (options: Record<string, unknown>) => UTApiLike };
|
|
110
|
+
try {
|
|
111
|
+
module = (await import(SDK)) as never;
|
|
112
|
+
} catch {
|
|
113
|
+
throw new Error('STORAGE_DRIVER is "uploadthing" but the `uploadthing` package is not installed. Run: pnpm add uploadthing');
|
|
114
|
+
}
|
|
115
|
+
return new module.UTApi(token ? { token } : {});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function uploadThingBucket(options: UploadThingOptions): StorageBucket {
|
|
119
|
+
const publicDelivery = options.publicDelivery ?? true;
|
|
120
|
+
// Read once rather than per request, so a malformed token is an error at startup
|
|
121
|
+
// instead of a 404 on somebody's first upload.
|
|
122
|
+
const appId = options.appId ?? appIdFromToken(options.token ?? process.env.UPLOADTHING_TOKEN ?? "");
|
|
123
|
+
const deliveryUrl = (key: string) => `https://${appId}.ufs.sh/f/${customIdFor(key)}`;
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
async get(key: string): Promise<StorageObjectBody | null> {
|
|
127
|
+
const response = await fetch(deliveryUrl(key));
|
|
128
|
+
if (!response.ok || !response.body) return null;
|
|
129
|
+
return {
|
|
130
|
+
body: response.body,
|
|
131
|
+
size: Number(response.headers.get("content-length") ?? 0),
|
|
132
|
+
httpEtag: response.headers.get("etag") ?? "",
|
|
133
|
+
httpMetadata: { contentType: response.headers.get("content-type") ?? undefined },
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async put(key, value, putOptions) {
|
|
138
|
+
const body = value === null ? new ArrayBuffer(0) : await new Response(value as BodyInit).arrayBuffer();
|
|
139
|
+
const contentType = putOptions?.httpMetadata?.contentType;
|
|
140
|
+
const name = key.split("/").pop() ?? "file";
|
|
141
|
+
|
|
142
|
+
// UTApi takes a Blob with a `name`, and reads `customId` off it. A global `File`
|
|
143
|
+
// is exactly that on Node 20+, which is the floor Flare builds on anyway.
|
|
144
|
+
const file = new File([body], name, contentType ? { type: contentType } : undefined) as File & { customId?: string };
|
|
145
|
+
file.customId = customIdFor(key);
|
|
146
|
+
|
|
147
|
+
const { error } = await (await api(options.token)).uploadFiles(file);
|
|
148
|
+
if (error) throw new Error(`UploadThing upload failed for "${key}": ${error.message}`);
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async delete(keys) {
|
|
152
|
+
const ids = [keys].flat().map(customIdFor);
|
|
153
|
+
if (ids.length === 0) return;
|
|
154
|
+
// Deleting what was never there is not an error, which is the outcome asked for.
|
|
155
|
+
await (await api(options.token)).deleteFiles(ids, { keyType: "customId" });
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
publicUrl: publicDelivery ? (key: string) => deliveryUrl(key) : undefined,
|
|
159
|
+
};
|
|
160
|
+
}
|