@marver-design/marver 0.13.0 → 0.15.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/CHANGELOG.md +162 -0
- package/README.md +44 -20
- package/dist/{build-BxGrHFT2.mjs → build-DfuTQZlY.mjs} +46 -6
- package/dist/cli.mjs +21 -7
- package/dist/{daemon-BChkzDqQ.mjs → daemon-DalgvoA9.mjs} +1 -1
- package/dist/{dev-DLwt3Brb.mjs → dev-BxCmeU_H.mjs} +14 -4
- package/dist/{init-BpitOqRQ.mjs → init-QKNi9gvF.mjs} +66 -2
- package/dist/{manifest-CS6krOTe.mjs → manifest-BzxSMoDB.mjs} +24 -6
- package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
- package/dist/{plugin-DNc4Jpae.mjs → plugin-DJyjmQeh.mjs} +60 -10
- package/dist/poster-CbpzSzJu.mjs +143 -0
- package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
- package/dist/{shot-Cyv3GN79.mjs → shot-BWhoz6cU.mjs} +204 -57
- package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
- package/docs/live-jam.md +177 -0
- package/docs/publish.md +270 -0
- package/docs/sharing.md +333 -0
- package/docs/slides.md +140 -0
- package/package.json +3 -1
- package/src/client/const.ts +13 -0
- package/src/client/content/chart-engine.ts +33 -0
- package/src/client/content/chart.tsx +138 -0
- package/src/client/content/index.tsx +30 -6
- package/src/client/content/slide.tsx +238 -0
- package/src/client/content/video.tsx +223 -0
- package/src/client/frame-host/bridge.js +6 -1
- package/src/client/shell/App.tsx +59 -13
- package/src/client/shell/LockedApp.tsx +7 -2
- package/src/client/shell/Play.tsx +138 -24
- package/src/client/shell/Toolbar.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +5 -3
- package/src/client/shell/hash.ts +3 -1
- package/src/client/shell/icons.tsx +3 -0
- package/src/client/shell/play-order.ts +22 -0
- package/src/client/shell/store.ts +80 -9
- package/src/client/shell/styles.css +23 -27
- package/src/client/stage/main.tsx +54 -3
- package/src/shared/utm.ts +3 -2
- package/templates/AGENTS-embedded.md +20 -4
- package/templates/AGENTS-studio.md +20 -4
- package/templates/instructions/boards.md +47 -5
- package/templates/instructions/craft.md +17 -0
- package/templates/instructions/iterate.md +109 -14
- package/templates/instructions/jam.md +18 -2
- package/templates/instructions/publish.md +7 -0
- package/templates/instructions/reference/deck-layouts.md +230 -0
- package/templates/instructions/reference/deck-story.md +110 -0
- package/templates/instructions/shape.md +15 -1
- package/templates/instructions/slides.md +402 -0
package/docs/publish.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Publishing a Marver canvas
|
|
2
|
+
|
|
3
|
+
A published canvas is a static site: `marver build` bundles the shell, the prototype
|
|
4
|
+
stage, and your frames with all data inlined - it fetches nothing, saves nothing, and
|
|
5
|
+
runs on any static host. `marver serve` hosts it, optionally behind a gate - a shared
|
|
6
|
+
password, or Marver Sign In (see [Who can open your canvas](#who-can-open-your-canvas)).
|
|
7
|
+
Give it a persistent volume (`MARVER_DATA_DIR`) and comments + viewer accounts persist
|
|
8
|
+
across deploys (`MARVER_OWNER_EMAIL` bootstraps the first owner account).
|
|
9
|
+
|
|
10
|
+
Publishing is default-closed: `design/publish.json` names the boards that ship and
|
|
11
|
+
their rights (`"read"` or `"comment"`) - no policy and no explicit flag means no build.
|
|
12
|
+
A board row can also be an object that says how the board presents - its `type`
|
|
13
|
+
(`doc`, `slides`, `design`, `sketch`, `refs`, `mix`), the view visitors land in
|
|
14
|
+
(`open`: `canvas`, `board`, `present`, `focus`, `slides`), `lock` to freeze them
|
|
15
|
+
there, and for decks `transition` / `chrome` - the fields are spelled out in the
|
|
16
|
+
[sharing guide](sharing.md#publishjson-v2---the-ceiling-and-the-boards-shape) and,
|
|
17
|
+
for decks, the [slides guide](slides.md).
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx marver build # boards named in design/publish.json → design/.dist
|
|
21
|
+
npx marver build --boards checkout # only these boards - the frame filter is applied
|
|
22
|
+
# at BUILD time; excluded frames never enter the bundle
|
|
23
|
+
MARVER_PASSWORD=secret npx marver serve # a shared password, or:
|
|
24
|
+
MARVER_ID_ISSUER=https://id.marver.design \
|
|
25
|
+
MARVER_PUBLIC_ORIGIN=https://your.canvas \
|
|
26
|
+
MARVER_DATA_DIR=/data npx marver serve # accounts, one sign-in across canvases
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Deep links work verbatim: any `#/b/...` or `#/p/...` URL copied from your dev canvas
|
|
30
|
+
opens the same view on the published site, as long as its board and frame shipped.
|
|
31
|
+
|
|
32
|
+
**What ships**: the boards you list, the frames they reference, and the host `public/`
|
|
33
|
+
directory in full (the `--boards` filter covers frames, not public assets). A flow you
|
|
34
|
+
publish must have all its `data-goto` targets on a published board.
|
|
35
|
+
|
|
36
|
+
## Who can open your canvas
|
|
37
|
+
|
|
38
|
+
Three choices, and the canvas is public until you make one.
|
|
39
|
+
|
|
40
|
+
| | **Open** | **Password** | **Marver Sign In** |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| Set | nothing | `MARVER_PASSWORD` | `MARVER_ID_ISSUER` + `MARVER_PUBLIC_ORIGIN` |
|
|
43
|
+
| People prove | nothing | they know a secret | who they are |
|
|
44
|
+
| Named accounts | none | only with `MARVER_DATA_DIR` | always (needs `MARVER_DATA_DIR`) |
|
|
45
|
+
| Who decides entry | - | you | you |
|
|
46
|
+
| Outbound requests | none | none | public keys only |
|
|
47
|
+
| Best for | a public canvas | one link to a small group | a team, across canvases |
|
|
48
|
+
|
|
49
|
+
`MARVER_DATA_DIR` is what turns a gate into accounts. Without it the password gate
|
|
50
|
+
is exactly one shared secret and nothing else - no per-person identity, no comments
|
|
51
|
+
that persist, and nothing for `marver comments invite` to write to. Marver Sign In
|
|
52
|
+
requires it outright, because identity accounts need somewhere to live.
|
|
53
|
+
|
|
54
|
+
They are alternatives, not layers. Setting both `MARVER_PASSWORD` and
|
|
55
|
+
`MARVER_ID_ISSUER` would weaken your invite list to "an account OR whoever has the
|
|
56
|
+
password", so the identity gate replaces the password gate rather than sitting
|
|
57
|
+
beside it.
|
|
58
|
+
|
|
59
|
+
**Whichever you choose, the guest list stays yours.** This is the part worth
|
|
60
|
+
reading twice: with Marver Sign In, the identity service proves *who somebody is*
|
|
61
|
+
and has no say in *where they may go*. Your canvas decides that, from a list it
|
|
62
|
+
holds. Somebody with a perfectly valid Marver account who is not on your list
|
|
63
|
+
gets nothing.
|
|
64
|
+
|
|
65
|
+
And the honest fine print, because this is a promise we publish rather than a
|
|
66
|
+
slogan: Marver stores which canvases a person has *signed in to*, and issues
|
|
67
|
+
short-lived tokens for them. What someone can **do** on your canvas is answered
|
|
68
|
+
by your canvas to their own browser, cached only there, and never sent to us.
|
|
69
|
+
Two things do cross the line, both disclosed and both optional: an invite email
|
|
70
|
+
means the identity service learns that an address was invited to your origin
|
|
71
|
+
(that is what sending the mail requires - decline it entirely with
|
|
72
|
+
`share: { notify: false }` in `design/config.ts` and use the dialog's "copy
|
|
73
|
+
invite message" instead), and the front door at `app.marver.design` learns
|
|
74
|
+
which origins a person probes (`share: { frontDoor: false }` keeps your canvas
|
|
75
|
+
silent there). The app ships no third-party analytics and no summary telemetry.
|
|
76
|
+
|
|
77
|
+
**Sharing, precisely.** Sharing controls who may **comment**, and who gets in
|
|
78
|
+
at all. What a person who is in can **see** is decided at build time by
|
|
79
|
+
`design/publish.json` - boards you do not publish are not in the bundle. One
|
|
80
|
+
canvas per audience is the read boundary today; per-person read arrives in v2,
|
|
81
|
+
served rather than bundled. Managing that roster - granting people, blocking
|
|
82
|
+
them, approving access requests, and reading who-sees-what from the terminal -
|
|
83
|
+
is its own guide: [Sharing a Marver canvas](sharing.md).
|
|
84
|
+
|
|
85
|
+
### Sovereign accounts (`MARVER_PASSWORD` + `MARVER_DATA_DIR`)
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
MARVER_PASSWORD=secret MARVER_DATA_DIR=/data npx marver serve
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Everything stays here. Accounts live in `MARVER_DATA_DIR` as scrypt hashes, invites
|
|
92
|
+
are minted by you, and the canvas makes no outbound request of any kind - there is
|
|
93
|
+
no service to depend on and nothing to phone home to. If you want a canvas that
|
|
94
|
+
still works in ten years on a disconnected network, this is it.
|
|
95
|
+
|
|
96
|
+
`MARVER_PASSWORD` on its own is a simpler thing: one shared secret in front of the
|
|
97
|
+
bundle, with no accounts behind it. Add the volume when you want named people.
|
|
98
|
+
|
|
99
|
+
Auth is an HMAC-signed 30-day cookie with a per-boot secret (a server restart
|
|
100
|
+
re-prompts), and each password attempt pays an scrypt cost. Invite people with
|
|
101
|
+
`marver comments invite <email>`; they claim it in the browser and choose a
|
|
102
|
+
password.
|
|
103
|
+
|
|
104
|
+
**Set `MARVER_PUBLIC_ORIGIN` here too if you serve over https.** It is required for
|
|
105
|
+
Marver Sign In and optional here, but it is what puts `Secure` on that 30-day
|
|
106
|
+
cookie. Without it the canvas has to guess from `X-Forwarded-Proto`, and nginx's
|
|
107
|
+
own documented `proxy_pass http://localhost:PORT` sends no `X-Forwarded-*` at all -
|
|
108
|
+
so an https canvas behind that config drops `Secure` and the cookie will travel
|
|
109
|
+
over plain http. Proxies that do set the header (Railway, Fly, Vercel, Caddy,
|
|
110
|
+
nginx with `proxy_set_header`) were never affected. Fixed in 0.11.1; on 0.11.0 the
|
|
111
|
+
gate cookie guessed regardless.
|
|
112
|
+
|
|
113
|
+
The costs are the ordinary costs of passwords. It is one secret shared by
|
|
114
|
+
everybody, so removing one person means rotating it for all of them; there is no
|
|
115
|
+
password reset; and a person with five canvases keeps five passwords.
|
|
116
|
+
|
|
117
|
+
### Marver Sign In (`MARVER_ID_ISSUER`) - recommended for teams
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
MARVER_ID_ISSUER=https://id.marver.design \
|
|
121
|
+
MARVER_PUBLIC_ORIGIN=https://your.canvas \
|
|
122
|
+
MARVER_DATA_DIR=/data \
|
|
123
|
+
npx marver serve
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The gate asks people who they are instead of asking for a shared secret. They sign
|
|
127
|
+
in once - with Google, or a code emailed to them - and every canvas you gate this
|
|
128
|
+
way opens without another password. Nobody types a canvas password, so there is
|
|
129
|
+
none to leak, rotate, or forget, and revoking one person revokes them.
|
|
130
|
+
|
|
131
|
+
A Marver account is **free**, and there is exactly one of it. That is the point:
|
|
132
|
+
the account is not per canvas, so the second board you share with somebody costs
|
|
133
|
+
them nothing - no signup, no password to store, no invite link to keep. The first
|
|
134
|
+
canvas is where they pay the thirty seconds; every one after that is a click. If
|
|
135
|
+
you have ever watched a review die because a reviewer could not find the link, that
|
|
136
|
+
is the friction this removes.
|
|
137
|
+
|
|
138
|
+
This is the better default for a team, and it is the one we run ourselves. What it
|
|
139
|
+
costs you is honest to state:
|
|
140
|
+
|
|
141
|
+
- **A dependency.** If the identity service is unreachable, sign-in fails closed:
|
|
142
|
+
existing sessions keep working, new ones are refused, nothing falls back to open.
|
|
143
|
+
- **The service learns when somebody signs in**, and to which canvas origin. It
|
|
144
|
+
does not learn whether you let them in, what is on the canvas, or anything else.
|
|
145
|
+
- **It is a hosted service**, so it is the one part of a self-hosted canvas that is
|
|
146
|
+
not self-hosted. The protocol is ordinary ES256 + JWKS and the verifying half
|
|
147
|
+
lives in this repo (`src/server/marver-id.ts`), so a different issuer is a
|
|
148
|
+
configuration change, not a fork.
|
|
149
|
+
|
|
150
|
+
A canvas with no `MARVER_ID_ISSUER` set makes no outbound request at all. Opting
|
|
151
|
+
out is the default, and this section is the only reason to opt in.
|
|
152
|
+
|
|
153
|
+
#### Configuration
|
|
154
|
+
|
|
155
|
+
`MARVER_PUBLIC_ORIGIN` is **required** - always, including in development - and the
|
|
156
|
+
canvas refuses to start without it. Every assertion is bound to this exact origin
|
|
157
|
+
(scheme, host and port), so one minted for one canvas is inert at another.
|
|
158
|
+
|
|
159
|
+
It is configuration rather than inference on purpose, and the reason is worth
|
|
160
|
+
knowing if you deploy behind a proxy. The canvas used to work this out for itself
|
|
161
|
+
when the connection looked local, which is wrong in the most ordinary setup there
|
|
162
|
+
is: nginx's documented `proxy_pass http://localhost:PORT` rewrites `Host` to the
|
|
163
|
+
upstream and adds no `X-Forwarded-*` headers at all, so a request from the open
|
|
164
|
+
internet arrives looking exactly like one from your own machine. There is no signal
|
|
165
|
+
here a proxy cannot erase, so the canvas asks instead of guessing.
|
|
166
|
+
|
|
167
|
+
It must be a bare origin - https anywhere, or http on loopback - with no path or
|
|
168
|
+
query. Cookie security follows it, not any forwarded header:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
MARVER_PUBLIC_ORIGIN=https://canvas.example.com # deployed
|
|
172
|
+
MARVER_PUBLIC_ORIGIN=http://localhost:4199 # development
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
#### Who gets in
|
|
176
|
+
|
|
177
|
+
An address may enter if it already has an account on this canvas, holds an
|
|
178
|
+
unexpired invite, or is `MARVER_OWNER_EMAIL` on a canvas with no accounts yet. You
|
|
179
|
+
invite people exactly as before; Marver Sign In only removes the password step from
|
|
180
|
+
claiming it.
|
|
181
|
+
|
|
182
|
+
People are matched on the stable identity behind the address, not the address
|
|
183
|
+
itself, so somebody whose email changes keeps their account and their history. Their
|
|
184
|
+
other sessions are signed out when that happens - a session records the address it
|
|
185
|
+
was minted for, and leaving it alive would hand it to whoever claims that address
|
|
186
|
+
next. A rename onto an address someone else already holds is refused outright.
|
|
187
|
+
|
|
188
|
+
> **Managing people needs `MARVER_CLI_TOKEN`.** `marver comments invite`,
|
|
189
|
+
> `revoke` and `sync` authenticate the CLI with a password, and an identity
|
|
190
|
+
> account has none. Set `MARVER_CLI_TOKEN` on the canvas to a generated secret of
|
|
191
|
+
> 32 characters or more and hand the same value back:
|
|
192
|
+
>
|
|
193
|
+
> ```bash
|
|
194
|
+
> # on the canvas: MARVER_CLI_TOKEN=$(openssl rand -hex 24)
|
|
195
|
+
> MARVER_CLI_TOKEN='<that same value>' marver comments connect https://canvas.example.com
|
|
196
|
+
> ```
|
|
197
|
+
>
|
|
198
|
+
> `--token` works too, but a secret on the command line is visible to anything
|
|
199
|
+
> that can list processes, so prefer the variable.
|
|
200
|
+
>
|
|
201
|
+
> Generate it, do not choose it: nothing rate-limits this credential and nothing
|
|
202
|
+
> slows a guess down, so its entropy is the whole defence. Use hex rather than
|
|
203
|
+
> base64 - an `Authorization` header carries letters, digits, `_` and `-`, and the
|
|
204
|
+
> canvas refuses to start on a value it could never accept. It acts as whoever
|
|
205
|
+
> owns the canvas, so let the owner sign in once first.
|
|
206
|
+
>
|
|
207
|
+
> `connect` trades it for an ordinary session and stores THAT in
|
|
208
|
+
> `~/.marver/canvases/`, so neither the secret nor the session lands in your repo.
|
|
209
|
+
>
|
|
210
|
+
> **To revoke it, rotate `MARVER_CLI_TOKEN`.** Every session it ever minted stops
|
|
211
|
+
> working the moment the variable changes; sessions people hold in their browsers
|
|
212
|
+
> are untouched. `marver comments revoke` cannot help here - the session acts as
|
|
213
|
+
> the owner, and a canvas refuses to remove its last owner - so rotation is the
|
|
214
|
+
> lever, and it is the reason each device session records which secret minted it.
|
|
215
|
+
> (One instance at a time, as ever: during a rolling restart an old replica still
|
|
216
|
+
> honours old sessions until it drains.)
|
|
217
|
+
>
|
|
218
|
+
> It is a deployment variable rather than something a page hands out, and that is
|
|
219
|
+
> deliberate. A browser-approved sign-in for the CLI was built for this and then
|
|
220
|
+
> removed before release: authored frames run same-origin in a canvas, so frame
|
|
221
|
+
> JavaScript could have driven the approval itself and walked away with a
|
|
222
|
+
> long-lived credential; no header distinguishes a frame from the page around it,
|
|
223
|
+
> because they are the same origin. Per-member CLI credentials still want the
|
|
224
|
+
> frame isolation this release does not have, so the one credential is the
|
|
225
|
+
> operator's.
|
|
226
|
+
|
|
227
|
+
### The gate footer
|
|
228
|
+
|
|
229
|
+
The gate footer ("Powered by Marver.design") is the honor system, not enforcement:
|
|
230
|
+
Marver is free, the gate is fully personalized to your app, and that one line is how
|
|
231
|
+
the tool spreads - we'd love it if you keep it. It's yours to remove, no strings:
|
|
232
|
+
`share: { branding: false }` in `design/config.ts` (this also strips every Marver
|
|
233
|
+
mention from the page metadata, the sign-in screens included).
|
|
234
|
+
|
|
235
|
+
### Name the canvas
|
|
236
|
+
|
|
237
|
+
`share: { name: "Your App" }` in `design/config.ts`. That name titles the gate,
|
|
238
|
+
labels the brand pill, and becomes `utm_campaign` on every powered-by link the
|
|
239
|
+
canvas emits, so site analytics can tell which canvas sent a visitor. Unset, the gate
|
|
240
|
+
falls back to your `package.json` name and the canvas shell to the root
|
|
241
|
+
directory name - which inside a container is usually `app`, so every unnamed
|
|
242
|
+
containerised canvas reports as one campaign.
|
|
243
|
+
|
|
244
|
+
## Railway (the one-pager)
|
|
245
|
+
|
|
246
|
+
1. Push your repo to GitHub and create a Railway service from it.
|
|
247
|
+
2. Build command: `npm ci && npx marver build`
|
|
248
|
+
3. Start command: `npx marver serve` (Railway's `$PORT` is picked up automatically)
|
|
249
|
+
4. Variables: `MARVER_PASSWORD=<your password>`
|
|
250
|
+
|
|
251
|
+
Deploy. The repo itself is the deployable - nothing to export, nothing to sync.
|
|
252
|
+
|
|
253
|
+
## Docker (everywhere else)
|
|
254
|
+
|
|
255
|
+
```dockerfile
|
|
256
|
+
FROM node:22-slim
|
|
257
|
+
WORKDIR /app # set share.name in design/config.ts - the fallback name is this directory
|
|
258
|
+
COPY . .
|
|
259
|
+
RUN npm ci && npx marver build
|
|
260
|
+
ENV PORT=8080
|
|
261
|
+
EXPOSE 8080
|
|
262
|
+
CMD ["npx", "marver", "serve"]
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Cloudflare Pages + Access (email/domain allowlists)
|
|
266
|
+
|
|
267
|
+
For teams that want per-email policies instead of one password: build in CI
|
|
268
|
+
(`npx marver build`, output directory `design/.dist`), deploy to Pages, then put
|
|
269
|
+
Cloudflare Access in front with your email or domain rules. Google login and audit
|
|
270
|
+
logs come free; Marver ships no auth code at all in this setup.
|
package/docs/sharing.md
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# Sharing a Marver canvas
|
|
2
|
+
|
|
3
|
+
[Publishing](publish.md) decides which boards ship and how a canvas is gated.
|
|
4
|
+
**Sharing** decides who gets in and who may comment - person by person, from the
|
|
5
|
+
terminal or the browser.
|
|
6
|
+
|
|
7
|
+
The one thing worth holding in your head: **there is a single pure function that
|
|
8
|
+
answers "what can this person do here", and the doors that enforce access all
|
|
9
|
+
call it.** The gate, comment writes, and the browser dialog's owner routes read
|
|
10
|
+
from the same `share.json` and the same resolver, so the policy is one thing in
|
|
11
|
+
one place, not a rule re-implemented per door.
|
|
12
|
+
|
|
13
|
+
> **Sharing needs a place to keep the roster.** `marver share` and the dialog
|
|
14
|
+
> manage a roster of *people*, which needs three things: `MARVER_DATA_DIR` (a
|
|
15
|
+
> persistent volume for `share.json`), an **owner account** on the canvas, and
|
|
16
|
+
> the owner's **device credential** (`marver comments connect`, authenticated
|
|
17
|
+
> with `MARVER_CLI_TOKEN` on an identity canvas - see the
|
|
18
|
+
> [publishing guide](publish.md#marver-sign-in-marver_id_issuer---recommended-for-teams)).
|
|
19
|
+
> With those, a canvas gated by a **password** supports exact-email grants
|
|
20
|
+
> today. **Marver Sign In** adds what only a verified identity can do: domain
|
|
21
|
+
> grants (`@acme.com`), the request-access flow for refused visitors, the
|
|
22
|
+
> hosted browser dialog, and the front door. A password canvas without
|
|
23
|
+
> `MARVER_DATA_DIR` is one shared secret and nothing to share person by person.
|
|
24
|
+
|
|
25
|
+
## The inputs sharing combines
|
|
26
|
+
|
|
27
|
+
Access is built from a few inputs, and the resolver combines them in one fixed
|
|
28
|
+
order (the precise order is in [How access is computed](#how-access-is-computed-the-resolver-precisely) below):
|
|
29
|
+
|
|
30
|
+
1. **The blocklist** - the only *deny*, applied first. Its reach is narrower than
|
|
31
|
+
it sounds; see the note under `block` below.
|
|
32
|
+
2. **The owner** - the canvas owner precedes principal matching and can always
|
|
33
|
+
open and administer the canvas.
|
|
34
|
+
3. **Grants** - who was let in and at what level (`view` or `comment`), optionally
|
|
35
|
+
until a date. Grants are *additive*: the highest matching grant wins.
|
|
36
|
+
4. **General access** - the floor for anyone admitted: `private`, `password`, or
|
|
37
|
+
`public`. What you can actually select is clamped by the gate you run (below).
|
|
38
|
+
|
|
39
|
+
Every result is then **clamped by the board's published ceiling** - `publish.json`
|
|
40
|
+
says a board tops out at `read` or `comment`, and no grant can exceed it. A grant
|
|
41
|
+
of `comment` on a board published `read` resolves to `view`.
|
|
42
|
+
|
|
43
|
+
## `marver share` - the roster from the terminal
|
|
44
|
+
|
|
45
|
+
`marver share` calls the same owner-only routes the browser dialog does,
|
|
46
|
+
authenticated with the device credential [`marver comments connect`](publish.md#marver-sign-in-marver_id_issuer---recommended-for-teams)
|
|
47
|
+
stored. Connect once as the owner, then:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
marver share add sam@acme.com # grant view (the default)
|
|
51
|
+
marver share add sam@acme.com --role comment # grant comment instead
|
|
52
|
+
marver share add sam@acme.com --expires 2027-06-01 # a grant that lapses on its own
|
|
53
|
+
marver share add @acme.com --role comment # any verified @acme.com address (identity canvas only)
|
|
54
|
+
marver share remove sam@acme.com # take the grant back
|
|
55
|
+
|
|
56
|
+
marver share block troll@spam.com # see the note below - narrower than it reads
|
|
57
|
+
marver share unblock troll@spam.com
|
|
58
|
+
|
|
59
|
+
marver share general private # the stored floor (the gate may clamp it, below)
|
|
60
|
+
marver share general password
|
|
61
|
+
marver share general public
|
|
62
|
+
|
|
63
|
+
marver share list # the roster: general access, grants, blocklist
|
|
64
|
+
marver share requests # pending access requests
|
|
65
|
+
marver share requests --approve sam@acme.com # grant them - canvas-wide, at view
|
|
66
|
+
marver share requests --approve sam@acme.com --role comment
|
|
67
|
+
marver share requests --decline sam@acme.com # resolve it silently - no rejection reaches them
|
|
68
|
+
|
|
69
|
+
marver share explain sam@acme.com # the resolver's trace for one person (see caveats)
|
|
70
|
+
marver share who # granted principals x published boards
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**`block` is narrower than "refused everywhere."** The blocklist is the first and
|
|
74
|
+
only deny *inside the resolver*, but two things sit outside it, and the CLI says
|
|
75
|
+
so when you block: **"Blocking only bites while general access is Private."** If
|
|
76
|
+
general access is `password` or `public`, the same person can still enter
|
|
77
|
+
anonymously - there is no identity to block at an anonymous door. And a canvas
|
|
78
|
+
**owner** is admitted ahead of the blocklist (someone must be able to administer),
|
|
79
|
+
though comment authorization can still deny them. Blocking reliably denies an
|
|
80
|
+
*identified, non-owner* person and their access requests; it is a complete entry
|
|
81
|
+
denial only while general access is private.
|
|
82
|
+
|
|
83
|
+
**`explain` asks the canvas; `who` is a local convenience view.** Since 0.13.0
|
|
84
|
+
`marver share explain` calls the canvas's own `share/explain` route - the owner
|
|
85
|
+
role, domain principals, and the winning step included - so it is the enforcing
|
|
86
|
+
trace. `who` still runs the resolver locally over the roster it fetched, and
|
|
87
|
+
takes three shortcuts the enforcing doors do not:
|
|
88
|
+
|
|
89
|
+
- it does not pass the owner role, so a fresh owner with no grant of their own is
|
|
90
|
+
explained as an ordinary ungranted person (the server's own `explain` route
|
|
91
|
+
fills the owner role in; the dialog is the accurate view for the owner);
|
|
92
|
+
- it treats a `@domain` argument as having no address to match, so a domain row
|
|
93
|
+
in `who` shows `(per member)` rather than an effective role;
|
|
94
|
+
- it prints each step's role but not which step *won* - the resolver marks a
|
|
95
|
+
winning step, and the CLI renderer drops that mark, so read the trace as the
|
|
96
|
+
inputs, not a highlighted verdict.
|
|
97
|
+
|
|
98
|
+
So `explain` is reliable for anyone; `who` lists *granted principals* down the side (not the owner,
|
|
99
|
+
who holds no grant row), the blocklist, and - when general access is open - an
|
|
100
|
+
anonymous row; it is the grant matrix, not a census of everyone who could get in.
|
|
101
|
+
|
|
102
|
+
Domain grants (`marver share add @acme.com`) need Marver Sign In: a canvas that
|
|
103
|
+
cannot verify who holds an address cannot verify their domain.
|
|
104
|
+
|
|
105
|
+
## `publish.json` v2 - the ceiling, and the board's shape
|
|
106
|
+
|
|
107
|
+
`publish.json` is where a board's **ceiling** lives - the most any grant can
|
|
108
|
+
resolve to there - and, in the v2 row shape, a few facts about how the board
|
|
109
|
+
presents. Every 0.11 canvas parses unchanged: a bare `"read"` or `"comment"`
|
|
110
|
+
string is still a valid row. (This is a **schema** version; it is unrelated to
|
|
111
|
+
the read-privacy work also called "v2" further down.)
|
|
112
|
+
|
|
113
|
+
```jsonc
|
|
114
|
+
{
|
|
115
|
+
"boards": {
|
|
116
|
+
"roadmap": "comment", // v1 row: the ceiling, nothing more
|
|
117
|
+
|
|
118
|
+
"brand": { // v2 row: an object
|
|
119
|
+
"max": "read", // required - the ceiling ("read" | "comment")
|
|
120
|
+
"type": "design", // optional - the artifact type (default "mix")
|
|
121
|
+
"open": "focus", // optional - the landing view
|
|
122
|
+
"lock": true // optional - freeze the landing view (needs "open")
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"reveal": { "structure": true, "source": false }
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- **`max`** (`"read"` | `"comment"`) is the ceiling. This is the only field that
|
|
130
|
+
affects *access*; everything else is presentation.
|
|
131
|
+
- **`type`** is one of `doc`, `slides`, `design`, `sketch`, `refs`, `mix`
|
|
132
|
+
(default `mix`). It picks the board's default landing view and its card icon;
|
|
133
|
+
nothing infers a type from content.
|
|
134
|
+
- **`open`** names the landing view: `canvas`, `board`, `present`, `focus`,
|
|
135
|
+
or `slides`. `canvas` and `board` both land on the canvas; `present`,
|
|
136
|
+
`focus`, and `slides` are their own modes. Absent means the type decides
|
|
137
|
+
(`slides` → slides mode, `doc` → focus, `design` / `sketch` / `refs` →
|
|
138
|
+
board, `mix` → canvas).
|
|
139
|
+
- **`lock`** freezes the canvas in the `open` view (no view switcher). It
|
|
140
|
+
requires `open` - freezing an unnamed mode is meaningless, so the build refuses
|
|
141
|
+
it.
|
|
142
|
+
- **`transition`** (`"fade"` default | `"none"`) and **`chrome`** (`"full"`
|
|
143
|
+
default | `"minimal"` | `"none"`) shape slide playback only. They are
|
|
144
|
+
accepted on a board whose `type` or `open` is `slides` and refused
|
|
145
|
+
elsewhere. A slides board plays only its `slide: true` frames: non-slide
|
|
146
|
+
frames on it warn at build, a board with none errors (0.13.0 let `slides`
|
|
147
|
+
alias `present`; 0.14.0 plays only slides - to keep the old behaviour set
|
|
148
|
+
a non-slides `type` such as `mix` AND `open: "present"`, or add
|
|
149
|
+
`slide: true` to the frames).
|
|
150
|
+
- **`reveal.source`** defaults **off** on a published canvas: the bundle would
|
|
151
|
+
otherwise carry every frame's repo path, which is a disclosure the moment a
|
|
152
|
+
canvas is shared beyond its own repo. `reveal.structure` defaults on.
|
|
153
|
+
|
|
154
|
+
Publishing stays **default-closed**: no `publish.json` and no explicit
|
|
155
|
+
`--boards`/`--all-boards` flag means nothing ships. A board absent from the
|
|
156
|
+
policy is absent from the bundle - which is also the read boundary in v1 (see
|
|
157
|
+
["What v1 does not do"](#what-v1-does-not-do)).
|
|
158
|
+
|
|
159
|
+
## General access, and what your gate can enforce
|
|
160
|
+
|
|
161
|
+
`marver share general <mode>` is clamped to what the **gate you run can actually
|
|
162
|
+
enforce** - and it is clamped when you *set* it, not when access is resolved. When
|
|
163
|
+
you ask for more than the gate allows, the owner API stores the operative
|
|
164
|
+
(clamped) mode, and the CLI tells you what it stored and why, so the roster never
|
|
165
|
+
holds a state the server cannot enforce:
|
|
166
|
+
|
|
167
|
+
| Gate you run | `private` | `password` | `public` |
|
|
168
|
+
|---|---|---|---|
|
|
169
|
+
| **Marver Sign In** (`MARVER_ID_ISSUER`) | private | private | private |
|
|
170
|
+
| **Password** (`MARVER_PASSWORD`, no issuer) | private | password | password |
|
|
171
|
+
| **No gate** (neither set) | public | public | public |
|
|
172
|
+
|
|
173
|
+
So on an identity canvas general access is always `private` - membership is the
|
|
174
|
+
whole point, and there is no anonymous door to open. On a password canvas,
|
|
175
|
+
`public` clamps to `password` (the password would otherwise be theater). A canvas
|
|
176
|
+
with no gate is `public` by definition. When the CLI clamps your request it tells
|
|
177
|
+
you what it stored and why.
|
|
178
|
+
|
|
179
|
+
## `share.json` - the grant store
|
|
180
|
+
|
|
181
|
+
The roster lives in `share.json` on your volume (`MARVER_DATA_DIR`), beside
|
|
182
|
+
`auth.json`. It is a plain JSON file on the server, written mode `0600` (readable
|
|
183
|
+
by the OS account running Marver, not by a browser owner - browser owners inspect
|
|
184
|
+
it through the CLI or the dialog, both of which go through the owner API). You
|
|
185
|
+
rarely touch it by hand, but three things about how it behaves are worth knowing:
|
|
186
|
+
|
|
187
|
+
- **It is created once, at serve boot, from your live 0.11 state.** Every existing
|
|
188
|
+
account gets a canvas-wide `comment` grant (clamped per board) because that is
|
|
189
|
+
exactly what they could do the day before. Until that first boot, every door
|
|
190
|
+
falls back to the legacy rules it always had - upgrading changes nothing you
|
|
191
|
+
did not ask for.
|
|
192
|
+
- **A grant carries a per-board ratchet, not just a role.** A grant records what
|
|
193
|
+
you *asked for* (`assigned`) separately from what each board currently resolves
|
|
194
|
+
to (`boardRole`). Reads always take `min(current ceiling, boardRole)`, and every
|
|
195
|
+
boot re-clamps entries *down* to the ceiling, never up. So raising a board's
|
|
196
|
+
ceiling later does **not** silently re-promote everyone who was granted under
|
|
197
|
+
the old, lower one - the entry stays low until you explicitly re-confirm it.
|
|
198
|
+
This is the non-promotion invariant, and it is the reason a ceiling change is
|
|
199
|
+
always safe.
|
|
200
|
+
- **It fails closed.** A present-but-corrupt or malformed `share.json` denies
|
|
201
|
+
rather than falling back to open - a policy typo must never quietly grant the
|
|
202
|
+
ceiling. A *missing* file is the pre-migration signal and keeps legacy rules; a
|
|
203
|
+
broken one stops the doors.
|
|
204
|
+
|
|
205
|
+
## Access requests - when someone refused wants in
|
|
206
|
+
|
|
207
|
+
When a person is refused at the identity gate, the canvas offers them a request
|
|
208
|
+
form instead of a dead end. The refused visitor has no session to authenticate
|
|
209
|
+
with, so the canvas mints a **short-lived, origin-bound, single-purpose request
|
|
210
|
+
token** and accepts a request only against it (the `marver-reqaccess+jwt`
|
|
211
|
+
contract). The request lands as one pending row per address:
|
|
212
|
+
|
|
213
|
+
- it surfaces in `marver share requests` and in the dialog's requests list;
|
|
214
|
+
- **approving grants canvas-wide at the role you choose** (`view` by default) -
|
|
215
|
+
in v1 an approval covers every published board, and the CLI and dialog both
|
|
216
|
+
say so;
|
|
217
|
+
- **declining resolves the row silently** - no rejection email reaches the asker;
|
|
218
|
+
- a repeat ask from the same address replaces its note, it does not pile up a
|
|
219
|
+
second row, and a row expires on its own after 30 days.
|
|
220
|
+
|
|
221
|
+
## The front door (`app.marver.design`)
|
|
222
|
+
|
|
223
|
+
The front door is the signed-in home page at `app.marver.design`: a person signs
|
|
224
|
+
in once and sees the canvases they can reach, each row lit by a short summary the
|
|
225
|
+
**canvas itself signs and serves**. The front door holds no roster and makes no
|
|
226
|
+
access decision - it asks each canvas, and each canvas answers for itself.
|
|
227
|
+
|
|
228
|
+
For a canvas operator there are two things to know:
|
|
229
|
+
|
|
230
|
+
- **Your canvas answers its own summary probes, and only signed ones.** The front
|
|
231
|
+
door presents a `marver-summary+jwt` (audience = your exact origin, authorized
|
|
232
|
+
party = the app, valid ≤120s); the canvas verifies it against the identity
|
|
233
|
+
service's published keys before answering, and the answer is signed by the
|
|
234
|
+
canvas so the browser can pin it. Owner mutations from the dialog carry a
|
|
235
|
+
separate `marver-owner-api+jwt` (≤300s), and outbound mail rides a
|
|
236
|
+
`marver-relay+jwt`. You do not configure any of this - it is the wire, listed
|
|
237
|
+
so you can recognize it.
|
|
238
|
+
- **You can keep your canvas out of it.** `share: { frontDoor: false }` in
|
|
239
|
+
`design/config.ts` makes the canvas stop answering front-door identity and
|
|
240
|
+
summary probes - the app receives no signed summary from this canvas, so it has
|
|
241
|
+
nothing to show. The privacy tradeoff around the front door is disclosed in full
|
|
242
|
+
in the [publishing guide](publish.md#who-can-open-your-canvas); this is the
|
|
243
|
+
switch that makes your canvas silent to it.
|
|
244
|
+
|
|
245
|
+
## How access is computed (the resolver, precisely)
|
|
246
|
+
|
|
247
|
+
For anyone who wants to check rather than trust, here is the exact order the one
|
|
248
|
+
resolver (`resolveAccess`) runs, top to bottom:
|
|
249
|
+
|
|
250
|
+
1. **Blocklist.** If the address is blocked, every board is `none` and the person
|
|
251
|
+
is refused, and nothing below runs. (An anonymous caller - past a password
|
|
252
|
+
gate, or on a public canvas - has no identity to block, so this step never
|
|
253
|
+
fires for them, which is why blocking does not stop anonymous entry.)
|
|
254
|
+
2. **Owner.** The canvas owner precedes principal matching and resolves to
|
|
255
|
+
`comment` on every board - still clamped by the ceiling, so an unpublished
|
|
256
|
+
board is empty even for the owner.
|
|
257
|
+
3. **Grants, additive, highest wins.** Every live grant matching the address
|
|
258
|
+
(exact, or by verified domain) contributes its `boardRole`, read through the
|
|
259
|
+
read-time ceiling `min`. The highest contribution per board wins.
|
|
260
|
+
4. **General access.** If the operative mode is not `private`, everyone admitted
|
|
261
|
+
gets `view` as a floor.
|
|
262
|
+
5. **Ceiling clamp.** Every board's result is `min(result, publish.json ceiling)`.
|
|
263
|
+
|
|
264
|
+
`entry` (may they open the canvas at all) is "at least `view` on at least one
|
|
265
|
+
board". Note the gate's own entry check short-circuits step 1 for the owner - the
|
|
266
|
+
owner is always admitted so the canvas can be administered - which is the one
|
|
267
|
+
place the blocklist does not have the last word.
|
|
268
|
+
|
|
269
|
+
## Mail, mentions, and the bell (v1.1)
|
|
270
|
+
|
|
271
|
+
Sharing's mail rides one relay at the identity service, and the canvas can only
|
|
272
|
+
ever choose a **template** - never a subject or a body. Activity mail carries
|
|
273
|
+
exactly three variables beyond the origin: the commenter's display name, a
|
|
274
|
+
comment snippet capped at 180 characters, and your own unsubscribe link -
|
|
275
|
+
which means the identity service holds those snippets for its delivery window.
|
|
276
|
+
That is a deliberate, disclosed widening (every collaboration product's
|
|
277
|
+
mention mail shows who and what; a mail that names neither gets ignored).
|
|
278
|
+
Transactional mail (invites, approvals, requests) carries no name and no
|
|
279
|
+
content, and `share: { notify: false }` opts out of all of it. Three transactional moments (you were invited, your request
|
|
280
|
+
was approved, someone asked for access) shipped with v1; v1.1 adds the two
|
|
281
|
+
**activity** moments that pull collaborators back:
|
|
282
|
+
|
|
283
|
+
- **A thread you are in moved.** A fresh reply mails the thread's other
|
|
284
|
+
participants - the 10 most recent distinct people, at most one mail per
|
|
285
|
+
person per thread per 6-hour window, and only for replies just written
|
|
286
|
+
(history imports and syncs never mail anyone).
|
|
287
|
+
- **Somebody named you.** Typing `@` in the comment composer offers the people
|
|
288
|
+
already visible in the canvas's comments; a completed `@Name` mails that
|
|
289
|
+
person once for that comment and rings their bell on the front door. You can
|
|
290
|
+
only mention people you can already see - the roster is never disclosed, and
|
|
291
|
+
in the browser mentions travel as the same opaque ids authors do, so no
|
|
292
|
+
member's address ever reaches another viewer.
|
|
293
|
+
|
|
294
|
+
While you are ON the canvas, the same moments notify in place: a mention (or a
|
|
295
|
+
reply in your thread) raises a bottom-right pill with the author's face and
|
|
296
|
+
message plus a soft ping, and the mentioned thread's pin pulses in accent blue
|
|
297
|
+
until you open it. The mail's button deep-links straight to that thread.
|
|
298
|
+
|
|
299
|
+
Only people who still resolve to at least `view` are mailed - a revoked or
|
|
300
|
+
blocked participant's mail stops with their access. Every activity mail carries
|
|
301
|
+
an **unsubscribe link**: it can mute replies, mentions, or all activity mail,
|
|
302
|
+
for that canvas or everywhere, and it mutes **email only** - the bell keeps
|
|
303
|
+
working. Invitations and approvals are never muted (an invite you cannot
|
|
304
|
+
receive is a lockout, not a courtesy). `share: { notify: false }` still
|
|
305
|
+
declines the relay entirely, activity mail included.
|
|
306
|
+
|
|
307
|
+
## What v1 does not do
|
|
308
|
+
|
|
309
|
+
Sharing v1 controls **who gets in** and **who may comment**. It does **not** yet
|
|
310
|
+
do per-person *read* privacy: every admitted person can read every published
|
|
311
|
+
board. The read boundary in v1 is the bundle itself - a board you do not publish
|
|
312
|
+
is not in the build, so the way to keep something from an audience today is a
|
|
313
|
+
separate canvas for that audience. ("v2" here means this next release, the
|
|
314
|
+
read-privacy one - a different thing from the `publish.json` v2 *row schema*
|
|
315
|
+
above, which ships now.)
|
|
316
|
+
|
|
317
|
+
Three consequences, stated plainly because they are easy to assume otherwise:
|
|
318
|
+
|
|
319
|
+
- **Grants are canvas-scoped.** `share.json` is already shaped for board-scoped
|
|
320
|
+
grants, but the CLI and dialog accept `canvas` scope only - a board-scoped grant
|
|
321
|
+
would open the whole bundle while reading as "just this board", so the door
|
|
322
|
+
refuses it until read privacy makes it real.
|
|
323
|
+
- **Approvals are canvas-wide.** An approved access request covers every published
|
|
324
|
+
board, and the surfaces say so. The request records what the refused link
|
|
325
|
+
pointed at, but that target is context in v1, not an enforced scope.
|
|
326
|
+
- **A deep link is presentation, not a wall.** A single-frame or single-board
|
|
327
|
+
link lands that visit in the right view, but the rest of the canvas stays
|
|
328
|
+
reachable by URL. The door renders because there is one frame to show, not
|
|
329
|
+
because the others are protected.
|
|
330
|
+
|
|
331
|
+
All three become enforced in the read-privacy release, when boards are *served*
|
|
332
|
+
per person rather than *bundled*. The schema is already ready for it; v1 declines
|
|
333
|
+
the operations it cannot honor rather than pretending to.
|