@flaredev/cli 0.10.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.
- package/dist/index.js +4516 -3014
- package/package.json +2 -2
- package/templates/app/.dev.vars.example +20 -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 +17 -4
- 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 +112 -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/drizzle-rows.ts +7 -2
- package/templates/app/lib/resource/handlers.ts +9 -2
- package/templates/app/lib/resource/http.ts +24 -13
- package/templates/app/lib/resource/rows.ts +5 -1
- package/templates/app/lib/resource/store.ts +126 -38
- package/templates/app/lib/storage/cloudinary.ts +146 -0
- package/templates/app/lib/storage/uploadthing.ts +122 -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/{package-lock.json → cloudflare/package-lock.json} +1674 -1997
- package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1591 -1844
- package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1312 -1460
- package/templates/locks/next/package-lock.json +13713 -0
- package/templates/locks/next/pnpm-lock.yaml +8252 -0
- package/templates/locks/next/yarn.lock +5790 -0
- package/templates/next/.env.example +26 -10
- package/templates/next/README.md +35 -3
- package/templates/next/docker-compose.yml +74 -0
- package/templates/next/lib/auth.ts +108 -2
- package/templates/next/lib/dashboard.ts +3 -1
- package/templates/next/lib/db.ts +5 -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 +55 -11
- 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,142 @@
|
|
|
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
|
+
* Invitations, for an app whose sign-up is `invite` (see lib/auth-config.ts).
|
|
12
|
+
*
|
|
13
|
+
* **They live in the `verification` table**, which Better Auth already has and already
|
|
14
|
+
* uses for exactly this shape of thing: a single-use token with an expiry. Reusing it
|
|
15
|
+
* means invitations need no table of their own and so no migration — an app switching to
|
|
16
|
+
* `invite` changes one line in auth-config.ts and nothing else.
|
|
17
|
+
*
|
|
18
|
+
* Rows are keyed `invite:<hash>`. Only the hash is stored, never the token itself, for the
|
|
19
|
+
* same reason a password is not: a leaked database should not be a pile of working
|
|
20
|
+
* invitations.
|
|
21
|
+
*
|
|
22
|
+
* The flow:
|
|
23
|
+
*
|
|
24
|
+
* 1. An admin creates one. `createInvitation` returns the token once — it is not
|
|
25
|
+
* recoverable afterwards, which is why the link is shown on screen to copy.
|
|
26
|
+
* 2. The invited person opens the link. `/invite/[token]` puts the token in a
|
|
27
|
+
* short-lived cookie and sends them to the sign-up page.
|
|
28
|
+
* 3. They sign up. `lib/auth.ts` reads the cookie, checks the invitation, and lets the
|
|
29
|
+
* account be created — then spends it, so the link cannot be used twice.
|
|
30
|
+
*/
|
|
31
|
+
import { and, eq, like, lt } from "drizzle-orm";
|
|
32
|
+
import { getDb } from "@/db";
|
|
33
|
+
import { verification } from "@/db/auth-schema";
|
|
34
|
+
|
|
35
|
+
/** How long an invitation is good for, unless you say otherwise. */
|
|
36
|
+
export const DEFAULT_INVITE_DAYS = 7;
|
|
37
|
+
|
|
38
|
+
/** The cookie an opened invitation link leaves behind, for the sign-up that follows. */
|
|
39
|
+
export const INVITE_COOKIE = "flare_invite";
|
|
40
|
+
|
|
41
|
+
/** How long that cookie lives: long enough to fill in a form, not long enough to forget. */
|
|
42
|
+
export const INVITE_COOKIE_MAX_AGE = 60 * 60;
|
|
43
|
+
|
|
44
|
+
const KEY_PREFIX = "invite:";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* WebCrypto rather than node:crypto, so the same file runs on Workers and on Node
|
|
48
|
+
* without a compatibility flag deciding whether it does.
|
|
49
|
+
*/
|
|
50
|
+
const randomToken = () => {
|
|
51
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
52
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Only the hash is stored, so a copy of the table is not a pile of working invitations. */
|
|
56
|
+
async function hash(token: string): Promise<string> {
|
|
57
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
58
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const key = async (token: string) => `${KEY_PREFIX}${await hash(token)}`;
|
|
62
|
+
|
|
63
|
+
/** What an invitation is for: one address, or anybody who has the link. */
|
|
64
|
+
export type InviteFor = { email: string } | { anyone: true };
|
|
65
|
+
|
|
66
|
+
/** An invitation. The token is returned once and never stored. */
|
|
67
|
+
export interface CreatedInvitation {
|
|
68
|
+
token: string;
|
|
69
|
+
expiresAt: Date;
|
|
70
|
+
/** Who it is for, or null when anybody with the link may use it. */
|
|
71
|
+
email: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Create one. The returned token is the only copy. */
|
|
75
|
+
export async function createInvitation(options: { for: InviteFor; days?: number }): Promise<CreatedInvitation> {
|
|
76
|
+
const token = randomToken();
|
|
77
|
+
const email = "email" in options.for ? options.for.email.trim().toLowerCase() : null;
|
|
78
|
+
const expiresAt = new Date(Date.now() + (options.days ?? DEFAULT_INVITE_DAYS) * 86_400_000);
|
|
79
|
+
await getDb()
|
|
80
|
+
.insert(verification)
|
|
81
|
+
.values({
|
|
82
|
+
id: crypto.randomUUID(),
|
|
83
|
+
identifier: await key(token),
|
|
84
|
+
// `*` rather than an empty string, so "for anybody" is a value and not a mistake.
|
|
85
|
+
value: email ?? "*",
|
|
86
|
+
expiresAt,
|
|
87
|
+
createdAt: new Date(),
|
|
88
|
+
updatedAt: new Date(),
|
|
89
|
+
});
|
|
90
|
+
return { token, expiresAt, email };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The link to send somebody, given the app's own origin. */
|
|
94
|
+
export const invitationLink = (baseUrl: string, token: string) => `${baseUrl.replace(/\/$/, "")}/invite/${token}`;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether this token is a live invitation for this email address.
|
|
98
|
+
*
|
|
99
|
+
* Checked, not spent: the account does not exist yet when this is asked, and an
|
|
100
|
+
* invitation should survive a sign-up that fails validation.
|
|
101
|
+
*/
|
|
102
|
+
export async function invitationAllows(token: string | undefined, email: string): Promise<boolean> {
|
|
103
|
+
if (!token) return false;
|
|
104
|
+
const rows = await getDb().select().from(verification).where(eq(verification.identifier, await key(token))).limit(1);
|
|
105
|
+
const row = rows[0];
|
|
106
|
+
if (!row) return false;
|
|
107
|
+
if (row.expiresAt.getTime() <= Date.now()) return false;
|
|
108
|
+
// An invitation addressed to somebody is for them alone; `*` is for whoever has the link.
|
|
109
|
+
return row.value === "*" || row.value === email.trim().toLowerCase();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Spend it, so the link cannot be used again. Called once the account exists. */
|
|
113
|
+
export async function spendInvitation(token: string | undefined): Promise<void> {
|
|
114
|
+
if (!token) return;
|
|
115
|
+
await getDb().delete(verification).where(eq(verification.identifier, await key(token)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Invitations an admin can see: live ones, newest first. Never the tokens. */
|
|
119
|
+
export async function listInvitations(): Promise<{ id: string; email: string | null; expiresAt: Date; createdAt: Date }[]> {
|
|
120
|
+
const rows = await getDb().select().from(verification).where(like(verification.identifier, `${KEY_PREFIX}%`));
|
|
121
|
+
return rows
|
|
122
|
+
.filter((row) => row.expiresAt.getTime() > Date.now())
|
|
123
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
124
|
+
.map((row) => ({ id: row.id, email: row.value === "*" ? null : row.value, expiresAt: row.expiresAt, createdAt: row.createdAt }));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Revoke one by its row id, so a link sent to the wrong address stops working. */
|
|
128
|
+
export async function revokeInvitation(id: string): Promise<void> {
|
|
129
|
+
await getDb().delete(verification).where(eq(verification.id, id));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Drop invitations that have expired.
|
|
134
|
+
*
|
|
135
|
+
* Only rows under the `invite:` prefix — the rest of this table is Better Auth's, and
|
|
136
|
+
* deleting its tokens would sign people out and break magic links.
|
|
137
|
+
*/
|
|
138
|
+
export async function pruneInvitations(): Promise<void> {
|
|
139
|
+
await getDb()
|
|
140
|
+
.delete(verification)
|
|
141
|
+
.where(and(like(verification.identifier, `${KEY_PREFIX}%`), lt(verification.expiresAt, new Date())));
|
|
142
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -108,9 +108,14 @@ export function drizzleRows(table: SQLiteTable, getDb: () => AnyDatabase): Resou
|
|
|
108
108
|
return (record as Row | undefined) ?? null;
|
|
109
109
|
},
|
|
110
110
|
|
|
111
|
-
async titles(ids, titleField) {
|
|
111
|
+
async titles(ids, titleField, owner) {
|
|
112
112
|
const titleColumn = columns[titleField] ?? idColumn;
|
|
113
|
-
const
|
|
113
|
+
const ownerColumn = owner ? columns[owner.field] : undefined;
|
|
114
|
+
// An owner naming a column this table does not have would widen the query rather
|
|
115
|
+
// than narrow it, so it answers nothing instead.
|
|
116
|
+
if (owner && !ownerColumn) return [];
|
|
117
|
+
const where = ownerColumn ? and(inArray(idColumn, ids), eq(ownerColumn, owner!.value)) : inArray(idColumn, ids);
|
|
118
|
+
const rows = await getDb().select({ id: idColumn, title: titleColumn }).from(table).where(where);
|
|
114
119
|
return rows as { id: string; title: unknown }[];
|
|
115
120
|
},
|
|
116
121
|
|
|
@@ -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
|
|
|
@@ -78,7 +78,11 @@ export interface ResourceRows {
|
|
|
78
78
|
/** How many rows match, giving up at `limit` so a huge table is never fully counted. */
|
|
79
79
|
countUpTo(query: Pick<RowsQuery, "search" | "filters" | "deleted">, limit: number): Promise<number>;
|
|
80
80
|
byId(id: string): Promise<Row | null>;
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Title values for a set of ids. `owner`, when given, restricts the rows considered —
|
|
83
|
+
* a relation picker shown to a confined user must not name records they cannot see.
|
|
84
|
+
*/
|
|
85
|
+
titles(ids: string[], titleField: string, owner?: { field: string; value: string }): Promise<{ id: string; title: unknown }[]>;
|
|
82
86
|
insert(values: Row): Promise<Row>;
|
|
83
87
|
/** The saved row, or null when there was nothing with that id. */
|
|
84
88
|
update(id: string, values: Row): Promise<Row | null>;
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// `flare diff` shows how your copy differs from the version Flare ships, and
|
|
9
9
|
// `flare update` applies the parts you choose. Neither runs unless you ask.
|
|
10
10
|
|
|
11
|
-
import { columnName, fromMinorUnits, ownershipFilter, storedFields, toMinorUnits, type Policy, type Resource } from "@flaredev/core";
|
|
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.
|
|
@@ -212,6 +253,34 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
212
253
|
(def.kind === "int" || def.kind === "float") && def.format === "money" ? [{ key, currency: def.currency }] : [],
|
|
213
254
|
);
|
|
214
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Fields that only apply when another field says so, e.g. a billing cycle that exists
|
|
258
|
+
* on a recurring project and not on a one-off one.
|
|
259
|
+
*
|
|
260
|
+
* The validator decides whether one is *required*; this decides whether it is *kept*.
|
|
261
|
+
* Without it, changing a project from recurring to one-off leaves last month's billing
|
|
262
|
+
* cycle sitting in the row — the form stopped showing the input, so nothing ever sent a
|
|
263
|
+
* value to overwrite it, and the record quietly disagrees with itself.
|
|
264
|
+
*/
|
|
265
|
+
const conditionalFields = fields.flatMap(([key, def]) => (def.showWhen ? [{ key, condition: def.showWhen }] : []));
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Null the fields that no longer apply.
|
|
269
|
+
*
|
|
270
|
+
* `record` is what the condition is judged against: the whole record as it will be once
|
|
271
|
+
* this write lands, which on a PATCH means the stored row with the patch laid over it.
|
|
272
|
+
* Judging the patch alone would read a field it never mentioned as absent and clear a
|
|
273
|
+
* value that was meant to stay.
|
|
274
|
+
*/
|
|
275
|
+
function clearInapplicable(values: Row, record: Row): Row {
|
|
276
|
+
if (conditionalFields.length === 0) return values;
|
|
277
|
+
const out = { ...values };
|
|
278
|
+
for (const { key, condition } of conditionalFields) {
|
|
279
|
+
if (!conditionHolds(condition, record)) out[key] = null;
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
215
284
|
/** Amounts as the database stores them: 19.99 -> 1999. */
|
|
216
285
|
function toStored(values: Row): Row {
|
|
217
286
|
if (moneyFields.length === 0) return values;
|
|
@@ -277,15 +346,17 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
277
346
|
/**
|
|
278
347
|
* The owner this call is confined to: `null` to touch every row, or a Failure.
|
|
279
348
|
*
|
|
280
|
-
*
|
|
281
|
-
* and a request with no session is a 403, not an unrestricted query:
|
|
282
|
-
* that earlier, but the dashboard and server actions come through here
|
|
283
|
-
* 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.
|
|
284
353
|
*/
|
|
285
354
|
async function confine(): Promise<{ field: string; value: string } | Failure | null> {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
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);
|
|
289
360
|
return ownershipFilter(options.policy, user);
|
|
290
361
|
}
|
|
291
362
|
|
|
@@ -303,7 +374,16 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
303
374
|
/** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
|
|
304
375
|
async list(params: URLSearchParams, options: { maxPerPage?: number } = {}): Promise<Result<ListResult>> {
|
|
305
376
|
const parsed = parseListQuery(resource, params, options);
|
|
306
|
-
|
|
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
|
+
}
|
|
307
387
|
const { page, perPage, sort, q, cursor } = parsed.query;
|
|
308
388
|
|
|
309
389
|
const limit = await confine();
|
|
@@ -405,16 +485,19 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
405
485
|
/**
|
|
406
486
|
* Title-field values for a set of ids (for showing relations). Missing ids are omitted.
|
|
407
487
|
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
488
|
+
* Confined by ownership, like every other read: a relation picker shown to a user who
|
|
489
|
+
* only sees their own rows must not name anybody else's. An id they may not see is
|
|
490
|
+
* simply absent from the result, which the caller already handles — missing ids are
|
|
491
|
+
* omitted either way, so a title they are not entitled to looks the same as one that
|
|
492
|
+
* was deleted.
|
|
413
493
|
*/
|
|
414
494
|
async titles(ids: string[]): Promise<Record<string, string>> {
|
|
415
495
|
const unique = [...new Set(ids.filter(Boolean))];
|
|
416
496
|
if (unique.length === 0) return {};
|
|
417
|
-
const
|
|
497
|
+
const limit = await confine();
|
|
498
|
+
// Not signed in where sign-in is required: no titles rather than everyone's.
|
|
499
|
+
if (isFailure(limit)) return {};
|
|
500
|
+
const found = await rows.titles(unique, resource.titleField, limit ?? undefined);
|
|
418
501
|
return Object.fromEntries(found.map((row) => [row.id, row.title == null ? row.id : String(row.title)]));
|
|
419
502
|
},
|
|
420
503
|
|
|
@@ -436,7 +519,8 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
436
519
|
if (!result.success) return invalid(result.error.issues);
|
|
437
520
|
const input = result.data as Record<string, unknown>;
|
|
438
521
|
const now = new Date();
|
|
439
|
-
const
|
|
522
|
+
const fresh = { ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now };
|
|
523
|
+
const record = await rows.insert(toStored(clearInapplicable(fresh, fresh)));
|
|
440
524
|
const saved = withComputed(record);
|
|
441
525
|
await hooks.afterCreate?.(saved, context);
|
|
442
526
|
return { ok: true, data: saved };
|
|
@@ -452,7 +536,10 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
452
536
|
|
|
453
537
|
// A confined update has to read the row first: `rows.update(id, …)` takes an id
|
|
454
538
|
// and nothing else, so this is where "is it yours" is answered.
|
|
455
|
-
|
|
539
|
+
// Conditional fields join this list: deciding whether one still applies needs the
|
|
540
|
+
// whole record, and a PATCH on its own may not mention the field it depends on.
|
|
541
|
+
const needsCurrent = limit || hooks.beforeUpdate || hooks.afterUpdate || conditionalFields.length > 0;
|
|
542
|
+
const current = needsCurrent ? await rows.byId(id) : null;
|
|
456
543
|
if (limit) {
|
|
457
544
|
if (!current) return notFound();
|
|
458
545
|
if (!owns(current, limit)) return notFound();
|
|
@@ -467,16 +554,17 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
467
554
|
const sent = (supplied as Record<string, unknown>)[limit.field];
|
|
468
555
|
if (sent !== limit.value) {
|
|
469
556
|
const label = resource.fields[limit.field]?.label ?? limit.field;
|
|
470
|
-
return fail(422, `You can't change which user this ${resource.label.toLowerCase()} belongs to.`,
|
|
471
|
-
|
|
472
|
-
|
|
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
|
+
]);
|
|
473
560
|
}
|
|
474
561
|
}
|
|
475
562
|
|
|
476
563
|
const result = validators.update.safeParse(supplied);
|
|
477
564
|
if (!result.success) return invalid(result.error.issues);
|
|
478
565
|
const input = result.data as Record<string, unknown>;
|
|
479
|
-
const
|
|
566
|
+
const merged = { ...((current ?? {}) as Row), ...input };
|
|
567
|
+
const record = await rows.update(id, toStored(clearInapplicable({ ...input, updatedAt: new Date() }, merged)));
|
|
480
568
|
if (!record) return notFound();
|
|
481
569
|
const saved = withComputed(record);
|
|
482
570
|
await hooks.afterUpdate?.(saved, { ...context, previous: current });
|
|
@@ -505,7 +593,7 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
505
593
|
// one of them, and a replace would quietly orphan the row it just saved.
|
|
506
594
|
if (limit) values[limit.field] = limit.value;
|
|
507
595
|
|
|
508
|
-
const record = await rows.update(id, toStored(values));
|
|
596
|
+
const record = await rows.update(id, toStored(clearInapplicable(values, values)));
|
|
509
597
|
return record ? { ok: true, data: withComputed(record) } : notFound();
|
|
510
598
|
});
|
|
511
599
|
},
|