create-meith 0.18.0 → 0.20.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/bin.mjs CHANGED
@@ -25,7 +25,7 @@ var MATERIALIZED_AT_ROOT = [
25
25
  ];
26
26
  var VERCEL_BUILD_COMMAND = `community migrate && forum-web build ${AT_ROOT_FLAG}`;
27
27
  var TICK_PATH = "/api/system/tick";
28
- var TICK_SCHEDULE = "* * * * *";
28
+ var TICK_SCHEDULE = "0 3 * * *";
29
29
  var MATERIALIZED_PUBLIC = [
30
30
  "placeholder-logo.png",
31
31
  "placeholder-logo.svg",
@@ -176,25 +176,24 @@ function vercelEnvExample(name) {
176
176
  # no other instance can read and that is discarded with the instance \u2014 on Vercel
177
177
  # the board refuses it outright rather than losing uploads quietly.
178
178
  #
179
- # CACHE_DRIVER and FILESTORE_DRIVER are asked for at deploy time. The other
180
- # three derive themselves: a DATABASE_URL means postgres for the data source and
181
- # the queue, and a RESEND_API_KEY with a MAIL_FROM beside it means mail over the
182
- # provider's HTTPS API. Setting one here overrides the derivation, which is what
183
- # this file is for when you copy it to .env.local.
179
+ # The deploy form asks for none of them. On Vercel the board works each one out
180
+ # from what the linked stores publish: a DATABASE_URL means postgres for the
181
+ # data source and the queue, a Redis connection string means CACHE_DRIVER=redis,
182
+ # a Blob store's read-write token means FILESTORE_DRIVER=blob, and a
183
+ # RESEND_API_KEY with a MAIL_FROM beside it means mail over the provider's HTTPS
184
+ # API. Setting one here overrides the derivation, which is what this file is for
185
+ # when you copy it to .env.local.
184
186
  #
185
- # The two that stay explicit stay that way on purpose: each turns on an external
186
- # service the board then depends on for every request, and neither is switched on
187
- # by a variable that merely happens to be present. The board caches in Redis and
188
- # puts uploads in an object store because you said so.
187
+ # Every one of those derivations is scoped to Vercel, and each fires only from a
188
+ # value that is unambiguously the thing itself \u2014 a redis:// or rediss:// URL, a
189
+ # read-write token. A board you run anywhere else is untouched by all of it and
190
+ # still takes these values from this file, exactly as it did before.
189
191
  #
190
- # What happens if you leave one blank differs, and the difference is the reason
191
- # the form asks for both. Leave FILESTORE_DRIVER blank and the board refuses to
192
- # boot at all: the default is local, and local on this platform means uploads
193
- # written to a disk that is about to disappear. Leave CACHE_DRIVER blank and
194
- # nothing complains \u2014 the board falls back to caching inside each instance and
195
- # serves whatever that instance last saw, for up to a minute, however many
196
- # instances there are. That one degrades quietly, which is exactly why it is
197
- # worth typing rather than leaving to a default.
192
+ # A derivation that cannot resolve is a configuration error, not an invitation
193
+ # to pick something safe-looking. On Vercel, with the cache or the object store
194
+ # missing, the board refuses to boot and names every variable it looked at. It
195
+ # will not quietly cache inside the instance, and it will not quietly write
196
+ # uploads to a disk that is about to disappear.
198
197
  DATA_SOURCE=postgres
199
198
  QUEUE_DRIVER=postgres
200
199
  CACHE_DRIVER=redis
@@ -208,16 +207,20 @@ DATABASE_URL=
208
207
 
209
208
  ${ENV_DIRECT_DATABASE_URL_PROSE}
210
209
  #
211
- # On Vercel this is not optional. DATABASE_URL here is the pooler string, the
212
- # build runs \`community migrate\` against it, and /install takes the second of
213
- # those two session locks on first run. Neon publishes the direct string as
214
- # DATABASE_URL_UNPOOLED; copy that value into this variable.
210
+ # On Vercel this is not optional, and it is no longer yours to copy. DATABASE_URL
211
+ # here is the pooler string, the build runs \`community migrate\` against it, and
212
+ # /install takes the second of those two session locks on first run. Left blank,
213
+ # the board reads Neon's own direct string \u2014 \`DATABASE_URL_UNPOOLED\` first, then
214
+ # \`POSTGRES_URL_NON_POOLING\` \u2014 and refuses to boot if neither is there, naming
215
+ # both. Never \`POSTGRES_URL\`: that one is pooled.
215
216
  DIRECT_DATABASE_URL=
216
217
 
217
218
  # The shared cache \u2014 a Redis or Valkey endpoint, \`rediss://\` for TLS. Redis
218
219
  # holds cache entries and nothing else: losing it costs the board a warm cache,
219
- # not data, and signs nobody out. A store provisioned with the board publishes
220
- # its own connection variable; copy that value into this one.
220
+ # not data, and signs nobody out. Left blank on Vercel, the board reads the
221
+ # Upstash store's own \`KV_URL\`, which is the one variable it publishes that
222
+ # speaks the Redis protocol \u2014 \`KV_REST_API_URL\` is an HTTPS endpoint and is
223
+ # never used for this. A name we do not know goes here by hand.
221
224
  REDIS_URL=
222
225
 
223
226
  ${ENV_AUTH_SECRET_PROSE}
@@ -233,15 +236,26 @@ ${ENV_TICK_SECRET_PROSE}
233
236
  CRON_SECRET=
234
237
  # TICK_SECRET=
235
238
 
236
- # Uploads, in the Vercel Blob store the Deploy Button provisions. The store
237
- # publishes this variable into the project by itself, so there is nothing to
238
- # type and nothing to mistype \u2014 but the board still needs FILESTORE_DRIVER=blob
239
- # above before it will use it. Every object is written with private access: an
240
- # object URL is not a public link, and member content is served by the board,
241
- # which is where permissions are checked.
239
+ # Uploads, in the Vercel Blob store the Deploy Button provisions. A store
240
+ # attached to the project publishes BLOB_STORE_ID and nothing else \u2014 no token \u2014
241
+ # because the SDK authenticates with the deployment's own OIDC identity: the
242
+ # board hands it the store id and lets it fetch the credential. There is nothing
243
+ # to type and nothing to mistype, and FILESTORE_DRIVER=blob derives from this
244
+ # variable being present.
242
245
  #
243
- # An upload is held whole in the instance's memory on the way in and on the way
244
- # out, so the function's memory limit, not the store, is what caps a file.
246
+ # BLOB_READ_WRITE_TOKEN is the other way in, and you make it yourself on the
247
+ # store. Set it when something has to reach the store from OUTSIDE a Vercel
248
+ # deployment \u2014 \`community backup\` run on your own machine is the case that
249
+ # matters \u2014 because there is no OIDC identity there to borrow. Set both and the
250
+ # board prefers the store id, unless the token names a different store, in which
251
+ # case the token wins: naming another store is a deliberate act.
252
+ #
253
+ # Every object is written with private access: an object URL is not a public
254
+ # link, and member content is served by the board, which is where permissions
255
+ # are checked. An upload is held whole in the instance's memory on the way in
256
+ # and on the way out, so the function's memory limit, not the store, is what
257
+ # caps a file.
258
+ BLOB_STORE_ID=
245
259
  BLOB_READ_WRITE_TOKEN=
246
260
 
247
261
  # Uploads in an S3-compatible bucket instead \u2014 AWS, R2, MinIO, Spaces. This is
@@ -264,10 +278,10 @@ BLOB_READ_WRITE_TOKEN=
264
278
  # board refuses it on Vercel; 587 with STARTTLS may work, but an API does not
265
279
  # depend on the platform's egress rules staying as they are.
266
280
  #
267
- # MAIL_FROM is yours to decide and the one mail value the button asks for: it
268
- # must be an address at a domain the provider has verified for you, and no
269
- # default is right. Send from an unverified sender and the provider rejects
270
- # every message.
281
+ # MAIL_FROM is yours to decide, and it belongs to the step that adds Resend
282
+ # rather than to the deploy: it must be an address at a domain the provider has
283
+ # verified for you, no default is right, and until a provider is added the board
284
+ # cannot send from any address at all. Set it alongside the integration.
271
285
  MAIL_FROM=
272
286
 
273
287
  # Add the Resend integration to the project from Vercel's marketplace and it
@@ -308,22 +322,15 @@ var SELF_HOST_DEPLOY_KIT = [
308
322
  "docker-entrypoint.sh",
309
323
  "docker-healthcheck.sh"
310
324
  ];
311
- var VERCEL_TYPED_DRIVERS = ["CACHE_DRIVER=redis", "FILESTORE_DRIVER=blob"];
312
325
  var VERCEL_DERIVED_DRIVERS = [
313
326
  "DATA_SOURCE=postgres",
314
327
  "QUEUE_DRIVER=postgres",
328
+ "CACHE_DRIVER=redis",
329
+ "FILESTORE_DRIVER=blob",
315
330
  "MAIL_DRIVER=http"
316
331
  ];
317
- var VERCEL_PROMPTED_ENV = [
318
- "CACHE_DRIVER",
319
- "FILESTORE_DRIVER",
320
- "DIRECT_DATABASE_URL",
321
- "REDIS_URL",
322
- "AUTH_SECRET",
323
- "CRON_SECRET",
324
- "MAIL_FROM"
325
- ];
326
- var VERCEL_MARKETPLACE_STORES = [
332
+ var VERCEL_PROMPTED_ENV = ["AUTH_SECRET", "CRON_SECRET"];
333
+ var VERCEL_MARKETPLACE_PRODUCTS = [
327
334
  { type: "integration", integrationSlug: "neon", productSlug: "neon", protocol: "storage" },
328
335
  {
329
336
  type: "integration",
@@ -331,7 +338,13 @@ var VERCEL_MARKETPLACE_STORES = [
331
338
  productSlug: "upstash-kv",
332
339
  protocol: "storage"
333
340
  },
334
- { type: "blob" }
341
+ { type: "blob" },
342
+ {
343
+ type: "integration",
344
+ integrationSlug: "resend",
345
+ productSlug: "resend-email",
346
+ protocol: "messaging"
347
+ }
335
348
  ];
336
349
  function deployButtonUrl(templateRepositoryUrl) {
337
350
  const params = new URLSearchParams([
@@ -341,10 +354,10 @@ function deployButtonUrl(templateRepositoryUrl) {
341
354
  ["env", VERCEL_PROMPTED_ENV.join(",")],
342
355
  [
343
356
  "envDescription",
344
- "Two driver values, two secrets to generate, the direct database URL, the cache URL, and the verified address the board sends from."
357
+ "Two secrets, generated rather than chosen \u2014 32 characters or more each. Everything else the board reads from the database, cache, blob store and mail provider this form links."
345
358
  ],
346
359
  ["envLink", `${templateRepositoryUrl}/blob/main/.env.example`],
347
- ["stores", JSON.stringify(VERCEL_MARKETPLACE_STORES)],
360
+ ["products", JSON.stringify(VERCEL_MARKETPLACE_PRODUCTS)],
348
361
  ["skippable-integrations", "1"]
349
362
  ]);
350
363
  return `https://vercel.com/new/clone?${params.toString()}`;
@@ -992,20 +1005,27 @@ A forum, built on [Meith](${repositoryUrl}), running as Vercel functions.
992
1005
  - **A Neon Postgres database**, attached to the project. Neon publishes the
993
1006
  pooled connection string as \`DATABASE_URL\` and the direct one as
994
1007
  \`DATABASE_URL_UNPOOLED\`.
995
- - **An Upstash Redis store**, attached the same way, for the shared cache.
996
- It publishes its own connection variable.
997
- - **A Vercel Blob store** for uploads, which publishes \`BLOB_READ_WRITE_TOKEN\`
998
- into the project by itself. This is the one value that used to be four hand-
999
- typed \`S3_*\` secrets.
1008
+ - **An Upstash Redis store**, attached the same way, for the shared cache. It
1009
+ publishes \`KV_URL\`, which the board reads as \`REDIS_URL\` \u2014 \`KV_REST_API_URL\`
1010
+ beside it is an HTTPS endpoint and is not used for this.
1011
+ - **A Vercel Blob store** for uploads, which publishes \`BLOB_STORE_ID\` into the
1012
+ project by itself. That is the whole credential: the board hands the id to
1013
+ Vercel's SDK, which authenticates with the deployment's own OIDC identity, so
1014
+ there is no token to copy. This is what used to be four hand-typed \`S3_*\`
1015
+ secrets.
1016
+ - **A Resend mail account**, attached the same way, which publishes
1017
+ \`RESEND_API_KEY\`. The board reads that name directly: its mail driver already
1018
+ speaks Resend's request shape, so there is nothing to adapt.
1000
1019
  - **A Vercel project** carrying \`vercel.json\` \u2014 the build command
1001
1020
  \`${VERCEL_BUILD_COMMAND}\`,
1002
1021
  which applies the schema before it builds, materializes the board's app at
1003
1022
  the project root so the artefact lands where Vercel reads it, and the cron
1004
1023
  entry that drives the tick.
1005
1024
 
1006
- **Mail is the one thing the button does not set up**, and it takes one click
1007
- after the deploy \u2014 see *Mail, in one click* below. The board boots and runs
1008
- without it, and delivers nothing, silently, until it is done.
1025
+ **Mail needs one thing after the deploy**: the address the board sends from.
1026
+ It has to be at a domain Resend has verified, and no form can ask for that
1027
+ before the account exists \u2014 see *Mail, after the deploy* below. The board boots
1028
+ and runs without it, and delivers nothing, silently, until it is done.
1009
1029
 
1010
1030
  ## What to type into the deploy form
1011
1031
 
@@ -1018,71 +1038,60 @@ openssl rand -hex 32 # CRON_SECRET
1018
1038
  \`\`\`
1019
1039
 
1020
1040
  \`CRON_SECRET\` is the name Vercel Cron sends, as \`Authorization: Bearer\`, and it
1021
- cannot be told to send another. Note that this floor is stricter than the 16
1041
+ cannot be told to send another \u2014 the caller is the platform, so this one has to
1042
+ be an environment variable both ends can read, and cannot be something the
1043
+ board makes up for itself. Note that this floor is stricter than the 16
1022
1044
  characters Vercel's own cron documentation suggests \u2014 a value generated by
1023
1045
  following those instructions is refused here, and the fix is a longer secret.
1024
1046
 
1025
- **Two driver values**, and they are the two that turn on an external service
1026
- the board then needs for every request:
1027
-
1028
- \`\`\`ini
1029
- ${VERCEL_TYPED_DRIVERS.join("\n")}
1030
- \`\`\`
1031
-
1032
- Neither is inferred, deliberately: the board caches in Redis and puts uploads
1033
- in an object store because you said so, never because a connection string or a
1034
- token happened to be present.
1047
+ \`AUTH_SECRET\` seals members' two-factor secrets and signs the unsubscribe links
1048
+ in outgoing mail. It stays in the environment deliberately: a copy of the
1049
+ database is then not enough to forge either.
1035
1050
 
1036
- Leaving them blank fails differently, which is why the form asks for both.
1037
- Blank \`FILESTORE_DRIVER\` means \`local\`, and the board **refuses to boot** on
1038
- this platform rather than write uploads to a disk that is about to disappear.
1039
- Blank \`CACHE_DRIVER\` complains about nothing: the board falls back to caching
1040
- inside each instance, and every instance then serves whatever it last saw for
1041
- up to a minute. That one degrades quietly, so type it.
1042
-
1043
- The remaining three drivers do derive themselves, and the form does not ask:
1044
- a \`DATABASE_URL\` means \`DATA_SOURCE=postgres\` and \`QUEUE_DRIVER=postgres\`, and
1045
- a \`RESEND_API_KEY\` with a \`MAIL_FROM\` beside it means \`MAIL_DRIVER=http\`:
1051
+ **That is the whole form.** Everything else the board works out from the stores
1052
+ this button just linked to the project:
1046
1053
 
1047
1054
  \`\`\`ini
1048
1055
  ${VERCEL_DERIVED_DRIVERS.join("\n")}
1049
1056
  \`\`\`
1050
1057
 
1051
- **Two connection strings copied from the stores the button just created.**
1052
- \`DIRECT_DATABASE_URL\` takes Neon's \`DATABASE_URL_UNPOOLED\` \u2014 migrations and the
1053
- first-run installer each hold a session-level advisory lock, which a
1054
- transaction-mode pooler cannot hold. \`REDIS_URL\` takes whatever variable the
1055
- Upstash store published.
1056
-
1057
- **The address the board sends from**: \`MAIL_FROM\`. This is the only mail value
1058
- you ever type. It has to be an address at a domain your mail provider has
1059
- verified for you, so no default is right and the board cannot guess it \u2014 see
1060
- *Mail, in one click* below. If you do not have one yet, put in the address you
1061
- intend to use and finish verifying it afterwards; nothing else waits on it.
1062
-
1063
- That is seven fields. If you would rather keep uploads somewhere you hold
1064
- yourself \u2014 see *Leaving Vercel* below for why that matters \u2014 set
1065
- \`FILESTORE_DRIVER=s3\` instead and add \`S3_BUCKET\`, \`S3_REGION\`,
1066
- \`S3_ACCESS_KEY_ID\` and \`S3_SECRET_ACCESS_KEY\` in the project's environment
1067
- settings, with \`S3_ENDPOINT\` for a bucket that is not AWS (\`S3_REGION=auto\` for
1068
- R2). The same board runs either way.
1069
-
1070
- ## Mail, in one click
1058
+ \`DIRECT_DATABASE_URL\` comes from Neon's own \`DATABASE_URL_UNPOOLED\`, or
1059
+ \`POSTGRES_URL_NON_POOLING\` if that one is absent \u2014 migrations and the first-run
1060
+ installer each hold a session-level advisory lock, which the pooled
1061
+ \`DATABASE_URL\` cannot hold. \`REDIS_URL\` comes from Upstash's \`KV_URL\`, the one
1062
+ variable it publishes that speaks the Redis protocol.
1063
+
1064
+ Every one of those derivations is scoped to this platform, fires only where you
1065
+ have not set the variable yourself, and **refuses to boot rather than guess**.
1066
+ If a store is missing, or publishes a name this board does not know, the deploy
1067
+ stops with a message naming every variable it looked at \u2014 it will not fall back
1068
+ to caching inside each instance, or to uploads on a disk that is discarded with
1069
+ the instance. When the name is one we do not know, set \`REDIS_URL\` or
1070
+ \`DIRECT_DATABASE_URL\` in the project's environment settings and the derivation
1071
+ stands aside.
1072
+
1073
+ If you would rather keep uploads somewhere you hold yourself \u2014 see *Leaving
1074
+ Vercel* below for why that matters \u2014 set \`FILESTORE_DRIVER=s3\` and add
1075
+ \`S3_BUCKET\`, \`S3_REGION\`, \`S3_ACCESS_KEY_ID\` and \`S3_SECRET_ACCESS_KEY\` in the
1076
+ project's environment settings, with \`S3_ENDPOINT\` for a bucket that is not AWS
1077
+ (\`S3_REGION=auto\` for R2). The same board runs either way.
1078
+
1079
+ ## Mail, after the deploy
1071
1080
 
1072
1081
  A board that cannot send mail cannot reset a password, so do this before you
1073
- invite anybody.
1082
+ invite anybody. The deploy form already added Resend and published its key; two
1083
+ steps remain, and neither could have been answered on the form.
1074
1084
 
1075
- 1. Open your project on Vercel, go to **Storage \u2192 Marketplace** (or
1076
- **Integrations**), and add **Resend**. It creates a Resend account linked to
1077
- the project and connects your sending domain.
1078
- 2. Verify that domain in the Resend dashboard if you have not already. Resend
1079
- refuses to send from an address at a domain it has not verified.
1085
+ 1. Verify your sending domain in the Resend dashboard if you have not already.
1086
+ Resend refuses to send from an address at a domain it has not verified.
1087
+ 2. Add \`MAIL_FROM\` to the project's environment settings \u2014 an address at that
1088
+ verified domain, and the only mail value you ever type.
1080
1089
  3. Redeploy, or let the next push redeploy.
1081
1090
 
1082
- That is all. The integration publishes its key into the project as
1083
- \`RESEND_API_KEY\`, and the board reads that name: with it set, and \`MAIL_FROM\`
1084
- already in place, mail sends over Resend's HTTPS API with nothing further to
1085
- configure.
1091
+ That is all. The integration published its key into the project as
1092
+ \`RESEND_API_KEY\` when you deployed, and the board reads that name: with it set,
1093
+ and \`MAIL_FROM\` beside it, mail sends over Resend's HTTPS API with nothing
1094
+ further to configure.
1086
1095
 
1087
1096
  The board is not tied to Resend. Its mail driver is a plain JSON-over-HTTPS
1088
1097
  sender that posts \`{from, to, subject, text, html, reply_to}\` with a bearer
@@ -1116,15 +1125,23 @@ drains; nothing here runs it on its own, because there is no worker process on
1116
1125
  a function platform. Two things about it are worth knowing **before** you
1117
1126
  deploy rather than after:
1118
1127
 
1119
- - **A per-minute schedule needs a paid plan.** Hobby allows a couple of cron
1120
- jobs and runs each of them roughly once a day, at an hour Vercel chooses;
1121
- only paid plans accept an arbitrary cron expression. A board ticking daily
1122
- still loses nothing \u2014 tasks are written so a missed run delays work rather
1123
- than dropping it \u2014 but "as it happens" notifications become a daily digest in
1124
- all but name. To keep a minute-by-minute tick on Hobby, drive
1125
- \`${TICK_PATH}\` from something else that can call a URL on a schedule \u2014 a
1126
- GitHub Actions workflow, a systemd timer, an uptime pinger \u2014 presenting
1127
- \`TICK_SECRET\` instead.
1128
+ - **This ships a daily schedule, because Hobby refuses anything faster.** A
1129
+ Hobby plan rejects a cron expression that would run more than once a day \u2014
1130
+ the deployment fails outright rather than being slowed down \u2014 so
1131
+ \`vercel.json\` carries \`${TICK_SCHEDULE}\` and deploys anywhere. On a
1132
+ paid plan, edit it to \`* * * * *\` and the board ticks every minute.
1133
+
1134
+ A daily tick loses nothing permanently: tasks are written so a missed run
1135
+ delays work rather than dropping it, and a password reset is sent as the
1136
+ request is handled rather than waiting for a tick. What it does delay is
1137
+ everything the tick drives \u2014 a new post is not findable in search, and a
1138
+ notification is not sent, until the next run.
1139
+
1140
+ **To keep a fast tick without paying**, drive \`${TICK_PATH}\` from anything
1141
+ that can call a URL on a schedule \u2014 a GitHub Actions workflow, a systemd
1142
+ timer, an uptime pinger \u2014 presenting \`TICK_SECRET\` instead of
1143
+ \`CRON_SECRET\`. The endpoint accepts either, so the Vercel cron and an
1144
+ outside scheduler can both drive it.
1128
1145
  - **\`maxDuration = 300\` is validated when the project builds, not when the
1129
1146
  function runs.** A plan that does not allow 300 seconds therefore **fails the
1130
1147
  deployment** rather than clamping the request. With Fluid Compute \u2014 the
@@ -1178,13 +1195,19 @@ a Blob store does not:
1178
1195
  DATABASE_URL=\u2026 # Neon's pooled string
1179
1196
  DIRECT_DATABASE_URL=\u2026 # Neon's DATABASE_URL_UNPOOLED
1180
1197
  FILESTORE_DRIVER=blob
1181
- BLOB_READ_WRITE_TOKEN=\u2026 # copy it out of the project's environment settings
1198
+ BLOB_READ_WRITE_TOKEN=\u2026 # create one on the store; see below
1182
1199
  npm run community -- backup
1183
1200
  \`\`\`
1184
1201
 
1185
1202
  Run that from a checkout of this repository, with those four values in the
1186
1203
  environment \u2014 the CLI talks to Neon and to the Blob store over the network, so
1187
- it does not have to run on Vercel. The bundle it writes holds the dump *and*
1204
+ it does not have to run on Vercel.
1205
+
1206
+ That last one is the one value this route asks you to make by hand, and only
1207
+ here. On the deployment the board reaches the store with \`BLOB_STORE_ID\` and
1208
+ the deployment's OIDC identity, which a command on your own machine does not
1209
+ have. Open the store under **Storage**, create a read-write token, and use it
1210
+ for the backup; the board itself never needs it. The bundle it writes holds the dump *and*
1188
1211
  every object. Check the last line it prints: if it says *no uploads*, the
1189
1212
  uploads are not in the bundle and restoring it gives a board whose posts have
1190
1213
  broken images.
@@ -1317,7 +1340,7 @@ async function run(argv, version) {
1317
1340
  }
1318
1341
 
1319
1342
  // src/bin.ts
1320
- var result = await run(process.argv.slice(2), "0.18.0");
1343
+ var result = await run(process.argv.slice(2), "0.20.0");
1321
1344
  for (const line of result.lines) {
1322
1345
  if (result.code === 0) console.log(line);
1323
1346
  else console.error(line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-meith",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Scaffold a Meith board — npx create-meith <name> writes a workspace that depends on @meith/web and @meith/cli.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/bin.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from './cli'
3
3
 
4
- const result = await run(process.argv.slice(2), '0.18.0')
4
+ const result = await run(process.argv.slice(2), '0.20.0')
5
5
  for (const line of result.lines) {
6
6
  if (result.code === 0) console.log(line)
7
7
  else console.error(line)
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ export {
10
10
  TICK_PATH,
11
11
  TICK_SCHEDULE,
12
12
  VERCEL_BUILD_COMMAND,
13
- VERCEL_MARKETPLACE_STORES,
13
+ VERCEL_MARKETPLACE_PRODUCTS,
14
14
  VERCEL_PROMPTED_ENV,
15
15
  validateName,
16
16
  } from './scaffold'
package/src/scaffold.ts CHANGED
@@ -33,7 +33,7 @@ export const VERCEL_BUILD_COMMAND = `community migrate && forum-web build ${AT_R
33
33
 
34
34
  export const TICK_PATH = '/api/system/tick'
35
35
 
36
- export const TICK_SCHEDULE = '* * * * *'
36
+ export const TICK_SCHEDULE = '0 3 * * *'
37
37
 
38
38
  export const MATERIALIZED_PUBLIC = [
39
39
  'placeholder-logo.png',
@@ -200,25 +200,24 @@ function vercelEnvExample(name: string): string {
200
200
  # no other instance can read and that is discarded with the instance — on Vercel
201
201
  # the board refuses it outright rather than losing uploads quietly.
202
202
  #
203
- # CACHE_DRIVER and FILESTORE_DRIVER are asked for at deploy time. The other
204
- # three derive themselves: a DATABASE_URL means postgres for the data source and
205
- # the queue, and a RESEND_API_KEY with a MAIL_FROM beside it means mail over the
206
- # provider's HTTPS API. Setting one here overrides the derivation, which is what
207
- # this file is for when you copy it to .env.local.
203
+ # The deploy form asks for none of them. On Vercel the board works each one out
204
+ # from what the linked stores publish: a DATABASE_URL means postgres for the
205
+ # data source and the queue, a Redis connection string means CACHE_DRIVER=redis,
206
+ # a Blob store's read-write token means FILESTORE_DRIVER=blob, and a
207
+ # RESEND_API_KEY with a MAIL_FROM beside it means mail over the provider's HTTPS
208
+ # API. Setting one here overrides the derivation, which is what this file is for
209
+ # when you copy it to .env.local.
208
210
  #
209
- # The two that stay explicit stay that way on purpose: each turns on an external
210
- # service the board then depends on for every request, and neither is switched on
211
- # by a variable that merely happens to be present. The board caches in Redis and
212
- # puts uploads in an object store because you said so.
211
+ # Every one of those derivations is scoped to Vercel, and each fires only from a
212
+ # value that is unambiguously the thing itself a redis:// or rediss:// URL, a
213
+ # read-write token. A board you run anywhere else is untouched by all of it and
214
+ # still takes these values from this file, exactly as it did before.
213
215
  #
214
- # What happens if you leave one blank differs, and the difference is the reason
215
- # the form asks for both. Leave FILESTORE_DRIVER blank and the board refuses to
216
- # boot at all: the default is local, and local on this platform means uploads
217
- # written to a disk that is about to disappear. Leave CACHE_DRIVER blank and
218
- # nothing complains the board falls back to caching inside each instance and
219
- # serves whatever that instance last saw, for up to a minute, however many
220
- # instances there are. That one degrades quietly, which is exactly why it is
221
- # worth typing rather than leaving to a default.
216
+ # A derivation that cannot resolve is a configuration error, not an invitation
217
+ # to pick something safe-looking. On Vercel, with the cache or the object store
218
+ # missing, the board refuses to boot and names every variable it looked at. It
219
+ # will not quietly cache inside the instance, and it will not quietly write
220
+ # uploads to a disk that is about to disappear.
222
221
  DATA_SOURCE=postgres
223
222
  QUEUE_DRIVER=postgres
224
223
  CACHE_DRIVER=redis
@@ -232,16 +231,20 @@ DATABASE_URL=
232
231
 
233
232
  ${ENV_DIRECT_DATABASE_URL_PROSE}
234
233
  #
235
- # On Vercel this is not optional. DATABASE_URL here is the pooler string, the
236
- # build runs \`community migrate\` against it, and /install takes the second of
237
- # those two session locks on first run. Neon publishes the direct string as
238
- # DATABASE_URL_UNPOOLED; copy that value into this variable.
234
+ # On Vercel this is not optional, and it is no longer yours to copy. DATABASE_URL
235
+ # here is the pooler string, the build runs \`community migrate\` against it, and
236
+ # /install takes the second of those two session locks on first run. Left blank,
237
+ # the board reads Neon's own direct string — \`DATABASE_URL_UNPOOLED\` first, then
238
+ # \`POSTGRES_URL_NON_POOLING\` — and refuses to boot if neither is there, naming
239
+ # both. Never \`POSTGRES_URL\`: that one is pooled.
239
240
  DIRECT_DATABASE_URL=
240
241
 
241
242
  # The shared cache — a Redis or Valkey endpoint, \`rediss://\` for TLS. Redis
242
243
  # holds cache entries and nothing else: losing it costs the board a warm cache,
243
- # not data, and signs nobody out. A store provisioned with the board publishes
244
- # its own connection variable; copy that value into this one.
244
+ # not data, and signs nobody out. Left blank on Vercel, the board reads the
245
+ # Upstash store's own \`KV_URL\`, which is the one variable it publishes that
246
+ # speaks the Redis protocol — \`KV_REST_API_URL\` is an HTTPS endpoint and is
247
+ # never used for this. A name we do not know goes here by hand.
245
248
  REDIS_URL=
246
249
 
247
250
  ${ENV_AUTH_SECRET_PROSE}
@@ -257,15 +260,26 @@ ${ENV_TICK_SECRET_PROSE}
257
260
  CRON_SECRET=
258
261
  # TICK_SECRET=
259
262
 
260
- # Uploads, in the Vercel Blob store the Deploy Button provisions. The store
261
- # publishes this variable into the project by itself, so there is nothing to
262
- # type and nothing to mistype but the board still needs FILESTORE_DRIVER=blob
263
- # above before it will use it. Every object is written with private access: an
264
- # object URL is not a public link, and member content is served by the board,
265
- # which is where permissions are checked.
263
+ # Uploads, in the Vercel Blob store the Deploy Button provisions. A store
264
+ # attached to the project publishes BLOB_STORE_ID and nothing else no token —
265
+ # because the SDK authenticates with the deployment's own OIDC identity: the
266
+ # board hands it the store id and lets it fetch the credential. There is nothing
267
+ # to type and nothing to mistype, and FILESTORE_DRIVER=blob derives from this
268
+ # variable being present.
266
269
  #
267
- # An upload is held whole in the instance's memory on the way in and on the way
268
- # out, so the function's memory limit, not the store, is what caps a file.
270
+ # BLOB_READ_WRITE_TOKEN is the other way in, and you make it yourself on the
271
+ # store. Set it when something has to reach the store from OUTSIDE a Vercel
272
+ # deployment — \`community backup\` run on your own machine is the case that
273
+ # matters — because there is no OIDC identity there to borrow. Set both and the
274
+ # board prefers the store id, unless the token names a different store, in which
275
+ # case the token wins: naming another store is a deliberate act.
276
+ #
277
+ # Every object is written with private access: an object URL is not a public
278
+ # link, and member content is served by the board, which is where permissions
279
+ # are checked. An upload is held whole in the instance's memory on the way in
280
+ # and on the way out, so the function's memory limit, not the store, is what
281
+ # caps a file.
282
+ BLOB_STORE_ID=
269
283
  BLOB_READ_WRITE_TOKEN=
270
284
 
271
285
  # Uploads in an S3-compatible bucket instead — AWS, R2, MinIO, Spaces. This is
@@ -288,10 +302,10 @@ BLOB_READ_WRITE_TOKEN=
288
302
  # board refuses it on Vercel; 587 with STARTTLS may work, but an API does not
289
303
  # depend on the platform's egress rules staying as they are.
290
304
  #
291
- # MAIL_FROM is yours to decide and the one mail value the button asks for: it
292
- # must be an address at a domain the provider has verified for you, and no
293
- # default is right. Send from an unverified sender and the provider rejects
294
- # every message.
305
+ # MAIL_FROM is yours to decide, and it belongs to the step that adds Resend
306
+ # rather than to the deploy: it must be an address at a domain the provider has
307
+ # verified for you, no default is right, and until a provider is added the board
308
+ # cannot send from any address at all. Set it alongside the integration.
295
309
  MAIL_FROM=
296
310
 
297
311
  # Add the Resend integration to the project from Vercel's marketplace and it
@@ -335,25 +349,17 @@ const SELF_HOST_DEPLOY_KIT = [
335
349
  'docker-healthcheck.sh',
336
350
  ] as const
337
351
 
338
- const VERCEL_TYPED_DRIVERS = ['CACHE_DRIVER=redis', 'FILESTORE_DRIVER=blob'] as const
339
-
340
352
  const VERCEL_DERIVED_DRIVERS = [
341
353
  'DATA_SOURCE=postgres',
342
354
  'QUEUE_DRIVER=postgres',
355
+ 'CACHE_DRIVER=redis',
356
+ 'FILESTORE_DRIVER=blob',
343
357
  'MAIL_DRIVER=http',
344
358
  ] as const
345
359
 
346
- export const VERCEL_PROMPTED_ENV = [
347
- 'CACHE_DRIVER',
348
- 'FILESTORE_DRIVER',
349
- 'DIRECT_DATABASE_URL',
350
- 'REDIS_URL',
351
- 'AUTH_SECRET',
352
- 'CRON_SECRET',
353
- 'MAIL_FROM',
354
- ] as const
360
+ export const VERCEL_PROMPTED_ENV = ['AUTH_SECRET', 'CRON_SECRET'] as const
355
361
 
356
- export const VERCEL_MARKETPLACE_STORES = [
362
+ export const VERCEL_MARKETPLACE_PRODUCTS = [
357
363
  { type: 'integration', integrationSlug: 'neon', productSlug: 'neon', protocol: 'storage' },
358
364
  {
359
365
  type: 'integration',
@@ -362,6 +368,12 @@ export const VERCEL_MARKETPLACE_STORES = [
362
368
  protocol: 'storage',
363
369
  },
364
370
  { type: 'blob' },
371
+ {
372
+ type: 'integration',
373
+ integrationSlug: 'resend',
374
+ productSlug: 'resend-email',
375
+ protocol: 'messaging',
376
+ },
365
377
  ] as const
366
378
 
367
379
  export function deployButtonUrl(templateRepositoryUrl: string): string {
@@ -372,10 +384,10 @@ export function deployButtonUrl(templateRepositoryUrl: string): string {
372
384
  ['env', VERCEL_PROMPTED_ENV.join(',')],
373
385
  [
374
386
  'envDescription',
375
- 'Two driver values, two secrets to generate, the direct database URL, the cache URL, and the verified address the board sends from.',
387
+ 'Two secrets, generated rather than chosen — 32 characters or more each. Everything else the board reads from the database, cache, blob store and mail provider this form links.',
376
388
  ],
377
389
  ['envLink', `${templateRepositoryUrl}/blob/main/.env.example`],
378
- ['stores', JSON.stringify(VERCEL_MARKETPLACE_STORES)],
390
+ ['products', JSON.stringify(VERCEL_MARKETPLACE_PRODUCTS)],
379
391
  ['skippable-integrations', '1'],
380
392
  ])
381
393
 
@@ -1054,20 +1066,27 @@ A forum, built on [Meith](${repositoryUrl}), running as Vercel functions.
1054
1066
  - **A Neon Postgres database**, attached to the project. Neon publishes the
1055
1067
  pooled connection string as \`DATABASE_URL\` and the direct one as
1056
1068
  \`DATABASE_URL_UNPOOLED\`.
1057
- - **An Upstash Redis store**, attached the same way, for the shared cache.
1058
- It publishes its own connection variable.
1059
- - **A Vercel Blob store** for uploads, which publishes \`BLOB_READ_WRITE_TOKEN\`
1060
- into the project by itself. This is the one value that used to be four hand-
1061
- typed \`S3_*\` secrets.
1069
+ - **An Upstash Redis store**, attached the same way, for the shared cache. It
1070
+ publishes \`KV_URL\`, which the board reads as \`REDIS_URL\` — \`KV_REST_API_URL\`
1071
+ beside it is an HTTPS endpoint and is not used for this.
1072
+ - **A Vercel Blob store** for uploads, which publishes \`BLOB_STORE_ID\` into the
1073
+ project by itself. That is the whole credential: the board hands the id to
1074
+ Vercel's SDK, which authenticates with the deployment's own OIDC identity, so
1075
+ there is no token to copy. This is what used to be four hand-typed \`S3_*\`
1076
+ secrets.
1077
+ - **A Resend mail account**, attached the same way, which publishes
1078
+ \`RESEND_API_KEY\`. The board reads that name directly: its mail driver already
1079
+ speaks Resend's request shape, so there is nothing to adapt.
1062
1080
  - **A Vercel project** carrying \`vercel.json\` — the build command
1063
1081
  \`${VERCEL_BUILD_COMMAND}\`,
1064
1082
  which applies the schema before it builds, materializes the board's app at
1065
1083
  the project root so the artefact lands where Vercel reads it, and the cron
1066
1084
  entry that drives the tick.
1067
1085
 
1068
- **Mail is the one thing the button does not set up**, and it takes one click
1069
- after the deploy see *Mail, in one click* below. The board boots and runs
1070
- without it, and delivers nothing, silently, until it is done.
1086
+ **Mail needs one thing after the deploy**: the address the board sends from.
1087
+ It has to be at a domain Resend has verified, and no form can ask for that
1088
+ before the account exists see *Mail, after the deploy* below. The board boots
1089
+ and runs without it, and delivers nothing, silently, until it is done.
1071
1090
 
1072
1091
  ## What to type into the deploy form
1073
1092
 
@@ -1080,71 +1099,60 @@ openssl rand -hex 32 # CRON_SECRET
1080
1099
  \`\`\`
1081
1100
 
1082
1101
  \`CRON_SECRET\` is the name Vercel Cron sends, as \`Authorization: Bearer\`, and it
1083
- cannot be told to send another. Note that this floor is stricter than the 16
1102
+ cannot be told to send another the caller is the platform, so this one has to
1103
+ be an environment variable both ends can read, and cannot be something the
1104
+ board makes up for itself. Note that this floor is stricter than the 16
1084
1105
  characters Vercel's own cron documentation suggests — a value generated by
1085
1106
  following those instructions is refused here, and the fix is a longer secret.
1086
1107
 
1087
- **Two driver values**, and they are the two that turn on an external service
1088
- the board then needs for every request:
1108
+ \`AUTH_SECRET\` seals members' two-factor secrets and signs the unsubscribe links
1109
+ in outgoing mail. It stays in the environment deliberately: a copy of the
1110
+ database is then not enough to forge either.
1089
1111
 
1090
- \`\`\`ini
1091
- ${VERCEL_TYPED_DRIVERS.join('\n')}
1092
- \`\`\`
1093
-
1094
- Neither is inferred, deliberately: the board caches in Redis and puts uploads
1095
- in an object store because you said so, never because a connection string or a
1096
- token happened to be present.
1097
-
1098
- Leaving them blank fails differently, which is why the form asks for both.
1099
- Blank \`FILESTORE_DRIVER\` means \`local\`, and the board **refuses to boot** on
1100
- this platform rather than write uploads to a disk that is about to disappear.
1101
- Blank \`CACHE_DRIVER\` complains about nothing: the board falls back to caching
1102
- inside each instance, and every instance then serves whatever it last saw for
1103
- up to a minute. That one degrades quietly, so type it.
1104
-
1105
- The remaining three drivers do derive themselves, and the form does not ask:
1106
- a \`DATABASE_URL\` means \`DATA_SOURCE=postgres\` and \`QUEUE_DRIVER=postgres\`, and
1107
- a \`RESEND_API_KEY\` with a \`MAIL_FROM\` beside it means \`MAIL_DRIVER=http\`:
1112
+ **That is the whole form.** Everything else the board works out from the stores
1113
+ this button just linked to the project:
1108
1114
 
1109
1115
  \`\`\`ini
1110
1116
  ${VERCEL_DERIVED_DRIVERS.join('\n')}
1111
1117
  \`\`\`
1112
1118
 
1113
- **Two connection strings copied from the stores the button just created.**
1114
- \`DIRECT_DATABASE_URL\` takes Neon's \`DATABASE_URL_UNPOOLED\` — migrations and the
1115
- first-run installer each hold a session-level advisory lock, which a
1116
- transaction-mode pooler cannot hold. \`REDIS_URL\` takes whatever variable the
1117
- Upstash store published.
1118
-
1119
- **The address the board sends from**: \`MAIL_FROM\`. This is the only mail value
1120
- you ever type. It has to be an address at a domain your mail provider has
1121
- verified for you, so no default is right and the board cannot guess it see
1122
- *Mail, in one click* below. If you do not have one yet, put in the address you
1123
- intend to use and finish verifying it afterwards; nothing else waits on it.
1124
-
1125
- That is seven fields. If you would rather keep uploads somewhere you hold
1126
- yourself — see *Leaving Vercel* below for why that matters — set
1127
- \`FILESTORE_DRIVER=s3\` instead and add \`S3_BUCKET\`, \`S3_REGION\`,
1128
- \`S3_ACCESS_KEY_ID\` and \`S3_SECRET_ACCESS_KEY\` in the project's environment
1129
- settings, with \`S3_ENDPOINT\` for a bucket that is not AWS (\`S3_REGION=auto\` for
1130
- R2). The same board runs either way.
1131
-
1132
- ## Mail, in one click
1119
+ \`DIRECT_DATABASE_URL\` comes from Neon's own \`DATABASE_URL_UNPOOLED\`, or
1120
+ \`POSTGRES_URL_NON_POOLING\` if that one is absent — migrations and the first-run
1121
+ installer each hold a session-level advisory lock, which the pooled
1122
+ \`DATABASE_URL\` cannot hold. \`REDIS_URL\` comes from Upstash's \`KV_URL\`, the one
1123
+ variable it publishes that speaks the Redis protocol.
1124
+
1125
+ Every one of those derivations is scoped to this platform, fires only where you
1126
+ have not set the variable yourself, and **refuses to boot rather than guess**.
1127
+ If a store is missing, or publishes a name this board does not know, the deploy
1128
+ stops with a message naming every variable it looked at it will not fall back
1129
+ to caching inside each instance, or to uploads on a disk that is discarded with
1130
+ the instance. When the name is one we do not know, set \`REDIS_URL\` or
1131
+ \`DIRECT_DATABASE_URL\` in the project's environment settings and the derivation
1132
+ stands aside.
1133
+
1134
+ If you would rather keep uploads somewhere you hold yourself — see *Leaving
1135
+ Vercel* below for why that matters set \`FILESTORE_DRIVER=s3\` and add
1136
+ \`S3_BUCKET\`, \`S3_REGION\`, \`S3_ACCESS_KEY_ID\` and \`S3_SECRET_ACCESS_KEY\` in the
1137
+ project's environment settings, with \`S3_ENDPOINT\` for a bucket that is not AWS
1138
+ (\`S3_REGION=auto\` for R2). The same board runs either way.
1139
+
1140
+ ## Mail, after the deploy
1133
1141
 
1134
1142
  A board that cannot send mail cannot reset a password, so do this before you
1135
- invite anybody.
1143
+ invite anybody. The deploy form already added Resend and published its key; two
1144
+ steps remain, and neither could have been answered on the form.
1136
1145
 
1137
- 1. Open your project on Vercel, go to **Storage Marketplace** (or
1138
- **Integrations**), and add **Resend**. It creates a Resend account linked to
1139
- the project and connects your sending domain.
1140
- 2. Verify that domain in the Resend dashboard if you have not already. Resend
1141
- refuses to send from an address at a domain it has not verified.
1146
+ 1. Verify your sending domain in the Resend dashboard if you have not already.
1147
+ Resend refuses to send from an address at a domain it has not verified.
1148
+ 2. Add \`MAIL_FROM\` to the project's environment settings an address at that
1149
+ verified domain, and the only mail value you ever type.
1142
1150
  3. Redeploy, or let the next push redeploy.
1143
1151
 
1144
- That is all. The integration publishes its key into the project as
1145
- \`RESEND_API_KEY\`, and the board reads that name: with it set, and \`MAIL_FROM\`
1146
- already in place, mail sends over Resend's HTTPS API with nothing further to
1147
- configure.
1152
+ That is all. The integration published its key into the project as
1153
+ \`RESEND_API_KEY\` when you deployed, and the board reads that name: with it set,
1154
+ and \`MAIL_FROM\` beside it, mail sends over Resend's HTTPS API with nothing
1155
+ further to configure.
1148
1156
 
1149
1157
  The board is not tied to Resend. Its mail driver is a plain JSON-over-HTTPS
1150
1158
  sender that posts \`{from, to, subject, text, html, reply_to}\` with a bearer
@@ -1178,15 +1186,23 @@ drains; nothing here runs it on its own, because there is no worker process on
1178
1186
  a function platform. Two things about it are worth knowing **before** you
1179
1187
  deploy rather than after:
1180
1188
 
1181
- - **A per-minute schedule needs a paid plan.** Hobby allows a couple of cron
1182
- jobs and runs each of them roughly once a day, at an hour Vercel chooses;
1183
- only paid plans accept an arbitrary cron expression. A board ticking daily
1184
- still loses nothing tasks are written so a missed run delays work rather
1185
- than dropping it but "as it happens" notifications become a daily digest in
1186
- all but name. To keep a minute-by-minute tick on Hobby, drive
1187
- \`${TICK_PATH}\` from something else that can call a URL on a schedule — a
1188
- GitHub Actions workflow, a systemd timer, an uptime pinger — presenting
1189
- \`TICK_SECRET\` instead.
1189
+ - **This ships a daily schedule, because Hobby refuses anything faster.** A
1190
+ Hobby plan rejects a cron expression that would run more than once a day
1191
+ the deployment fails outright rather than being slowed down so
1192
+ \`vercel.json\` carries \`${TICK_SCHEDULE}\` and deploys anywhere. On a
1193
+ paid plan, edit it to \`* * * * *\` and the board ticks every minute.
1194
+
1195
+ A daily tick loses nothing permanently: tasks are written so a missed run
1196
+ delays work rather than dropping it, and a password reset is sent as the
1197
+ request is handled rather than waiting for a tick. What it does delay is
1198
+ everything the tick drives — a new post is not findable in search, and a
1199
+ notification is not sent, until the next run.
1200
+
1201
+ **To keep a fast tick without paying**, drive \`${TICK_PATH}\` from anything
1202
+ that can call a URL on a schedule — a GitHub Actions workflow, a systemd
1203
+ timer, an uptime pinger — presenting \`TICK_SECRET\` instead of
1204
+ \`CRON_SECRET\`. The endpoint accepts either, so the Vercel cron and an
1205
+ outside scheduler can both drive it.
1190
1206
  - **\`maxDuration = 300\` is validated when the project builds, not when the
1191
1207
  function runs.** A plan that does not allow 300 seconds therefore **fails the
1192
1208
  deployment** rather than clamping the request. With Fluid Compute — the
@@ -1240,13 +1256,19 @@ a Blob store does not:
1240
1256
  DATABASE_URL=… # Neon's pooled string
1241
1257
  DIRECT_DATABASE_URL=… # Neon's DATABASE_URL_UNPOOLED
1242
1258
  FILESTORE_DRIVER=blob
1243
- BLOB_READ_WRITE_TOKEN=… # copy it out of the project's environment settings
1259
+ BLOB_READ_WRITE_TOKEN=… # create one on the store; see below
1244
1260
  npm run community -- backup
1245
1261
  \`\`\`
1246
1262
 
1247
1263
  Run that from a checkout of this repository, with those four values in the
1248
1264
  environment — the CLI talks to Neon and to the Blob store over the network, so
1249
- it does not have to run on Vercel. The bundle it writes holds the dump *and*
1265
+ it does not have to run on Vercel.
1266
+
1267
+ That last one is the one value this route asks you to make by hand, and only
1268
+ here. On the deployment the board reaches the store with \`BLOB_STORE_ID\` and
1269
+ the deployment's OIDC identity, which a command on your own machine does not
1270
+ have. Open the store under **Storage**, create a read-write token, and use it
1271
+ for the backup; the board itself never needs it. The bundle it writes holds the dump *and*
1250
1272
  every object. Check the last line it prints: if it says *no uploads*, the
1251
1273
  uploads are not in the bundle and restoring it gives a board whose posts have
1252
1274
  broken images.