thurview 0.20.0 → 0.22.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.
@@ -0,0 +1,492 @@
1
+ ---
2
+ name: thurview-publish
3
+ description: Publish or update a thurview review, explainer or design as one self-contained HTML page in the user's own cloud - an Azure Storage container, an AWS S3 bucket, a Google Cloud Storage bucket or a Cloudflare Pages project - through the user's own CLI and login, at one stable unguessable path per document, and hand back a link that expires (an Azure SAS, an S3 presigned URL, a GCS signed URL) unless the user confirms a public copy. Refuses a target whose allow_remotes or deny_remotes rule out the repository, so a personal target never receives work code. Also re-signs a link, and takes a copy down. Use when the user asks to publish, upload, host, share a link to or update a published thurview document, to re-sign or refresh its link, or to unpublish it, or invokes /thurview-publish. Not for authoring the document, which is the thurview, thurview-explain or thurview-design skill.
4
+ user-invocable: true
5
+ argument-hint: "<review id> [--provider azure|aws|gcp|cloudflare] [--days N] [--public] [--resign | --unpublish]"
6
+ ---
7
+
8
+ # thurview publish
9
+
10
+ Put a thurview document where someone without thurview can open it, in the
11
+ user's own cloud, and give them a link that stops working on its own.
12
+
13
+ ```mermaid
14
+ flowchart LR
15
+ S{repository's remotes vs the target's scope} -->|allowed, or the user said yes| A[thurview export]
16
+ S -->|denied| X[refuse, and say why]
17
+ A --> B[index.html]
18
+ B --> C{provider}
19
+ C -->|azure / aws / gcp| D[private object at prefix/slug/index.html]
20
+ D --> E[signed link: 7 days at most, 12 hours on GCP]
21
+ C -->|cloudflare| F[Pages branch named slug]
22
+ F --> G[the alias wrangler prints]
23
+ C -.->|only after 'This will be public' and a yes| H[public copy, noindex]
24
+ ```
25
+
26
+ Four rules hold on every provider:
27
+
28
+ - **The user's own target, and only for code it is meant for.** A target
29
+ belongs to its user, and some are personal only - never for work or
30
+ company code. Before exporting anything, run the
31
+ [scope check](#0-check-the-target-may-take-this-repository): refuse on a
32
+ deny match, or on no allow match when the target has an allow list, and
33
+ say which remote and pattern decided it. With no scope configured, ask the
34
+ user whether this target is right for this repository, and wait for a yes.
35
+ - **The user's own login, never a secret.** Every command below runs as the
36
+ identity the user's CLI is already [signed in](#signing-in) as. Never ask
37
+ for, store, paste or pass an account key, connection string, secret access
38
+ key, service-account key file or API token, and never set one in the
39
+ environment yourself. If the CLI is not signed in, stop and tell the user
40
+ which login command to run - do not work around it.
41
+ - **Ask before the first upload.** Name the target, its provider, the bucket
42
+ or container, the object path, the expiry and whether the reader's threads
43
+ go in, and wait for a yes. An update of a copy the user already published
44
+ needs no second yes unless the target or the visibility changes.
45
+ - **Private, with a link that expires, by default.** The object stays private
46
+ and the link you hand back is signed for `expiry_days` (default 7), clamped
47
+ to the cloud's own limit: 7 days on Azure and AWS, 12 hours on GCP. A
48
+ public copy is opt-in only: see
49
+ [Public copies](#public-copies-only-on-confirmation).
50
+
51
+ ## Signing in
52
+
53
+ Interactive login is the default: it is how a person publishes from their
54
+ own machine, and the CLI keeps the session in its own user config, not in
55
+ anything thurview or the repository holds. Environment credentials are only
56
+ for a non-interactive run - CI, a scheduled job - where whoever owns that
57
+ environment sets them; the skill never does.
58
+
59
+ | Provider | Interactive, the default | Non-interactive only |
60
+ | ---------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
61
+ | Azure | `az login` | `az login --identity` (a managed identity), or a federated service principal the pipeline signs in |
62
+ | AWS | `aws sso login --profile <profile>` | the role the environment provides: an instance or task role, or web identity (`AWS_ROLE_ARN` with `AWS_WEB_IDENTITY_TOKEN_FILE`) |
63
+ | GCP | `gcloud auth login` | the attached service account, or workload identity federation (`gcloud auth login --cred-file=<config>`, no key) |
64
+ | Cloudflare | `wrangler login` (OAuth, kept in wrangler's own user config) | `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, set in that environment by its owner |
65
+
66
+ ## The config
67
+
68
+ The settings live in `${THURVIEW_HOME:-$HOME/.thurview}/publish.yaml`, the
69
+ user's own file beside thurview's review store - never in a repository, and
70
+ never in the exported page. Read it before anything else. When it is missing,
71
+ ask the user for their target and its scope, write it, and show it to them.
72
+
73
+ A user can have several targets - a personal one, a work one - each with its
74
+ own provider and its own scope. This one keeps a personal Cloudflare project
75
+ for the user's own repositories, never a company's, and a work Azure account
76
+ for the company's code only. A deny always wins over an allow:
77
+
78
+ ```yaml
79
+ default: personal # the target used when the user names none
80
+ prefix: thurview # every copy goes under <prefix>/<slug>/index.html
81
+ expiry_days: 7 # whole days, 1 or more; clamped to 7 on Azure and AWS, 12 hours on GCP
82
+ targets:
83
+ personal:
84
+ provider: cloudflare
85
+ project: my-reviews
86
+ allow_remotes: # only my own repositories...
87
+ - github.com/example-user/**
88
+ deny_remotes: # ...and never the company's code, even under a name I own
89
+ - gitlab.example.com/**
90
+ - github.com/example-corp/**
91
+ work:
92
+ provider: azure
93
+ account: examplecorpreviews
94
+ container: reviews # a private container, not $web
95
+ allow_remotes: # this account takes the company's code and nothing else
96
+ - gitlab.example.com/**
97
+ aws:
98
+ provider: aws
99
+ bucket: my-review-bucket
100
+ profile: default # the AWS CLI profile or SSO session to use
101
+ region: eu-west-1
102
+ gcp:
103
+ provider: gcp
104
+ bucket: my-review-bucket
105
+ signer: thurview-signer@my-project.iam.gserviceaccount.com # impersonated to sign, never a key file
106
+ published: {} # <review id>: { target, slug, public, url, expires }
107
+ ```
108
+
109
+ A target names its `provider` and that provider's fields: `account` and
110
+ `container` for Azure, `bucket`, `profile` and `region` for AWS, `bucket` and
111
+ `signer` for GCP, `project` for Cloudflare. `allow_remotes` and
112
+ `deny_remotes` are globs over a remote's host and path, such as
113
+ `gitlab.example.com/team/**`: `*` stays inside one path segment, `**`
114
+ crosses them, and case does not matter. `published` is how a document keeps
115
+ one path: it maps each review id to the target and slug its copy lives
116
+ under. Write it after every upload, re-sign and unpublish, and update or
117
+ unpublish a copy only on the target it records.
118
+
119
+ Every block from step 1 on reads the config through these shell variables.
120
+ Run each operation - publish (steps 1 to 4), re-sign, unpublish - as **one** script
121
+ file run with `bash`, never typed into an interactive shell, that starts by
122
+ setting them, so no block runs in a shell where they are empty, and stops at
123
+ the first command that fails:
124
+
125
+ ```sh
126
+ set -euo pipefail
127
+ REVIEW="<review id>"
128
+ PROVIDER="<target.provider>" PREFIX="<prefix>" EXPIRY_DAYS="<expiry_days>"
129
+ SLUG="<published.<review id>.slug, or empty for a first upload>"
130
+ ACCOUNT="<target.account>" CONTAINER="<target.container>"
131
+ BUCKET="<target.bucket>" PROFILE="<target.profile>" REGION="<target.region>"
132
+ SIGNER="<target.signer>" PROJECT="<target.project>"
133
+ ```
134
+
135
+ For a public Azure copy, `CONTAINER` is `'$web'` instead - see
136
+ [Public copies](#public-copies-only-on-confirmation).
137
+
138
+ ## Workflow
139
+
140
+ ### 0. Check the target may take this repository
141
+
142
+ Before exporting or uploading anything - and again before every update,
143
+ since a repository's remotes can change - hold the review's repository to
144
+ the target's scope. Run it **on its own, before the publish script**, not
145
+ inside it, so its answer decides whether that script runs at all. The
146
+ repository is the review's worktree: `thurview info --all --fields worktree`
147
+ lists it beside the review's id. The script sits in this skill's own
148
+ directory; pass one `--deny` per `deny_remotes` entry and one `--allow` per
149
+ `allow_remotes` entry of the chosen target:
150
+
151
+ ```sh
152
+ node "<this skill's directory>/scripts/check-scope.mjs" --repo "<the review's worktree>" \
153
+ --deny '<a deny_remotes entry>' --allow '<an allow_remotes entry>'
154
+ ```
155
+
156
+ It checks every fetch and push URL of every remote, reduced to host and
157
+ path, and an ssh alias also by the host `ssh -G` says it names. It prints one
158
+ line per URL saying what decided it. Its exit code decides what happens
159
+ next:
160
+
161
+ | Exit | Meaning | Do |
162
+ | ---- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
163
+ | 0 | allowed: no remote denied, and every one allowed when a list is set | run the publish script |
164
+ | 1 | refused: a remote is denied, or matches no allow pattern | stop. Tell the user the remote and the pattern it printed, and that this target is not for this repository. Do not offer a way round it; the user changes the config if it is wrong |
165
+ | 2 | nothing to decide by: no scope set, no remote, no repository, a local-path remote | ask the user whether this target is right for this repository, naming its remotes; run the publish script only on a yes, and suggest adding a scope so the question does not come back |
166
+
167
+ A deny list only refuses the hosts and paths it names. For a personal target,
168
+ an `allow_remotes` list of the user's own namespaces is the stronger guard:
169
+ work code under a name nobody thought to deny is refused too.
170
+
171
+ ### 1. Export the page
172
+
173
+ ```sh
174
+ OUT="$(mktemp -d)"
175
+ trap 'rm -rf "$OUT"' EXIT
176
+ thurview export --review "$REVIEW" --out "$OUT" --no-threads
177
+ FILE="$OUT/index.html"
178
+ ```
179
+
180
+ The page is the one the `thurview` skill's _Sharing a copy that needs no
181
+ server_ section describes: read only, self-contained, fetching nothing,
182
+ naming no server and no local path, and marked `noindex, nofollow`.
183
+ `--no-threads` keeps the reader's threads and decisions out, because a link
184
+ travels further than the conversation it was sent in. Drop it only when the
185
+ user asked for the threads in the copy. The trap deletes `$OUT` when the
186
+ script ends, whether the upload worked or not.
187
+
188
+ ### 2. Find or mint the slug
189
+
190
+ `SLUG` is the one `published` recorded for this review, so the upload
191
+ overwrites the same object and the link the user already sent keeps pointing
192
+ at the current copy. A first upload mints one:
193
+
194
+ ```sh
195
+ SLUG="${SLUG:-r$(node -e 'console.log(require("node:crypto").randomBytes(12).toString("hex"))')}"
196
+ OBJECT="${PREFIX:?set PREFIX from publish.yaml}/$SLUG/index.html"
197
+ ```
198
+
199
+ `r` and 24 random hex characters: 96 bits, so a path cannot be guessed from
200
+ the review id, the title or another link, and short, lowercase and
201
+ alphanumeric, so Cloudflare Pages takes it unchanged as a branch alias.
202
+ Never derive the slug from the review id, a branch or the title. The prefix
203
+ is required: the grants below cover `<prefix>/*` and nothing else.
204
+
205
+ ### 3. Work out the expiry
206
+
207
+ ```sh
208
+ case "${EXPIRY_DAYS:-7}" in
209
+ "" | 0* | *[!0-9]*) echo "expiry_days must be a whole number of days, 1 or more" >&2 && exit 2 ;;
210
+ esac
211
+ MAX_SECONDS=604800
212
+ if [ "$PROVIDER" = gcp ]; then MAX_SECONDS=43200; fi
213
+ EXPIRY_SECONDS=$((${EXPIRY_DAYS:-7} * 86400))
214
+ if [ "$EXPIRY_SECONDS" -gt "$MAX_SECONDS" ]; then EXPIRY_SECONDS=$MAX_SECONDS; fi
215
+ EXPIRY_AT="$(node -e 'console.log(new Date(Date.now() + process.argv[1] * 1000).toISOString().slice(0, 16) + "Z")' "$EXPIRY_SECONDS")"
216
+ ```
217
+
218
+ | Provider | Longest link | Why |
219
+ | -------- | ---------------------------- | ----------------------------------------------------------------------- |
220
+ | Azure | 7 days | a user-delegation key, which signs the SAS, lives 7 days at most |
221
+ | AWS | 7 days, or the session's end | SigV4 presigning stops at 604800 s, and at the signing credential's end |
222
+ | GCP | 12 hours | a URL signed by impersonating a service account lives 12 hours at most |
223
+
224
+ When the user asked for more, say it was clamped and to what. On GCP that is
225
+ every request over half a day: say so before the first upload, and point at
226
+ [re-signing](#re-signing-a-link) for a fresh link.
227
+
228
+ ### 4. Upload and sign, per provider
229
+
230
+ Run only the provider's own section, then go to step 5.
231
+
232
+ #### Azure Storage
233
+
234
+ ```sh
235
+ az storage blob upload --auth-mode login --account-name "$ACCOUNT" \
236
+ --container-name "$CONTAINER" --name "$OBJECT" --file "$FILE" --overwrite \
237
+ --content-type "text/html; charset=utf-8" --content-cache-control "no-cache"
238
+ ```
239
+
240
+ Then sign it - for a private copy only; a public one's link is the static
241
+ website's, and a SAS on it would be recorded as an expiring link that is not
242
+ the one the reader should get:
243
+
244
+ ```sh
245
+ az storage blob generate-sas --auth-mode login --as-user \
246
+ --account-name "$ACCOUNT" --container-name "$CONTAINER" --name "$OBJECT" \
247
+ --permissions r --https-only --expiry "$EXPIRY_AT" --full-uri --output tsv
248
+ ```
249
+
250
+ It prints the full link. `--as-user` makes it a
251
+ user-delegation SAS, signed by a key Entra ID issues to the user rather than
252
+ by the account key, so it is revocable and names who signed it. It also ends
253
+ when the delegation key does, which is at most 7 days.
254
+
255
+ #### AWS S3
256
+
257
+ ```sh
258
+ aws s3 cp "$FILE" "s3://$BUCKET/$OBJECT" --profile "$PROFILE" --region "$REGION" \
259
+ --content-type "text/html; charset=utf-8" --cache-control "no-cache"
260
+ aws s3 presign "s3://$BUCKET/$OBJECT" --profile "$PROFILE" --region "$REGION" \
261
+ --expires-in "$EXPIRY_SECONDS"
262
+ ```
263
+
264
+ Presigning is local: the link is signed with the profile's credentials and
265
+ works only while they do. With SSO or an assumed role those are temporary, so
266
+ the link dies with the session, often within hours, and nothing here can read
267
+ when that is without printing the credentials. So for such a profile, record
268
+ `expires` as "when the session ends, at the latest `EXPIRY_AT`", and tell the
269
+ user exactly that; a 7-day link needs a long-lived identity the user chooses
270
+ to use for it. Keep the bucket's Block Public Access on.
271
+
272
+ #### Google Cloud Storage
273
+
274
+ ```sh
275
+ gcloud storage cp "$FILE" "gs://$BUCKET/$OBJECT" \
276
+ --content-type="text/html; charset=utf-8" --cache-control="no-cache"
277
+ ```
278
+
279
+ Then sign it, for a private copy only:
280
+
281
+ ```sh
282
+ gcloud storage sign-url "gs://$BUCKET/$OBJECT" --duration="${EXPIRY_SECONDS}s" \
283
+ --impersonate-service-account="$SIGNER"
284
+ ```
285
+
286
+ A user's own Google login cannot sign a URL; only a service account can. So
287
+ the link is signed by impersonating the `signer` service account through the
288
+ user's login, which needs no key file. Never fall back to
289
+ `--private-key-file` or an activated service-account key to get a longer
290
+ link: a key file is exactly the stored secret this skill does not use. That
291
+ is what caps a GCS link at 12 hours.
292
+
293
+ #### Cloudflare Pages, public unless behind Access
294
+
295
+ Pages serves every deployment publicly unless the project's preview
296
+ deployments sit behind Cloudflare Access, and it has no signed or expiring
297
+ link. So confirm with the user first, as for any
298
+ [public copy](#public-copies-only-on-confirmation), unless they have told you
299
+ the project's previews are behind Access.
300
+
301
+ wrangler is Cloudflare's own client for Pages' multi-step direct upload, run
302
+ through `npx` at a pinned version (4.147.0, the latest stable on npm when
303
+ this was written; check `npm view wrangler version` and use that). Run it
304
+ from `$OUT`, outside the user's repository, so it does not pick up a
305
+ `wrangler.toml` there, and come back before deleting `$OUT`.
306
+
307
+ ```sh
308
+ export WRANGLER_SEND_METRICS=false
309
+ cd "${OUT:?}"
310
+ printf '/*\n X-Robots-Tag: noindex, nofollow\n Referrer-Policy: no-referrer\n Cache-Control: no-cache\n' >_headers
311
+ if ! npx --yes wrangler@4.147.0 pages project list --json |
312
+ node -e 'let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => process.exit(JSON.parse(s).some((p) => p["Project Name"] === process.argv[1]) ? 0 : 1))' "$PROJECT"; then
313
+ npx --yes wrangler@4.147.0 pages project create "$PROJECT" --production-branch production
314
+ fi
315
+ npx --yes wrangler@4.147.0 pages deploy "$OUT" --project-name "$PROJECT" --branch "${SLUG:?}" --commit-dirty=true
316
+ cd - >/dev/null
317
+ ```
318
+
319
+ - **The project must exist before `pages deploy`.** Run without a terminal,
320
+ a deploy to a missing project fails, and wrangler can turn `pages deploy`
321
+ into a Workers deploy when it detects an agent and the project is missing.
322
+ So the block creates `$PROJECT` only when the list lacks it - name that in
323
+ the question before the first upload, so the yes covers a new project too.
324
+ Its production branch is one
325
+ nothing is ever deployed to, so the bare project domain stays empty.
326
+ - **One preview branch per document.** The branch is the slug, and a
327
+ redeploy to the same branch updates its alias in place. Hand over the
328
+ **Deployment alias URL** wrangler prints, not the per-deployment
329
+ `<hash>.` one, and never build it from the project name: when the name was
330
+ taken, Pages gave the project a suffixed subdomain, which the list's
331
+ `Project Domains` shows. `*.pages.dev` uses a wildcard certificate, so an
332
+ alias never shows up in Certificate Transparency logs.
333
+ - Pages already sends `X-Robots-Tag: noindex` on preview deployments; the
334
+ `_headers` file adds `nofollow`, `no-referrer` and `no-cache`, and keeps
335
+ them should a deployment ever be promoted.
336
+ - There is no expiry to set, so `expires` stays empty.
337
+
338
+ ### 5. Record it and hand it over
339
+
340
+ Write `published.<review id>` with the provider, slug, `public`, the link and
341
+ its expiry, then give the user the link and say when it stops working. Do
342
+ not print the link anywhere else: it is a bearer credential until it
343
+ expires.
344
+
345
+ ## Re-signing a link
346
+
347
+ A link expired, or the user wants a fresh one. Nothing is uploaded: set the
348
+ variables, compute `OBJECT` (step 2, with the recorded slug) and the expiry
349
+ (step 3), and run only the signing command of the provider's section in
350
+ step 4. Then record the new link and expiry. A link already sent keeps
351
+ working until its own expiry; Azure can cut it short by revoking the user's
352
+ delegation keys (`az storage account revoke-delegation-keys`), which kills
353
+ every user-delegation SAS on the account.
354
+
355
+ Cloudflare Pages links and public copies do not expire, so there is nothing
356
+ to re-sign.
357
+
358
+ ## Unpublishing
359
+
360
+ Set the variables with the recorded slug, compute `OBJECT` (step 2), run the
361
+ provider's own lines, and remove the `published` entry only when they
362
+ succeeded: a copy whose record is gone can no longer be found to take down. For a public Azure
363
+ copy `CONTAINER` is `'$web'`, as it was when it was uploaded.
364
+
365
+ ```sh
366
+ az storage blob delete --auth-mode login --account-name "$ACCOUNT" \
367
+ --container-name "$CONTAINER" --name "$OBJECT"
368
+ aws s3 rm "s3://$BUCKET/$OBJECT" --profile "$PROFILE" --region "$REGION"
369
+ gcloud storage rm "gs://$BUCKET/$OBJECT"
370
+ DEPLOYMENTS="$(npx --yes wrangler@4.147.0 pages deployment list --project-name "$PROJECT" --environment preview --json |
371
+ node -e 'let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => { for (const d of JSON.parse(s)) if (d.Branch === process.argv[1]) console.log(d.Id); })' "${SLUG:?}")"
372
+ if [ -z "$DEPLOYMENTS" ]; then echo "no deployment of $SLUG listed" >&2 && exit 3; fi
373
+ for DEPLOYMENT in $DEPLOYMENTS; do
374
+ npx --yes wrangler@4.147.0 pages deployment delete "$DEPLOYMENT" --project-name "$PROJECT" --force
375
+ done
376
+ ```
377
+
378
+ A signed link to a deleted object stops working at once. On a versioned S3 or
379
+ GCS bucket the delete leaves the older versions behind, readable by anyone
380
+ with access to the bucket; say so when the bucket has versioning on. On
381
+ Cloudflare, a failing list stops the script before anything is deleted or
382
+ forgotten. Exit 3 means the list worked but showed nothing on the slug's
383
+ branch - already deleted, or past the first page: open the alias, and remove
384
+ the entry only once it no longer answers. `--force` is what removes the
385
+ deployment the alias points at, and
386
+ without it a run with no terminal deletes nothing and says nothing. The list
387
+ returns only the first page of results, so on a busy project an older
388
+ deployment of the slug can be missed: tell the user to check the project's
389
+ Deployments tab in the dashboard afterwards.
390
+
391
+ ## Public copies, only on confirmation
392
+
393
+ Only when the user asks for a public copy. Before anything, tell them, in
394
+ these words: **"This will be public: anyone with the link can read it, and it
395
+ cannot be made to expire."** Name what is in the page (the code it quotes,
396
+ and threads if they asked for them), and wait for an explicit yes. Record
397
+ `public: true`. The exported page is already `noindex, nofollow`; that keeps
398
+ it out of search results, not out of reach.
399
+
400
+ A copy keeps its visibility. To make a private copy public or the other way
401
+ round, [unpublish](#unpublishing) it, clear its slug, and publish afresh, so
402
+ no stale copy is left behind at the old path or the old visibility.
403
+
404
+ - **Azure**: the static website's `$web` container, once the user has
405
+ confirmed. Enabling it changes the whole account and needs **Storage
406
+ Account Contributor**, so it is the user's call. Then run step 4's upload -
407
+ not its SAS - with the container set to `$web`, which needs Storage Blob
408
+ Data Contributor on `$web` as well:
409
+
410
+ ```sh
411
+ az storage blob service-properties update --auth-mode login \
412
+ --account-name "$ACCOUNT" --static-website --index-document index.html
413
+ CONTAINER='$web'
414
+ az storage account show --name "$ACCOUNT" --query primaryEndpoints.web --output tsv
415
+ ```
416
+
417
+ The link is that endpoint followed by `$OBJECT`. Updates and unpublishing
418
+ use `'$web'` too.
419
+
420
+ - **AWS**: do not open the bucket. Put CloudFront with Origin Access Control
421
+ in front of it instead, which the user sets up once in their own account;
422
+ then the link is the distribution's domain followed by `/$OBJECT`. Turning
423
+ off Block Public Access with `aws s3api put-public-access-block` exposes
424
+ the whole bucket and is not this skill's to do.
425
+
426
+ - **GCP**: once the user has confirmed, a bucket with fine-grained access
427
+ can make the one object readable, which needs **Storage Object Admin**
428
+ rather than Object User. A
429
+ bucket with uniform access cannot make one object public, only the whole
430
+ bucket or a managed folder, so refuse and say why. Neither works where
431
+ public access prevention is enforced. Run step 4's upload - not its
432
+ signing - and then the grant, and run the grant again after **every**
433
+ update: an upload writes a new object generation with the bucket's default
434
+ ACL, which drops `allUsers`.
435
+
436
+ ```sh
437
+ gcloud storage objects update "gs://$BUCKET/$OBJECT" \
438
+ --add-acl-grant=entity=allUsers,role=READER
439
+ ```
440
+
441
+ The link is `https://storage.googleapis.com/$BUCKET/$OBJECT`.
442
+
443
+ - **Cloudflare**: the deployment in step 4 is already public.
444
+
445
+ ## Setting up, once per provider
446
+
447
+ The smallest grant that runs this skill, which the user applies in their own
448
+ account. A public copy needs more, named in its section above.
449
+
450
+ | Provider | Grant to the user |
451
+ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
452
+ | Azure | **Storage Blob Data Contributor** on the container; **Storage Blob Delegator** on the storage account, since the user-delegation key is issued at account scope |
453
+ | AWS | `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject` on `arn:aws:s3:::<bucket>/<prefix>/*`, and nothing on the bucket itself |
454
+ | GCP | **Storage Object User** (`roles/storage.objectUser`) on the bucket; **Service Account Token Creator** on the `signer` service account, which itself holds **Storage Object Viewer** on the bucket |
455
+ | Cloudflare | `wrangler login` (OAuth, as the user). Or a custom API token with only **Account › Cloudflare Pages › Edit** on the one account, which the user sets as `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` in their own environment |
456
+
457
+ To keep Cloudflare copies private, the user puts the project's previews
458
+ behind Cloudflare Access, once, in the dashboard: Workers & Pages › the
459
+ project › Settings › General › **Access policy › Enable**, then **Manage** to
460
+ edit the policy to **Include › Emails** or **Emails ending in**. It defaults
461
+ to members of the Cloudflare account only. It covers preview deployments,
462
+ not the bare project domain or a custom domain, which is one more reason
463
+ never to deploy to production. Before relying on it, open an alias in a
464
+ private window and see the Access login. Access is free for up to 50 users.
465
+
466
+ ### Smoke test
467
+
468
+ The user runs this with their own account before publishing a real document:
469
+ it uploads a one-line page under a fixed slug, prints a link, and deletes it.
470
+ Write it as a script file and run it with `bash`, never pasted into an
471
+ interactive shell, where `set -e` and `exit` would close the terminal. The
472
+ script is the variables block, then:
473
+
474
+ ```sh
475
+ OUT="$(mktemp -d)"
476
+ trap 'rm -rf "$OUT"' EXIT
477
+ printf '<!doctype html><title>thurview smoke test</title><p>ok\n' >"$OUT/index.html"
478
+ FILE="$OUT/index.html"
479
+ SLUG=rsmoketest
480
+ OBJECT="${PREFIX:?}/$SLUG/index.html"
481
+ EXPIRY_DAYS=1
482
+ ```
483
+
484
+ then step 3 and the provider's section of step 4. Open the link it prints
485
+ and see `ok`. Then run a second script - the variables block with
486
+ `SLUG=rsmoketest`, step 2, then the provider's lines from
487
+ [Unpublishing](#unpublishing) - and open the link again to see it refused.
488
+ Each script's variables die with it, so a real document published afterwards
489
+ mints its own slug instead of landing at the guessable `rsmoketest`. On
490
+ Cloudflare, use a throwaway project, check `curl -sI` on the alias shows
491
+ `x-robots-tag: noindex, nofollow`, and finish with
492
+ `npx --yes wrangler@4.147.0 pages project delete <project>`.
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // Is this repository's code allowed on this publish target?
3
+ //
4
+ // node check-scope.mjs --repo <dir> [--allow <glob>]... [--deny <glob>]...
5
+ //
6
+ // Every fetch and push URL of every remote is reduced to host/path - no
7
+ // scheme, user, password, port or .git - and matched against the globs, where
8
+ // `*` stays inside one path segment and `**` crosses them, ignoring case.
9
+ // A deny match on any URL refuses; with an allow list, so does any URL that
10
+ // matches none of it. An ssh alias is also judged by the host `ssh -G` says
11
+ // it names. Exit 0 allowed, 1 refused, 2 nothing to decide by (no scope, no
12
+ // remote, not a repository, a local-path remote) so the user must be asked,
13
+ // 64 misuse.
14
+ import { execFileSync } from "node:child_process";
15
+
16
+ const USAGE = "usage: check-scope.mjs --repo <dir> [--allow <glob>]... [--deny <glob>]...";
17
+
18
+ function parse(argv) {
19
+ const opts = { repo: ".", allow: [], deny: [] };
20
+ for (let i = 0; i < argv.length; i += 2) {
21
+ const [flag, value] = [argv[i], argv[i + 1]];
22
+ if (value === undefined || !["--repo", "--allow", "--deny"].includes(flag)) {
23
+ console.error(`unknown or incomplete flag: ${flag}\n${USAGE}`);
24
+ process.exit(64);
25
+ }
26
+ if (flag === "--repo") opts.repo = value;
27
+ else opts[flag.slice(2)].push(value);
28
+ }
29
+ return opts;
30
+ }
31
+
32
+ /**
33
+ * Where a remote URL points, as git reads it: a URL with a scheme, the scp
34
+ * form when a `:` comes before any `/`, or else a local path. Lowercased, with
35
+ * the user, password, port, query, fragment, `.git` and stray slashes gone.
36
+ */
37
+ function parseRemote(url) {
38
+ const u = url.trim().toLowerCase();
39
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\/([^/]*)(.*)$/.exec(u);
40
+ const scp = /^(?:[^/]*@)?(\[[^\]/]*\]|[^/:[\]]+):(.*)$/.exec(u);
41
+ let host;
42
+ let path;
43
+ let ssh = true;
44
+ if (scheme) {
45
+ if (scheme[1] === "file") return { local: true, where: tidy(scheme[2] + scheme[3]) };
46
+ const authority = scheme[2].slice(scheme[2].lastIndexOf("@") + 1);
47
+ host = /^(\[[^\]]*\]|[^:]*)/.exec(authority)[1];
48
+ path = scheme[3];
49
+ ssh = scheme[1].includes("ssh");
50
+ } else if (scp) [, host, path] = scp;
51
+ else return { local: true, where: tidy(u) };
52
+ return {
53
+ local: false,
54
+ host: host.replace(/[?#].*$/, "").replace(/\.$/, ""),
55
+ path: tidy(path),
56
+ ssh,
57
+ };
58
+ }
59
+
60
+ function tidy(path) {
61
+ return path
62
+ .replace(/[?#].*$/, "")
63
+ .replace(/\/+$/, "")
64
+ .replace(/\.git$/, "")
65
+ .replace(/\/{2,}/g, "/")
66
+ .replace(/^\/+|\/+$/g, "");
67
+ }
68
+
69
+ /** The host an ssh alias stands for, as `ssh -G` resolves it without connecting. */
70
+ function sshHostname(alias) {
71
+ try {
72
+ const out = execFileSync("ssh", ["-G", alias], {
73
+ encoding: "utf8",
74
+ stdio: ["ignore", "pipe", "ignore"],
75
+ timeout: 5000,
76
+ });
77
+ return /^hostname (.+)$/m.exec(out)?.[1]?.trim().toLowerCase().replace(/\.$/, "") ?? alias;
78
+ } catch {
79
+ return alias;
80
+ }
81
+ }
82
+
83
+ /** Every host/path a remote URL can be judged by: an ssh alias and what it names. */
84
+ function forms(url) {
85
+ const r = parseRemote(url);
86
+ if (r.local) return { local: true, names: [r.where] };
87
+ const names = [`${r.host}/${r.path}`];
88
+ const real = r.ssh ? sshHostname(r.host) : r.host;
89
+ if (real !== r.host) names.push(`${real}/${r.path}`);
90
+ return { local: false, names };
91
+ }
92
+
93
+ function globToRegExp(glob) {
94
+ const body = glob
95
+ .toLowerCase()
96
+ .split("**")
97
+ .map((part) =>
98
+ part
99
+ .split("*")
100
+ .map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
101
+ .join("[^/]*"),
102
+ )
103
+ .join(".*");
104
+ return new RegExp(`^${body}$`);
105
+ }
106
+
107
+ function git(repo, ...args) {
108
+ return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" })
109
+ .split("\n")
110
+ .filter(Boolean);
111
+ }
112
+
113
+ function remoteUrls(repo) {
114
+ const urls = new Map();
115
+ for (const name of git(repo, "remote"))
116
+ for (const url of [
117
+ ...git(repo, "remote", "get-url", "--all", name),
118
+ ...git(repo, "remote", "get-url", "--push", "--all", name),
119
+ ]) {
120
+ const f = forms(url);
121
+ urls.set(`${name} ${f.names.join(" ")}`, { name, ...f });
122
+ }
123
+ return [...urls.values()];
124
+ }
125
+
126
+ const opts = parse(process.argv.slice(2));
127
+ if (opts.allow.length === 0 && opts.deny.length === 0) {
128
+ console.log("no scope: this target names no allow_remotes or deny_remotes - ask the user");
129
+ process.exit(2);
130
+ }
131
+ let remotes;
132
+ try {
133
+ remotes = remoteUrls(opts.repo);
134
+ } catch {
135
+ console.log(`not a git repository: ${opts.repo} - ask the user`);
136
+ process.exit(2);
137
+ }
138
+ if (remotes.length === 0) {
139
+ console.log("no remote to judge this repository by - ask the user");
140
+ process.exit(2);
141
+ }
142
+
143
+ const deny = opts.deny.map((g) => [g, globToRegExp(g)]);
144
+ const allow = opts.allow.map((g) => [g, globToRegExp(g)]);
145
+ let refused = false;
146
+ let unjudged = false;
147
+ for (const { name, names, local } of remotes) {
148
+ // a malformed URL can leave a password fragment in the path; never print it
149
+ const masked = names.map((n) => n.replace(/[^/]*@/g, "…@"));
150
+ const shown = masked.length > 1 ? `${masked[0]} (${masked[1]})` : masked[0];
151
+ const denied = deny.find(([, re]) => names.some((n) => re.test(n)));
152
+ const allowed = allow.length === 0 || allow.some(([, re]) => names.some((n) => re.test(n)));
153
+ if (denied) console.log(`refused: ${name} ${shown} matches deny_remotes ${denied[0]}`);
154
+ else if (local)
155
+ console.log(`unjudged: ${name} ${shown} is a local path, not a host - ask the user`);
156
+ else if (!allowed) console.log(`refused: ${name} ${shown} matches no allow_remotes pattern`);
157
+ else console.log(`allowed: ${name} ${shown}`);
158
+ refused ||= Boolean(denied) || (!local && !allowed);
159
+ unjudged ||= local && !denied;
160
+ }
161
+ process.exit(refused ? 1 : unjudged ? 2 : 0);