@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.
Files changed (50) hide show
  1. package/dist/index.js +4516 -3014
  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 +17 -4
  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 +112 -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/drizzle-rows.ts +7 -2
  22. package/templates/app/lib/resource/handlers.ts +9 -2
  23. package/templates/app/lib/resource/http.ts +24 -13
  24. package/templates/app/lib/resource/rows.ts +5 -1
  25. package/templates/app/lib/resource/store.ts +126 -38
  26. package/templates/app/lib/storage/cloudinary.ts +146 -0
  27. package/templates/app/lib/storage/uploadthing.ts +122 -0
  28. package/templates/app/lib/storage.ts +72 -6
  29. package/templates/app/lib/user-titles.ts +52 -0
  30. package/templates/app/worker/index.ts +4 -1
  31. package/templates/app/wrangler.jsonc +11 -0
  32. package/templates/locks/{package-lock.json → cloudflare/package-lock.json} +1674 -1997
  33. package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1591 -1844
  34. package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1312 -1460
  35. package/templates/locks/next/package-lock.json +13713 -0
  36. package/templates/locks/next/pnpm-lock.yaml +8252 -0
  37. package/templates/locks/next/yarn.lock +5790 -0
  38. package/templates/next/.env.example +26 -10
  39. package/templates/next/README.md +35 -3
  40. package/templates/next/docker-compose.yml +74 -0
  41. package/templates/next/lib/auth.ts +108 -2
  42. package/templates/next/lib/dashboard.ts +3 -1
  43. package/templates/next/lib/db.ts +5 -1
  44. package/templates/next/lib/invitations.ts +119 -0
  45. package/templates/next/lib/rate-limit.ts +83 -0
  46. package/templates/next/lib/resource/prisma-rows.ts +55 -11
  47. package/templates/next/lib/storage/disk.ts +128 -0
  48. package/templates/next/lib/storage/r2.ts +86 -0
  49. package/templates/next/lib/storage.ts +83 -66
  50. package/templates/next/lib/user-titles.ts +49 -0
@@ -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
+ }
@@ -1,12 +1,35 @@
1
1
  import { env } from "cloudflare:workers";
2
- import { createObjectKey, createStorage } from "@flaredev/core";
2
+ import { createObjectKey, createStorage, type StorageBucket } from "@flaredev/core";
3
+ import { cloudinaryBucket } from "./storage/cloudinary";
4
+ import { uploadThingBucket } from "./storage/uploadthing";
3
5
 
4
6
  /**
5
- * File storage on the `STORAGE` R2 bucket.
7
+ * Where uploaded files go.
6
8
  *
7
- * `createUploadUrl` / `createReadUrl` return short-lived signed URLs redeemed by
8
- * `app/api/storage/route.ts`. A URL grants access to whoever holds it, so check
9
- * that the current user may upload or view the object before issuing one.
9
+ * The default is the `STORAGE` R2 bucket this app is already bound to: no account to
10
+ * create, no keys to copy, and Wrangler simulates it locally as a directory. Set
11
+ * `STORAGE_DRIVER` to send files to a content delivery network instead.
12
+ *
13
+ * r2 The bound bucket. The default, and nothing to configure.
14
+ * cloudinary A CDN with an image pipeline. Serves from res.cloudinary.com.
15
+ * uploadthing A CDN with an uploader. Serves from <app>.ufs.sh.
16
+ *
17
+ * The drivers are in `lib/storage/`, in your repository, and are ordinary files you can
18
+ * read and change. Adding one means writing `get`, `put` and `delete`.
19
+ *
20
+ * `disk` exists on the Next.js stack and not here: a Worker has no filesystem to write
21
+ * to, and the bound bucket already covers what it is for.
22
+ *
23
+ * ## Private by default, and what breaks that
24
+ *
25
+ * With `r2`, a read URL is a short-lived signed token redeemed by
26
+ * `app/api/storage/route.ts`, so whoever issued it decided the viewer was allowed it.
27
+ *
28
+ * `cloudinary` and `uploadthing` are content delivery networks, and serving through
29
+ * this Worker would throw away the thing you are paying them for. So they hand back
30
+ * their own URL, which **anyone with the link can read, forever**. That is the normal
31
+ * way to use them and the wrong way to store a medical record. Set
32
+ * `STORAGE_PUBLIC=false` to keep every read signed and proxied.
10
33
  *
11
34
  * const { url } = await storage.createUploadUrl({
12
35
  * key: createObjectKey(file.name, `users/${user.id}`),
@@ -17,8 +40,51 @@ import { createObjectKey, createStorage } from "@flaredev/core";
17
40
  *
18
41
  * const src = await storage.createReadUrl({ key });
19
42
  */
43
+
44
+ type StorageDriver = "r2" | "cloudinary" | "uploadthing";
45
+
46
+ const DRIVERS: StorageDriver[] = ["r2", "cloudinary", "uploadthing"];
47
+
48
+ const vars = env as unknown as Record<string, string | undefined>;
49
+
50
+ const configured = (vars.STORAGE_DRIVER ?? "r2").trim().toLowerCase();
51
+ if (!DRIVERS.includes(configured as StorageDriver)) {
52
+ throw new Error(`Unknown STORAGE_DRIVER "${configured}". One of: ${DRIVERS.join(", ")}.`);
53
+ }
54
+ const driver = configured as StorageDriver;
55
+
56
+ /** Required configuration, named in the error rather than left as `undefined`. */
57
+ function required(name: string): string {
58
+ const value = vars[name];
59
+ if (!value) throw new Error(`STORAGE_DRIVER is "${driver}" and needs ${name}, which is not set. Add it with: wrangler secret put ${name}`);
60
+ return value;
61
+ }
62
+
63
+ /** Public delivery is the point of a CDN, so it is on unless it is turned off. */
64
+ const publicDelivery = vars.STORAGE_PUBLIC !== "false";
65
+
66
+ function selectBucket(): StorageBucket {
67
+ switch (driver) {
68
+ case "cloudinary":
69
+ return cloudinaryBucket({
70
+ cloudName: required("CLOUDINARY_CLOUD_NAME"),
71
+ apiKey: required("CLOUDINARY_API_KEY"),
72
+ apiSecret: required("CLOUDINARY_API_SECRET"),
73
+ publicDelivery,
74
+ });
75
+ case "uploadthing":
76
+ return uploadThingBucket({
77
+ token: required("UPLOADTHING_TOKEN"),
78
+ appId: required("UPLOADTHING_APP_ID"),
79
+ publicDelivery,
80
+ });
81
+ case "r2":
82
+ return env.STORAGE;
83
+ }
84
+ }
85
+
20
86
  export const storage = createStorage({
21
- bucket: env.STORAGE,
87
+ bucket: selectBucket(),
22
88
  // Signing keys are derived from the auth secret with a storage-specific label.
23
89
  secret: env.BETTER_AUTH_SECRET,
24
90
  });
@@ -0,0 +1,52 @@
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
+ * Names for the accounts a `belongsTo("User")` field points at.
12
+ *
13
+ * Better Auth owns the account table, so it is not a Flare resource and has no store to
14
+ * ask — but the admin still has to show "Ada Lovelace" where the column holds a uuid.
15
+ * This is the one place that reads it, and it reads two columns and no more.
16
+ *
17
+ * An account may have no name (a passkey-only sign-up, say), so the email stands in. It
18
+ * is the same fallback `/api/users` uses, so a record reads the same in the table, on
19
+ * the detail page and in the picker.
20
+ */
21
+ import { inArray } from "drizzle-orm";
22
+ import { getDb } from "@/db";
23
+ import { user } from "@/db/auth-schema";
24
+
25
+ /** What the admin needs to treat Better Auth's account table as a relation target. */
26
+ export const AUTH_USER_RELATION = {
27
+ name: "User",
28
+ label: "User",
29
+ pluralLabel: "Users",
30
+ slug: "users",
31
+ titleField: "name",
32
+ } as const;
33
+
34
+ /** id → display name, for the ids that exist. Unknown ids are simply absent. */
35
+ export async function userTitles(ids: string[]): Promise<Record<string, string>> {
36
+ const wanted = [...new Set(ids.filter(Boolean))];
37
+ if (wanted.length === 0) return {};
38
+ const rows = await getDb().select({ id: user.id, name: user.name, email: user.email }).from(user).where(inArray(user.id, wanted));
39
+ return Object.fromEntries(rows.map((row) => [row.id, row.name?.trim() || row.email]));
40
+ }
41
+
42
+ /**
43
+ * Whether anybody has an account yet.
44
+ *
45
+ * Asked by the sign-up page: the gate in lib/auth.ts always lets the *first* account
46
+ * through, whatever `authConfig.signup` says, because an app nobody can sign into cannot
47
+ * be set up. So "sign-up is closed" is only true once somebody exists.
48
+ */
49
+ export async function anyAccountExists(): Promise<boolean> {
50
+ const [first] = await getDb().select({ id: user.id }).from(user).limit(1);
51
+ return first !== undefined;
52
+ }
@@ -1,4 +1,5 @@
1
1
  import app from "vinext/server/app-router-entry";
2
+ import { RateLimitCounter } from "@flaredev/core/rate-limit/server";
2
3
  import { RealtimeChannel, handleRealtimeUpgrade } from "@flaredev/core/realtime/server";
3
4
  import { SecurityMonitor } from "@flaredev/core/security/server";
4
5
  import { authorizeRealtime } from "../lib/realtime";
@@ -9,12 +10,14 @@ import { protect } from "../lib/security";
9
10
  // the `cloudflare:workers` module.
10
11
 
11
12
  // Durable Object classes must be exported from the Worker's main module.
12
- export { RealtimeChannel, SecurityMonitor };
13
+ export { RateLimitCounter, RealtimeChannel, SecurityMonitor };
13
14
 
14
15
  export interface AppEnv {
15
16
  ASSETS: Fetcher;
16
17
  DB: D1Database;
17
18
  FLARE_REALTIME: DurableObjectNamespace<RealtimeChannel>;
19
+ /** Counters for the rate limits a descriptor declares (see lib/rate-limit.ts). */
20
+ FLARE_RATE_LIMIT: DurableObjectNamespace<RateLimitCounter>;
18
21
  }
19
22
 
20
23
  export interface AppContext {
@@ -16,6 +16,13 @@
16
16
  // Object instance created on demand; wrangler provisions nothing upfront.
17
17
  "name": "FLARE_REALTIME",
18
18
  "class_name": "RealtimeChannel"
19
+ },
20
+ {
21
+ // Rate limit counters (see lib/rate-limit.ts). One instance per resource and
22
+ // action, holding a count per caller — so a limited endpoint costs one object
23
+ // request, not one per person calling it.
24
+ "name": "FLARE_RATE_LIMIT",
25
+ "class_name": "RateLimitCounter"
19
26
  }
20
27
  ]
21
28
  },
@@ -23,6 +30,10 @@
23
30
  {
24
31
  "tag": "v1",
25
32
  "new_sqlite_classes": ["RealtimeChannel"]
33
+ },
34
+ {
35
+ "tag": "v2",
36
+ "new_sqlite_classes": ["RateLimitCounter"]
26
37
  }
27
38
  ],
28
39
  "d1_databases": [