From d23529e128e6686c851ada3c107e519652b8e4e0 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Tue, 20 Oct 2026 23:37:50 +0100 Subject: [PATCH 01/10] chore(sync): sync marfa v0.0.18 --- openapi.json | 142 ++++++++++++++++++----- src/generated/GLOSSARY.md | 39 +++++++ src/generated/cli-download.md | 2 +- src/generated/cli/global-options.md | 5 +- src/generated/contract-index.json | 174 ++++++++++++++++++++++------ src/generated/marfa-release.json | 2 +- src/generated/server-image.md | 4 +- 7 files changed, 300 insertions(+), 68 deletions(-) diff --git a/openapi.json b/openapi.json index c4657862..15d78ac9 100644 --- a/openapi.json +++ b/openapi.json @@ -2264,7 +2264,7 @@ ], "description": "What happened to one entry of a bulk write." }, - "BulkAtomicRollbackOrMissingRequiredFieldOrValidationErrorRefusal": { + "BulkAtomicRollbackOrInvalidPropertiesOrMissingRequiredFieldOrValidationErrorRefusal": { "type": "object", "properties": { "error": { @@ -2274,6 +2274,7 @@ "type": "string", "enum": [ "bulk_atomic_rollback", + "invalid_properties", "missing_required_field", "validation_error" ], @@ -2444,7 +2445,7 @@ ], "description": "A bulk action Marfa runs in the background, with its progress." }, - "BulkCapExceededOrBulkConfirmationRequiredOrMissingRequiredFieldOrValidationErrorRefusal": { + "BulkCapExceededOrBulkConfirmationRequiredOrInvalidPropertiesOrMissingRequiredFieldOrValidationErrorRefusal": { "type": "object", "properties": { "error": { @@ -2455,6 +2456,7 @@ "enum": [ "bulk_cap_exceeded", "bulk_confirmation_required", + "invalid_properties", "missing_required_field", "validation_error" ], @@ -2684,6 +2686,44 @@ ], "description": "An item's extension namespaces that you can read." }, + "InvalidIdOrInvalidPropertiesOrMissingRequiredFieldOrValidationErrorRefusal": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "invalid_id", + "invalid_properties", + "missing_required_field", + "validation_error" + ], + "description": "A machine-readable code for the error. Use it in your logic." + }, + "message": { + "type": "string", + "description": "A description of the error for a person to read. It can change, so don't match on it." + }, + "details": { + "type": "object", + "additionalProperties": {}, + "description": "More about the error, such as the field it concerns. Each code defines its own details." + } + }, + "required": [ + "code", + "message" + ], + "description": "What went wrong." + } + }, + "required": [ + "error" + ], + "description": "An error response." + }, "EdgeResponse": { "type": "object", "properties": { @@ -5620,6 +5660,44 @@ ], "description": "One page of a connector's agreements." }, + "InvalidPropertiesOrMissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "invalid_properties", + "missing_required_field", + "unknown_type", + "validation_error" + ], + "description": "A machine-readable code for the error. Use it in your logic." + }, + "message": { + "type": "string", + "description": "A description of the error for a person to read. It can change, so don't match on it." + }, + "details": { + "type": "object", + "additionalProperties": {}, + "description": "More about the error, such as the field it concerns. Each code defines its own details." + } + }, + "required": [ + "code", + "message" + ], + "description": "What went wrong." + } + }, + "required": [ + "error" + ], + "description": "An error response." + }, "FolderSearch": { "type": "object", "properties": { @@ -5685,7 +5763,7 @@ "properties": { "type": "object", "additionalProperties": {}, - "description": "Properties a new file takes where its frontmatter doesn't set them. A default for the type's body field is never taken: the file's body is that property." + "description": "Properties a new file takes where its frontmatter doesn't set them. A default for the type's body field is never taken: the file's body is that property. A whole number whose digits a 64-bit float doesn't keep is refused." }, "tags": { "type": "array", @@ -5730,7 +5808,7 @@ "additionalProperties": false, "description": "A removal waits to be confirmed when it takes more than `files` files and more than `fraction` of the folder's files." }, - "InvalidIdOrInvalidTransitionOrMissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal": { + "InvalidIdOrInvalidPropertiesOrInvalidTransitionOrMissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal": { "type": "object", "properties": { "error": { @@ -5740,6 +5818,7 @@ "type": "string", "enum": [ "invalid_id", + "invalid_properties", "invalid_transition", "missing_required_field", "unknown_type", @@ -8402,7 +8481,7 @@ "properties": { "type": "object", "additionalProperties": {}, - "description": "The item's properties, checked against the type's schema. If the instance's strict mode names the type, an undeclared property is refused as `invalid_properties` with `details.code` `unknown_property`." + "description": "The item's properties, checked against the type's schema. Strict mode refuses an undeclared property as `invalid_properties` (`details.code` `unknown_property`). A whole number whose digits a 64-bit float doesn't keep is refused: send it as a string." }, "id": { "type": "string", @@ -10220,7 +10299,7 @@ "properties": { "type": "object", "additionalProperties": {}, - "description": "The properties to write. They lay over the item's properties, or become all of them when `properties_mode` is `replace`. Strict mode refuses an undeclared property, as in `POST /items`." + "description": "The properties to write. They lay over the item's properties, or become all of them when `properties_mode` is `replace`. As in `POST /items`, strict mode refuses an undeclared property, and a whole number whose digits a 64-bit float doesn't keep is refused." }, "type": { "type": "string", @@ -14994,7 +15073,7 @@ "properties": { "type": "object", "additionalProperties": {}, - "description": "The item's properties, checked against the type's schema. If the instance's strict mode names the type, an undeclared property is refused as `invalid_properties` with `details.code` `unknown_property`." + "description": "The item's properties, checked against the type's schema. Strict mode refuses an undeclared property as `invalid_properties` (`details.code` `unknown_property`). A whole number whose digits a 64-bit float doesn't keep is refused: send it as a string." }, "properties_mode": { "type": "string", @@ -15110,11 +15189,11 @@ } }, "400": { - "description": "- `validation_error`: the body is malformed, has a key this operation doesn't declare, or has more than 5,000 entries.\n- `missing_required_field`: a required field is missing.\n- `bulk_atomic_rollback`: with `atomic` true, an entry was refused and nothing was written. `details.code` and `details.index` give its code and position. The status is the one that refusal carries alone.", + "description": "- `validation_error`: the body is malformed, has an undeclared key or has over 5,000 entries.\n- `missing_required_field`: a required field is missing.\n- `invalid_properties`: an entry's `properties` hold a whole number a 64-bit float can't keep.\n- `bulk_atomic_rollback`: an `atomic` entry was refused and nothing was written (`details.code`, `details.index`). The status is the refusal's own.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkAtomicRollbackOrMissingRequiredFieldOrValidationErrorRefusal" + "$ref": "#/components/schemas/BulkAtomicRollbackOrInvalidPropertiesOrMissingRequiredFieldOrValidationErrorRefusal" } } }, @@ -15626,7 +15705,8 @@ }, "patch": { "type": "object", - "additionalProperties": {} + "additionalProperties": {}, + "description": "The properties to lay over each item's properties. A whole number whose digits a 64-bit float doesn't keep is refused: send it as a string." } }, "required": [ @@ -15735,11 +15815,11 @@ } }, "400": { - "description": "- `validation_error`: the body or `filter` is malformed or has an undeclared key or an empty field, `update_tags` has neither `add` nor `remove`, or `expected_ids` is empty or not on a purge.\n- `missing_required_field`: a field the action needs is missing.\n- `bulk_confirmation_required`: a purge without `confirm: \"PURGE\"`.\n- `bulk_cap_exceeded`: more items match than `max_items` allows.", + "description": "- `validation_error`: the body is malformed or has an undeclared or empty field, or `update_tags` has no `add` or `remove`.\n- `missing_required_field`: a needed field is missing.\n- `invalid_properties`: `patch` holds a whole number a 64-bit float cannot keep.\n- `bulk_confirmation_required`: a purge without `confirm: \"PURGE\"`.\n- `bulk_cap_exceeded`: more items match than `max_items` allows.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkCapExceededOrBulkConfirmationRequiredOrMissingRequiredFieldOrValidationErrorRefusal" + "$ref": "#/components/schemas/BulkCapExceededOrBulkConfirmationRequiredOrInvalidPropertiesOrMissingRequiredFieldOrValidationErrorRefusal" } } }, @@ -18607,7 +18687,7 @@ "schema": { "type": "object", "additionalProperties": {}, - "description": "The namespace's new data, as a JSON object." + "description": "The namespace's new data, as a JSON object. A whole number whose digits a 64-bit float doesn't keep is refused: send it as a string." } } } @@ -18644,11 +18724,11 @@ } }, "400": { - "description": "- `validation_error`: the body is not a JSON object, or its JSON is longer than 102,400 characters.\n- `invalid_id`: the ID is not a valid item ID.", + "description": "- `validation_error`: the body is not a JSON object, or its JSON is longer than 102,400 characters.\n- `invalid_id`: the ID is not a valid item ID.\n- `invalid_properties`: the body holds a whole number a 64-bit float can't keep.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidIdOrMissingRequiredFieldOrValidationErrorRefusal" + "$ref": "#/components/schemas/InvalidIdOrInvalidPropertiesOrMissingRequiredFieldOrValidationErrorRefusal" } } }, @@ -20587,7 +20667,7 @@ "properties": { "type": "object", "additionalProperties": {}, - "description": "The edge's properties. Leave it out for none." + "description": "The edge's properties. Leave it out for none. A whole number whose digits a 64-bit float doesn't keep is refused: send it as a string." } }, "required": [ @@ -21449,7 +21529,7 @@ "properties": { "type": "object", "additionalProperties": {}, - "description": "Properties to merge over the edge's. Required unless an end moves." + "description": "Properties to merge over the edge's. Required unless an end moves. A whole number whose digits a 64-bit float doesn't keep is refused: send it as a string." }, "source_id": { "type": "string", @@ -22355,7 +22435,7 @@ "properties": { "type": "object", "additionalProperties": {}, - "description": "The edge's properties. On an existing edge they merge over its own. Leave it out for none." + "description": "The edge's properties. On an existing edge they merge over its own. Leave it out for none. A whole number whose digits a 64-bit float doesn't keep is refused: send it as a string." }, "version": { "type": "integer", @@ -22426,11 +22506,11 @@ } }, "400": { - "description": "- `validation_error`: the body is malformed, has an undeclared key, or has more than 5,000 entries.\n- `missing_required_field`: `edges` is missing, or an entry lacks `source_id`, `target_id` or `edge_type`.\n- `bulk_atomic_rollback`: with `atomic` true, an entry was refused and nothing was written. `details.code` and `details.index` give its code and position. The status is that refusal's own.", + "description": "- `validation_error`: the body is malformed, has an undeclared key or has over 5,000 entries.\n- `missing_required_field`: a required field is missing.\n- `invalid_properties`: an entry's `properties` hold a whole number a 64-bit float can't keep.\n- `bulk_atomic_rollback`: an `atomic` entry was refused and nothing was written (`details.code`, `details.index`). The status is the refusal's own.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkAtomicRollbackOrMissingRequiredFieldOrValidationErrorRefusal" + "$ref": "#/components/schemas/BulkAtomicRollbackOrInvalidPropertiesOrMissingRequiredFieldOrValidationErrorRefusal" } } }, @@ -37265,11 +37345,11 @@ } }, "400": { - "description": "- `validation_error`: a setting is malformed, such as a `system.*` type, an invalid `filter` or a `first_placement` directory outside the folder. `details.errors[0].path` names it. A key the body or a setting doesn't declare is named in `details.unknown_body_fields`.\n- `missing_required_field`: `title` is missing.\n- `unknown_type`: a setting names a type that isn't registered.", + "description": "- `validation_error`: a setting is malformed or undeclared, such as a `system.*` type or a `first_placement` outside the folder. `details.errors[0].path` or `details.unknown_body_fields` names it.\n- `missing_required_field`: `title` is missing.\n- `unknown_type`: a setting's type is unknown.\n- `invalid_properties`: `defaults.properties` has a whole number a 64-bit float can't keep.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal" + "$ref": "#/components/schemas/InvalidPropertiesOrMissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal" } } }, @@ -37753,11 +37833,11 @@ } }, "400": { - "description": "- `validation_error`: a setting is malformed (`details.errors[0].path` names it), a key is undeclared (`details.unknown_body_fields` names it), the body names no setting, or the query has `conflict`.\n- `missing_required_field`: `version` is missing.\n- `unknown_type`: a setting names a type that isn't registered.\n- `invalid_id`: the ID is malformed.\n- `invalid_transition`: the folder is revoked.", + "description": "- `validation_error`: a setting is malformed or undeclared (`details.errors[0].path` or `details.unknown_body_fields` names it).\n- `missing_required_field`: `version` is missing.\n- `unknown_type`: a setting's type is unknown.\n- `invalid_properties`: `defaults.properties` has a whole number a 64-bit float can't keep.\n- `invalid_id`: the ID is invalid.\n- `invalid_transition`: the folder is revoked.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidIdOrInvalidTransitionOrMissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal" + "$ref": "#/components/schemas/InvalidIdOrInvalidPropertiesOrInvalidTransitionOrMissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal" } } }, @@ -40751,11 +40831,15 @@ "string", "null" ], - "description": "Replaces when the key stops working, as an ISO 8601 date and time in the future. `null` clears it. Leave it out to keep it. It can't be later than your own key's `expires_at`, or the key's current one for a key an app made or through `keys.manage`." + "description": "Replaces when the key stops working, as an ISO 8601 date and time in the future. `null` clears it. Leave it out to keep it. It can't be later than your own key's `expires_at`, or the key's current one for a key an app made, through `keys.manage` or with `narrow_only`." }, "source": { "type": "string", "description": "Can't change. To give a key another source, create a new key and revoke this one." + }, + "narrow_only": { + "type": "boolean", + "description": "`true` to refuse the update if it would give the key more: a permission, map entry or claimed source it doesn't hold, a later `expires_at` or none, or an `enforcement_override` other than its own. Defaults to `false`." } }, "additionalProperties": false @@ -40852,7 +40936,7 @@ } }, "403": { - "description": "- `forbidden`: you don't hold `keys.mint` and aren't the owner or local command; the body gives the key a permission, map entry or source you don't hold, or an `expires_at` later than yours; it widens a key an app created or, through `keys.manage`, any key; or it names `enforcement_override` and you don't hold `config.manage`. `details.required_scope` or `details.source` names what's missing.", + "description": "- `forbidden`: you don't hold `keys.mint` and aren't the owner or local command; the body gives the key more than you hold or an `expires_at` later than yours; it widens or lengthens an app's key, or any key through `keys.manage` or with `narrow_only`, which also refuses a changed `enforcement_override`; or it names `enforcement_override` without `config.manage`. `details` names what's missing.", "content": { "application/json": { "schema": { @@ -44370,7 +44454,7 @@ "ownerSession": [] } ], - "description": "Ends a sign-in at once: it can't make another request, and an app can't refresh its tokens. The keys an app minted stay unless `revoke_keys` is `true`. Requires the owner's browser session, signed in within five minutes, or the local command.", + "description": "Ends a sign-in at once: it can't refresh or make another request. An app's keys stay unless `revoke_keys` is `true`. Needs the owner's browser session or the local command. A browser over five minutes old can end only itself, without `revoke_keys`.", "parameters": [ { "schema": { @@ -44496,7 +44580,7 @@ } }, "403": { - "description": "- `forbidden`: the request carries a key or an app's token rather than the owner's browser session or the local command's authority, or the browser signed in more than five minutes ago.", + "description": "- `forbidden`: the request carries a key or an app's token rather than the owner's browser session or the local command's authority, or the browser signed in more than five minutes ago and the ID names another sign-in or `revoke_keys` is `true`.", "content": { "application/json": { "schema": { @@ -48192,7 +48276,7 @@ "Webhooks" ], "summary": "Update a webhook", - "description": "Updates a webhook and returns it. Fields you leave out keep their values. Changing `url`, or setting `active` to `false`, cancels its pending deliveries. To change the secret, create a new webhook.", + "description": "Updates a webhook and returns it. Fields you leave out keep their values. Marfa cancels a pending delivery if you change `url`, set `active` to `false`, or change `events` or `type_filter` to exclude it. To change the secret, create a new webhook.", "security": [ { "bearerAuth": [] diff --git a/src/generated/GLOSSARY.md b/src/generated/GLOSSARY.md index 2af539f7..d990d271 100644 --- a/src/generated/GLOSSARY.md +++ b/src/generated/GLOSSARY.md @@ -70,6 +70,45 @@ The words are grouped by topic. | **local authority** | Authority supplied only by the private Unix socket to a process running under the server's operating-system account. It is not an API key and cannot be asserted through public HTTP. | | **setup code** | A machine-issued proof that authorizes one owner claim. It grants no API access and stops working when setup closes or its generation is replaced. | +### Access words + +Access is read the same way wherever a person sees it: on the pages where they approve it, on a key's page, on an app's page and in the CLI's output. For each kind of thing it is one level, and a short list of named powers. The server builds the description in `packages/server/src/routes/access-model.ts`, from the words in `scope-copy.ts` and `permission-labels.ts` beside it; the CLI keeps the same words in `core/marfa-cli/src/access.rs`; `ci/access-words.test.ts` holds both to the tables below. A row the table does not title, such as an item type or a link type, is named by its own label on a page and as the scope writes it in the CLI (`core.note`, `edge.references`). + +| Level | The word | +| ------- | ------------- | +| `none` | No access | +| `read` | Read only | +| `write` | Read and edit | + +| Row | The title | +| --------------------- | -------------------------- | +| `*` | All your data | +| `content` | Your content | +| `user.*` | Your custom types | +| `edge.*` | Links between items | +| `profile` | Profile | +| `profile.name` | Name | +| `profile.email` | Email address | +| `profile.avatar` | Avatar | +| `metadata` | Definitions on your server | +| `metadata.types` | Custom data types | +| `metadata.edge_types` | Custom relationship types | + +| Power | The title | +| ------------------- | ----------------------------------------------------------- | +| `keys.mint` | Make, see, edit and revoke keys | +| `keys.manage` | See, edit, narrow and revoke every key | +| `grants.manage` | See and disconnect other apps | +| `config.manage` | See and change Marfa's settings | +| `instance.read` | See health, metrics, storage and job status | +| `instance.maintain` | Run housekeeping, reset platform types and cancel bulk jobs | +| `schema.write` | Replace or remove type definitions | +| `blobs.manage` | Read and add any file, and manage file storage | +| `webhooks.manage` | Manage webhooks that send events to other services | +| `connectors.manage` | See, manage and remove all connectors | +| `audit.read` | Read the activity log | +| `items.purge` | Delete items in the trash for good | + ### The instance | Word | What it means | diff --git a/src/generated/cli-download.md b/src/generated/cli-download.md index 95f897a7..755e9375 100644 --- a/src/generated/cli-download.md +++ b/src/generated/cli-download.md @@ -1,5 +1,5 @@ ```bash -curl -fsSL https://github.com/withmarfa/marfa/releases/download/v0.0.17/marfa-0.0.17-darwin-arm64.tar.gz | tar -xz +curl -fsSL https://github.com/withmarfa/marfa/releases/download/v0.0.18/marfa-0.0.18-darwin-arm64.tar.gz | tar -xz sudo mkdir -p /usr/local/bin sudo mv marfa /usr/local/bin/ marfa --version diff --git a/src/generated/cli/global-options.md b/src/generated/cli/global-options.md index 6f38331f..93178cb4 100644 --- a/src/generated/cli/global-options.md +++ b/src/generated/cli/global-options.md @@ -2,8 +2,9 @@ Every command accepts these. | Option | Description | | --- | --- | -| `--url ` | The server's base URL. Falls back to MARFA_API_URL, then to the server a kept credential made current. | -| `--key ` | A key or a token for that server. Prefer MARFA_API_KEY or the keychain: a command line is readable by other users of the machine and stays in shell history. Falls back to MARFA_API_KEY, then to the keychain: the file MARFA_KEYCHAIN names, where it names one. | +| `--url ` | The server's base URL. Falls back to MARFA_API_URL, then to the server a kept credential made current. A command that sends a key from --key or MARFA_API_KEY needs the URL named, and so does any command with the keychain off. | +| `--key ` | A key or a token for that server. Prefer MARFA_API_KEY or the keychain: a command line is readable by other users of the machine and stays in shell history. Falls back to MARFA_API_KEY, then to the keychain: the file MARFA_KEYCHAIN names, where it names one, unless the keychain is off. | +| `--no-keychain` | Treat the keychain as absent: read nothing from it and write nothing to it, so the server and the key must come from --url, --key, MARFA_API_URL or MARFA_API_KEY. A command that needs to keep something refuses. MARFA_NO_KEYCHAIN=1 does the same. | | `--allow-http` | Send a credential over plain http to a host that is not this machine without the warning, for a network that is private. MARFA_ALLOW_HTTP=1 does the same. | | `--socket ` | Use private local process authority through this Unix socket, without a keychain. | | `--json` | Print the answer as JSON, and a refusal as one JSON object on stderr, after any warning, which is one plain line. | diff --git a/src/generated/contract-index.json b/src/generated/contract-index.json index db330452..017ece03 100644 --- a/src/generated/contract-index.json +++ b/src/generated/contract-index.json @@ -10,15 +10,33 @@ "authentication/passkey-password-lock": "19e5fbf4a92d5049", "authentication/passkey-removal": "3cf0ca6cd05e51e7", "authentication/passkey-restart": "ff4dd2ca7c0a56f9", - "authentication/passkey-address": "072ac2a244ec282c", + "authentication/passkey-address": "dcf5e2b5f0e01050", "authentication/passkey-duplicate": "84e155ef6b4f85ad", "authentication/passkey-owner-only": "c626f5cf84ac99b4", "authentication/passkey-origin": "585591ff2d852436", "authentication/passkey-recent-management": "4872775ae6c4112f", "authentication/passkey-rename": "873a65d2824c5d11", + "authentication/passkey-registration-name": "e4529617d0e79edf", "authentication/passkey-management-not-found": "8af17181724212c9", "authentication/passkey-management-atomic": "b3b8acc6908ebac0", - "authentication/passkey-add-url": "5bb420ac86902958" + "authentication/passkey-add-url": "5bb420ac86902958", + "authentication/recent-refusal": "010cda700eeb99da", + "authentication/confirm-replaces": "e494d296e5b7a1b5", + "authentication/confirm-ends-old": "490b091e92e351b9", + "authentication/confirm-wrong-password": "13dab28ee1800518", + "authentication/confirm-lanes": "95714755d99ed182", + "authentication/confirm-locked": "2125f16bd33d220d", + "authentication/confirm-refused-keeps": "b9b3510d166cd4e5", + "authentication/confirm-names-no-account": "4f1286d50706cfd2", + "authentication/confirm-passkey-owner": "874f470a6620c4e4", + "authentication/confirm-needs-session": "ce8ac0c02906f879", + "authentication/confirm-origin": "248cf3e06973c5d4", + "authentication/passkey-register-recent": "867c32e933ffddb8", + "authentication/console-sign-in": "33df0f8ae12e0858", + "authentication/console-sign-in-no-store": "6557bafa3e9a287c", + "authentication/console-no-redirect": "379183a0c6f866ae", + "authentication/console-unclaimed": "e38dd34fd5163837", + "authentication/console-keys-no-plaintext": "e12c41e4070f02cc" }, "blobs": { "blobs/read-idle-lifetime": "343c013f840aa398", @@ -769,6 +787,12 @@ "device/save-before-sync-joins": "02b863a086eef3bc", "device/create-slice-tier": "e39b49cc543f808d", "device/declared-checked": "2566560af1b8f03b", + "device/number-exact": "1a5d91b7731c768a", + "device/number-exact-folder-flagged": "1bc25c70f8059113", + "device/number-exact-folder-kept": "f4abbcf9e7a313b9", + "device/number-digits": "e7d56d1cce146bd6", + "device/url-trimmed": "28313af0bd24a6d5", + "device/url-idna-unassigned": "2a9edf20a556e53d", "device/unknown-type-write": "69342d8d253b5913", "device/declaration-refused": "46d894c681563663", "device/declaration-refused-whole": "a4978e4678068722", @@ -795,7 +819,7 @@ "device/stop-no-retry": "6ef8eee75b47285d", "device/stop-first-request": "7a3ae9cb3b81afb3", "device/stop-first-request-unchanged": "258ff5288c2da4aa", - "device/tag-bound": "14523bfe935f38a3", + "device/tag-bound": "7b1e7174b8bc7493", "device/tag-count": "86ee8eeffb98f09e", "device/tag-bound-nothing-saved": "817aea68c4602642", "device/tag-bound-taken": "e1bf050475406c4c", @@ -1532,6 +1556,7 @@ "events/retry-poll-run": "5cb0290d41f8a694", "events/retry-restart-once": "3de84b2c2cf3c0e4", "events/cancel-edited": "41c7ab9a2feea7da", + "events/cancel-unselected": "888d9906e7f7f956", "events/cancel-deleted": "05aaa3087317b769", "events/cancel-record": "cc8dcbc457cac095", "events/redeliver-accepted": "9ddaf60bbdc7105c", @@ -1805,8 +1830,8 @@ "folders/time-null-empty": "076555860f525261", "folders/time-null-preserved": "5695c387a083ca0a", "folders/time-render": "efb491ce795d017c", - "folders/time-invalid": "9e2c8ec2d695bc10", - "folders/own-field-validation": "af443f0c9aefa7ae", + "folders/time-invalid": "42abab0244401a1e", + "folders/own-field-validation": "ddbcc6c42cb92c00", "folders/tag-state-forms": "2cc13528c991a383", "folders/preserve-source": "da94a142f4a06d6f", "folders/preserve-change-in-place": "c0c07d223491f9f6", @@ -1846,18 +1871,22 @@ "folders/document-name-no-writes": "47513baa0e261b66", "folders/document-name-continue": "0314bae960d92265", "folders/document-name-retry": "a11dd9df86245c7e", - "folders/unreadable-yaml": "b37046a97a2d4212", + "folders/unreadable-yaml": "2885c86952ae4e23", "folders/unreadable-no-write": "1e2ae833610fb8c9", "folders/unreadable-preserve": "ba1d3502556f580d", "folders/unreadable-report": "77b8a127d08e32eb", "folders/unreadable-rename": "af854e90cf8cd754", "folders/unreadable-repaired": "e3d5f5ed62eaf7f7", - "folders/encoding-flag": "ccae19d84c2335d8", + "folders/encoding-flag": "4c241ee47a3e9373", "folders/encoding-no-send": "c00d9872d5567b7d", "folders/encoding-preserve": "0da85bd5e7d9b381", "folders/encoding-status": "e7f95f6a7551df7d", "folders/encoding-repaired": "7a380fd7f643f199", "folders/file-bytes-not-document": "22dda5bee37721ff", + "folders/file-item-doc-name-sent": "face4bebd45344f3", + "folders/file-item-doc-name-unflagged": "f14624fbd75d79e8", + "folders/file-item-doc-name-emptied": "0bcc3bc1b41f6b6b", + "folders/file-item-doc-name-emptied-scan": "ba27c103d7f00b3a", "folders/version-line": "52e3d6e0d162afe3", "folders/version-stale-merge": "ebce032284cf0c02", "folders/version-absent-merge": "5b9d1faec2ac4dbd", @@ -1975,7 +2004,7 @@ "folders/embed-list-paragraph": "b6950ec034b0851c", "folders/embed-duplicate-line": "0f92efe120c81458", "folders/embed-upload-size": "8e9097cde63150a5", - "folders/markdown-extensions": "955cea5f28980bb9", + "folders/markdown-extensions": "7d13ca30c264866c", "folders/id-rename": "57d9fa591548614f", "folders/id-missing-binding": "0bf5a6f40bff5755", "folders/id-minted": "29592adc242c3768", @@ -1998,7 +2027,7 @@ "folders/id-unknown-new": "4eea10c487041eb6", "folders/id-system-ignored": "f5bf8cbdb94ab9f7", "folders/id-file-item-ignored": "e360cf24f4698a47", - "folders/raw-text-body": "3cf85c9a2b2f8d02", + "folders/raw-text-body": "d8b50f864566caa4", "folders/identity-rename": "1cd5ac0da2af6163", "folders/identity-unusable": "e2b366e0bdcc118d", "folders/identity-admitted-files": "b04df5b8f756fab2", @@ -2250,7 +2279,9 @@ "folders/remove-deleted-reported": "d614ae68ea571dc9", "folders/remove-unlisted-reported": "d8a26b9c4308e7ab", "folders/remove-unlisted-names-other": "683f7bd77e0aea68", - "folders/registry-removed-state": "828d221028083b8e", + "folders/registry-root-identity": "2582a0f7eb5ee76d", + "folders/registry-removed-state": "8fd1591f5af1b7c5", + "folders/registry-replaced-kept": "ee475b6966e8a153", "folders/registry-missing-kept": "0716971ee28a34e5", "folders/registry-unreadable-deletes-held": "662ca30f9c1ad7f1", "folders/registry-invalid-replaced-on-add": "54aac21a39c9cafa", @@ -2272,7 +2303,7 @@ "folders/uncertain-move-deferred": "26ddcaa6fb20dbd5", "folders/uncertain-move-deferred-unsure-reported": "a44b92875008474a", "folders/uncertain-move-not-text": "986c81a1f5d0d8b1", - "folders/moved-arrival-bytes": "291b20f2e114cf2b", + "folders/moved-arrival-bytes": "182a4887840a2089", "folders/moved-awaiting-peer": "e8ba48c290b6ec04", "folders/moved-awaiting-peer-no-replacement": "5bd0224352a549f5", "folders/watch-every-listed": "9b481488d7a7fba1", @@ -2283,6 +2314,7 @@ "folders/watch-every-others-go-on": "96a9f1359ddad31a", "folders/watch-every-missing-said": "e6121f2cb97d581e", "folders/watch-every-tried-again": "8bccfc61dce115ee", + "folders/watch-every-root-returns": "d97112af0e15293d", "folders/watch-every-credential-refused": "d1e8e316bbdccc14", "folders/watch-every-joins": "2c2c6aa4617d97ba", "folders/watch-every-leaves": "bc18ccdded59023b", @@ -2971,6 +3003,11 @@ "instance/root-page": "c1c32057467c83fa", "instance/root-description": "1a23cea416e66416", "instance/root-vary": "83d7f57713e419b5", + "instance/root-home": "50bdb68a4574ccb5", + "instance/root-home-search": "01113994fb342a31", + "instance/root-home-no-store": "07150c564aec3a48", + "instance/root-search-key": "f7d68a6991981226", + "instance/root-search-key-other": "c9c097224350f3ad", "instance/page-policy": "e1a892a64626051b", "instance/page-policy-nonce-only": "259228eb3bbefd69", "instance/page-policy-default-none": "f79682f7197d8b42", @@ -3087,6 +3124,9 @@ "items/property-length-cap": "57562925648f183e", "items/property-length-units": "64059eeb24118d15", "items/property-invalid": "df9a4fb4de90661f", + "items/property-number-exact": "bfde0be15944ce72", + "items/property-number-exact-bulk": "f6e73bdcef84f1eb", + "items/property-number-kept": "8063229297a2dddf", "items/property-name-empty": "53434991ae248cc1", "items/create-null-optional": "45736d3bdda52d4e", "items/property-order-create": "0ebfda02b3aaa2a4", @@ -3099,6 +3139,7 @@ "items/tags-empty": "c5979c0bc344a0c1", "items/tags-count": "f9f5b02c6931fceb", "items/tag-text": "8e92a2b85c1f0b78", + "items/tag-dot-segment": "0c7f8cbae600b094", "items/create-no-type": "d62ba775fc751523", "items/create-malformed-type": "75faf07479567aca", "items/create-unknown-type": "1cb37252ab035e16", @@ -3384,6 +3425,7 @@ "keys-and-oauth/mint-ceiling": "5c91f34ac349a810", "keys-and-oauth/namespace-ceiling": "9eaad2cc36929ad4", "keys-and-oauth/plaintext-once": "87e480b89a5cc72f", + "keys-and-oauth/plaintext-no-store": "3974e73945242ab0", "keys-and-oauth/own-source-unique": "cff6bb4f58cc4d61", "keys-and-oauth/own-source-race": "ba4f722ca2fc9eb7", "keys-and-oauth/mint-permission": "008802f8ba450886", @@ -3396,6 +3438,10 @@ "keys-and-oauth/update-ceiling-unchanged": "f0f8aa9e6c5c3ed7", "keys-and-oauth/manage-narrows-only": "87a75da5e4702508", "keys-and-oauth/app-key-narrows-only": "5a9cfb9cb9b0f725", + "keys-and-oauth/update-narrow-only": "98345011333f6d4a", + "keys-and-oauth/update-narrow-only-unchanged": "80051bbc9b9cfe42", + "keys-and-oauth/update-narrow-only-applies": "5ce0a93e67eb099b", + "keys-and-oauth/update-narrow-only-levers": "eb06f275bb0c85aa", "keys-and-oauth/key-unknown": "cc5aa0d3acd83314", "keys-and-oauth/key-malformed-id": "45676aecf585759c", "keys-and-oauth/revoke-answer": "8b230250f4155ac0", @@ -3438,6 +3484,7 @@ "keys-and-oauth/expiry-manage-narrows": "4dc55ec3b738bd73", "keys-and-oauth/expiry-app-key-narrows": "b15a4469a20d5f0a", "keys-and-oauth/expiry-owner-free": "a400c5602fef7d1a", + "keys-and-oauth/expiry-narrow-only-narrows": "b6270d65c69b2d45", "keys-and-oauth/no-credential": "d272f1da07e3f5e0", "keys-and-oauth/unknown-credential": "c55959c2200bd19c", "keys-and-oauth/permission-before-body": "f4a87220c641be6c", @@ -3508,14 +3555,17 @@ "keys-and-oauth/register-granted-kept": "45ba2033c9fb57d6", "keys-and-oauth/register-offered-kept": "398f18856c823f5c", "keys-and-oauth/no-backchannel-registered": "6a8a759e874f9714", - "keys-and-oauth/consent-screen": "061270f54be70f03", - "keys-and-oauth/consent-accepted": "f28c1e8771d0fdab", + "keys-and-oauth/consent-screen": "d27892df284d763b", + "keys-and-oauth/consent-accepted": "1f6c7122f504a25c", "keys-and-oauth/consent-declined": "f68b1619e4191282", - "keys-and-oauth/consent-ticked": "112dd15eb8440588", + "keys-and-oauth/consent-nothing-denies": "ac8a0ee0736f491a", + "keys-and-oauth/consent-ticked": "3acc2dab9abfa695", "keys-and-oauth/consent-standing": "44b74fa33945a1f6", - "keys-and-oauth/consent-wider": "830daf26ffb1cdc2", + "keys-and-oauth/consent-wider": "bd1e6b4845387025", "keys-and-oauth/consent-prompt": "62d7669cda1e33dd", "keys-and-oauth/authorize-narrowed": "99d695052c65f3fc", + "keys-and-oauth/authorize-narrowed-types": "b7e13fa6ac437614", + "keys-and-oauth/authorize-narrowed-refresh": "1d38d9b1c59a5156", "keys-and-oauth/authorize-nothing-grantable": "84f26fa217ed274b", "keys-and-oauth/authorize-pkce": "9fa7f38d8a9e000f", "keys-and-oauth/authorize-redirect-unregistered": "782f7a0d8824fbf0", @@ -3523,10 +3573,38 @@ "keys-and-oauth/prompt-none-consent": "2653bbfc6a75d432", "keys-and-oauth/prompt-none-code": "9b98c8a06b517db6", "keys-and-oauth/consent-older-reads": "3c6988333d5f9d12", - "keys-and-oauth/consent-recent-needed": "0c94fa1a0911d904", - "keys-and-oauth/consent-recent-browser": "904ebc41646ee60c", + "keys-and-oauth/consent-recent-needed": "52dffd78a15df3c0", "keys-and-oauth/consent-recent-held": "26fba6553ebae9e6", - "keys-and-oauth/consent-page-recent": "e982cc27a25f840b", + "keys-and-oauth/consent-page-confirm": "573d1168c0cf0267", + "keys-and-oauth/consent-page-reads": "30c161aa43200808", + "keys-and-oauth/approval-one-control": "acb6d5cb6c99480d", + "keys-and-oauth/approval-edit-words": "d108465dfda36ac0", + "keys-and-oauth/approval-levels-asked": "1e13de9608489631", + "keys-and-oauth/approval-only-asked": "29be86f83de99797", + "keys-and-oauth/approval-open-ended": "6abfcaff6aac61d3", + "keys-and-oauth/approval-powers-off": "31e2c858f3b34f42", + "keys-and-oauth/approval-powers-marked": "b1a72d2a5eded535", + "keys-and-oauth/approval-powers-words": "136193c47e4eaf08", + "keys-and-oauth/manage-powers-marked": "100241feb952e0b4", + "keys-and-oauth/approval-held-shown": "28cee7e209888407", + "keys-and-oauth/approval-levels-held": "d5e2e9e68f0a1728", + "keys-and-oauth/approval-keep-held": "8609376d97fac727", + "keys-and-oauth/approval-never-lowers": "a1eee9a92295696c", + "keys-and-oauth/approval-held-now": "f17b09f89210cbd7", + "keys-and-oauth/approval-write-read": "0fdde96611c94962", + "keys-and-oauth/approval-beyond-shown": "bab522ac4258d0b1", + "keys-and-oauth/approval-all-data-types": "d298c731ec8fa090", + "keys-and-oauth/approval-types-levels": "5f63759580a7341e", + "keys-and-oauth/approval-types-grant": "b674e4577f1790cb", + "keys-and-oauth/approval-types-unchosen": "b7795fd568306529", + "keys-and-oauth/approval-types-beyond": "8639d9fca9018a38", + "keys-and-oauth/approval-types-keep": "18addc0cd52e9e29", + "keys-and-oauth/approval-same-label": "47a1028348c70791", + "keys-and-oauth/approval-same-label-described": "a1c893ec53dfe266", + "keys-and-oauth/approval-own-label": "b02259c4ddebdf0b", + "keys-and-oauth/approval-page-row-no-identifier": "1301c06aa4026174", + "keys-and-oauth/access-words": "782a14594b362397", + "keys-and-oauth/access-words-cli": "30cb1eebe40e7f96", "keys-and-oauth/catch-up-signed-in": "29b3b37bcb0436ab", "keys-and-oauth/catch-up-nobody": "da931f67e026cc4c", "keys-and-oauth/catch-up-nobody-unchanged": "318f8b49b361567d", @@ -3589,16 +3667,17 @@ "keys-and-oauth/device-pending": "746c52329194ad61", "keys-and-oauth/device-slow-down": "bbea12d7ec67534e", "keys-and-oauth/device-denied": "4c4d5dc5f52decf6", + "keys-and-oauth/device-nothing-denies": "831e978dbb44226d", "keys-and-oauth/device-expired": "3dc71e8f6878d2a8", "keys-and-oauth/device-approved": "0e09538cc6f7542f", "keys-and-oauth/device-grant-revoked": "91df5a66c247a7fd", "keys-and-oauth/device-spent": "435afb775e386e9b", "keys-and-oauth/device-approval-catch-up": "faacc661786a5715", "keys-and-oauth/device-older-reads": "b194977803165f96", - "keys-and-oauth/device-recent-needed": "74c0c63503cef6ee", - "keys-and-oauth/device-recent-browser": "17142549e3cc5358", + "keys-and-oauth/device-recent-needed": "3154fa22a7448c22", "keys-and-oauth/device-recent-held": "ae12ff5b37d26f43", - "keys-and-oauth/device-page-recent": "b51b02c643d67b13", + "keys-and-oauth/device-page-confirm": "3605efc1f4a674a5", + "keys-and-oauth/device-page-reads": "1a7ae84abbb52750", "keys-and-oauth/device-client-gone": "f427bbbd2208b45e", "keys-and-oauth/browser-origin": "b6e56ea3b2467498", "keys-and-oauth/browser-no-origin": "2318dc8dd4795511", @@ -3617,6 +3696,8 @@ "keys-and-oauth/device-lookup-address-limit": "709a869d7675f2b8", "keys-and-oauth/device-lookup-wait": "3ac872da0a71821a", "keys-and-oauth/device-lookup-page": "516e2ef272bde7d5", + "keys-and-oauth/device-code-not-right": "31fd2c67214bc1b2", + "keys-and-oauth/device-code-lifetime": "27064a4319e60019", "keys-and-oauth/device-lookup-instance-limit": "8c44bf187c9f61dd", "keys-and-oauth/device-lookup-owner-lane": "72a04c00ed59852c", "keys-and-oauth/device-lookup-owner-limit": "b10e397702891606", @@ -3631,8 +3712,8 @@ "keys-and-oauth/caution-consent": "74bcd1827f9debdf", "keys-and-oauth/caution-device": "49599eba707d5321", "keys-and-oauth/caution-secret": "68ac7748d394c72d", - "keys-and-oauth/consent-names-page": "cc544c9aa9b617f9", - "keys-and-oauth/device-names-page": "0919d0bbf535cc29", + "keys-and-oauth/consent-names-page": "8cf40f2e2fc80c7b", + "keys-and-oauth/device-names-page": "2c23aead9c2976b2", "keys-and-oauth/sign-in-email-kept": "1dca3cdfa8dd739b", "keys-and-oauth/sign-in-email-shown": "e81c2eb1de0e831d", "keys-and-oauth/sign-in-no-password": "46cbbf189ff0a005", @@ -3641,7 +3722,7 @@ "keys-and-oauth/sign-in-unsigned-no-app": "6b1555f2c4a44caa", "keys-and-oauth/signed-in-page": "6035607b37be1066", "keys-and-oauth/signed-in-reauth": "e46308d36743c85f", - "keys-and-oauth/sign-in-page-reprompt": "692d9c5e7ab03c4b", + "keys-and-oauth/sign-in-page-reprompt": "62ec48ab4fa83b90", "keys-and-oauth/link-unverified": "ffbe8f6578572752", "keys-and-oauth/link-expired": "c36d89321ee285b4", "keys-and-oauth/end-session-page": "ef45e6c78da0e295", @@ -3677,7 +3758,8 @@ "keys-and-oauth/sign-in-end-versions": "3315a4b75f6ab456", "keys-and-oauth/sign-in-end-cookie": "3fcaeff6cc31b7fd", "keys-and-oauth/sign-in-end-unknown": "4ae2e20ebec49709", - "keys-and-oauth/sign-in-end-recent": "a5594142a3b5270e", + "keys-and-oauth/sign-in-end-recent": "0b2562bd301c0892", + "keys-and-oauth/sign-in-end-self": "d3166f19618d55c8", "keys-and-oauth/sign-in-end-restart": "27a7d1dc3761f721", "keys-and-oauth/sign-in-rename": "bfbae9d1b8d9aaa8", "keys-and-oauth/sign-in-rename-listed": "f2b2c8594fbace97", @@ -3880,7 +3962,7 @@ "queue-and-verdicts/report-unsent-verdicts": "2346206835baca0c", "queue-and-verdicts/report-held-unlisted": "a598d0f5ef4e7d47", "queue-and-verdicts/report-agrees-with-queue": "a8ea4a225d3b8c51", - "queue-and-verdicts/count-answered": "e9194f71513f7be2", + "queue-and-verdicts/count-answered": "8532e99c3f3ada18", "queue-and-verdicts/count-held": "7496eb38f49078fd", "queue-and-verdicts/count-undelivered": "2e586f9c91f246d2", "queue-and-verdicts/count-unsent": "cbe299281910dc0f", @@ -3905,7 +3987,8 @@ "queue-and-verdicts/later-row-kept": "17c212172589a022", "queue-and-verdicts/sibling-from-stream": "ef58369cfa1cceba", "queue-and-verdicts/sibling-link-held": "2c8bec46d5afda2f", - "queue-and-verdicts/refused-contract": "f35bbd75b1612c6f", + "queue-and-verdicts/refused-contract": "9838ec9469d42b47", + "queue-and-verdicts/not-retained-accepted": "6bc8270b9760adfd", "queue-and-verdicts/refused-not-resent": "3ce1a667cc64c2cc", "queue-and-verdicts/refused-envelope-kept": "f69ae215fc4e8928", "queue-and-verdicts/refused-read-again": "2b5ac4ad0f290d19", @@ -3917,9 +4000,9 @@ "queue-and-verdicts/refusal-same-in-queue": "d1e5d602c6bfd510", "queue-and-verdicts/refusal-part-absent": "976b460e4d687a55", "queue-and-verdicts/refusal-drain-made": "5404dd52ccd3ebed", - "queue-and-verdicts/blocked-reasons-five": "30a5878fe26a852f", + "queue-and-verdicts/blocked-reasons-five": "188037b16304aabd", "queue-and-verdicts/blocked-not-counted": "591c29d26eba28d3", - "queue-and-verdicts/blocked-passed-over": "3411061ff7ca5e55", + "queue-and-verdicts/blocked-passed-over": "d3a20835cb75cae4", "queue-and-verdicts/blocked-retried-itself": "2f0aed453d9387a8", "queue-and-verdicts/blocked-keeps-row": "1a59c41036791b99", "queue-and-verdicts/ceiling-five": "10383ce29ecce6cd", @@ -3929,11 +4012,30 @@ "queue-and-verdicts/environmental-uncounted": "6c2641acebc02083", "queue-and-verdicts/unnamed-refusal-environmental": "e7901bd35fac883f", "queue-and-verdicts/unnamed-refusal-said": "e3521c8ee83e704b", - "queue-and-verdicts/environmental-ends-pass": "cf1571b51b75e102", + "queue-and-verdicts/environmental-ends-pass": "ddb3925e5a332d84", "queue-and-verdicts/read-failure-ends-pass": "19d9e9111e866144", + "queue-and-verdicts/streak-counted": "c93b9d479e7a9adf", + "queue-and-verdicts/streak-run-restarts": "87c234df5075a5e9", + "queue-and-verdicts/streak-cleared": "4b6bc5344ff00749", + "queue-and-verdicts/stuck-write-suspect": "700f2eaf99f2e24b", + "queue-and-verdicts/stuck-write-tested": "cef1fa11b4864704", + "queue-and-verdicts/stuck-write-test-unsent": "c740a4efd6e46d79", + "queue-and-verdicts/stuck-write-test-unmade": "341e0519a67f3f8b", + "queue-and-verdicts/stuck-write-resent": "3feffb64a32e1d33", + "queue-and-verdicts/stuck-write-set-aside": "683e59f651bfa6dc", + "queue-and-verdicts/stuck-write-resend-answered": "efd1067029dd5bd7", + "queue-and-verdicts/stuck-write-carries-on": "11927ff273ccb44a", + "queue-and-verdicts/stuck-write-short-streak": "d2c5089946fea40e", + "queue-and-verdicts/stuck-write-sent-only": "cdc228961f335da6", + "queue-and-verdicts/stuck-write-test-failed": "4fcbd1a767e261bb", + "queue-and-verdicts/stuck-write-test-refused": "fee9ed40f77a57a8", + "queue-and-verdicts/stuck-write-alone": "229f61ed3ee2b544", + "queue-and-verdicts/failing-alone-holds-dependants": "e9c6f2fa76882a4a", + "queue-and-verdicts/failing-alone-withdraw-dependants": "cdc8584bd4f67086", + "queue-and-verdicts/queue-shows-last-failure": "a6fc251d12a2a078", "queue-and-verdicts/counted-failures": "af0528aa36dd78c5", "queue-and-verdicts/in-flight-counted": "a0671aece9c505c9", - "queue-and-verdicts/counted-goes-on": "9cf126f8a7d7df4a", + "queue-and-verdicts/counted-goes-on": "aecff034913354a8", "queue-and-verdicts/store-failure-counted": "e98159256c5fd867", "queue-and-verdicts/store-full-ends-drain": "3f82f185cc5809fe", "queue-and-verdicts/credential-blocks-queue": "79a5077d31908ba3", @@ -3961,6 +4063,7 @@ "queue-and-verdicts/release-stopped": "18e39dd28ecad49f", "queue-and-verdicts/release-fresh-key": "d6329435b8915867", "queue-and-verdicts/release-resets-count": "af249ecef875d4da", + "queue-and-verdicts/release-clears-streak": "7c9a0d04bf02975d", "queue-and-verdicts/release-by-reason": "6d799737ed27f7e5", "queue-and-verdicts/release-reason-held": "7389f6fd884a91fa", "queue-and-verdicts/release-held-by-id": "1b0b22b0c616abac", @@ -3968,9 +4071,9 @@ "queue-and-verdicts/release-takes-dependants": "24f08404533bb90e", "queue-and-verdicts/release-sent-refused": "64420d0e370784f8", "queue-and-verdicts/release-lays-over": "99fc2b18ad9c50e4", - "queue-and-verdicts/withdraw-two-reasons": "fee65d1c4eb19cd2", + "queue-and-verdicts/withdraw-two-reasons": "b0ed47449c5a31b7", "queue-and-verdicts/withdraw-unknown": "101d0fbda6c97bc2", - "queue-and-verdicts/withdraw-takes-out": "3477857d4a416473", + "queue-and-verdicts/withdraw-takes-out": "07b79bd1469a59b5", "queue-and-verdicts/withdraw-puts-back": "9ea1f7ae83411e05", "queue-and-verdicts/withdraw-read-first": "f3db5cabfb9cf530", "queue-and-verdicts/withdraw-refuses-held": "fabf78c907ff2c9e", @@ -4651,6 +4754,11 @@ "types/format-string-only-refused": "47f4bcaea2e397cc", "types/datetime-value": "fd07bee460e3d65e", "types/datetime-refused": "02b692f30451cc2f", + "types/url-value": "512fddb8c5b3e4ff", + "types/url-punycode": "1137cb9a93c3b47c", + "types/url-idna": "0eccb0c75d7f7bf3", + "types/url-host-refused": "1a5c9bf6f3bc551b", + "types/url-stored": "dd3950dd59da8624", "types/array-items": "8001e3dc5fa40455", "types/array-items-refused": "12f194f18218820c", "types/array-items-unnamed": "86a4fe72b9e76609", diff --git a/src/generated/marfa-release.json b/src/generated/marfa-release.json index 6b9ec6e1..449e391d 100644 --- a/src/generated/marfa-release.json +++ b/src/generated/marfa-release.json @@ -1 +1 @@ -{"version":"v0.0.17"} +{"version":"v0.0.18"} diff --git a/src/generated/server-image.md b/src/generated/server-image.md index 3f5c0728..4f79b126 100644 --- a/src/generated/server-image.md +++ b/src/generated/server-image.md @@ -1,5 +1,5 @@ ```bash -docker pull ghcr.io/withmarfa/marfa-server:0.0.17 +docker pull ghcr.io/withmarfa/marfa-server:0.0.18 docker run --name marfa -d --restart unless-stopped -p 127.0.0.1:8600:8600 \ - -v marfa-data:/data --env-file marfa.env ghcr.io/withmarfa/marfa-server:0.0.17 + -v marfa-data:/data --env-file marfa.env ghcr.io/withmarfa/marfa-server:0.0.18 ``` From 6b1f4a726f364a6d72965c59da84a8a28d1f06d4 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Tue, 20 Oct 2026 23:48:46 +0100 Subject: [PATCH 02/10] docs(release): add the v0.0.18 changelog --- content/docs/(reference)/changelog.mdx | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/content/docs/(reference)/changelog.mdx b/content/docs/(reference)/changelog.mdx index 01a8b0c2..f9c0f4ef 100644 --- a/content/docs/(reference)/changelog.mdx +++ b/content/docs/(reference)/changelog.mdx @@ -7,6 +7,34 @@ Each release of Marfa ships the server and its container image, the CLI for macO **Until the first public release, Marfa does not upgrade a database in place.** A build refuses a database whose schema differs from its own or that holds retired tables or settings, and leaves the file unchanged. If the build refuses your database, [Upgrade](/guides/upgrade) covers exporting with the old build and restoring into fresh storage. A version change alone does not cause a refusal. +## v0.0.18 + +Released October 20, 2026. [v0.0.18 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.18) + +For running an instance: + +- **The owner has a Home page at `/`.** A browser signed in as the owner sees the instance's name and address, what needs attention, a search for pages, and Sign out. A browser that isn't signed in is redirected to the sign-in page and returned to the page it asked for, and an unclaimed instance sends it to `/setup`. A program that requests `/` still gets the description of the instance. [Start from Home](/guides/authenticate#start-from-home) covers it. +- **The owner manages API keys in the browser.** The API keys pages list the keys, create one, and edit or revoke one. A key's access starts off, and the secret shows once and is removed when the owner leaves the page. A save is refused if the key changed after the page opened. [Use the API keys page](/guides/manage-keys#use-the-api-keys-page) covers it. +- **The owner has Profile and Password and passkeys pages.** Changing the password and adding, renaming or removing a passkey ask the owner to confirm it's them when the sign-in is more than five minutes old. A passkey name that is empty or too long is refused `400 validation_error` when the passkey is verified. [Authenticate](/guides/authenticate#sign-in-as-the-owner) covers it. +- **A folder that a different directory replaces stays listed.** A folder registry entry records the directory's identity. When another directory takes the folder's place, the entry stays and the watch resumes. Marfa removes the entry only when the directory it admits holds no folder state. [Sync a folder](/guides/sync-a-folder#a-missing-or-unreadable-directory) covers it. +- **The server needs Node.js 22.14 or later.** The container image runs Node.js 24. [Deploy](/guides/deploy) covers it. + +For apps and devices: + +- **The owner chooses what an app gets.** The approval screen lists each type with No access, Read only or Read and edit, then the owner's profile and the powers over Marfa that are off until the owner turns them on. Three powers that can't be undone are marked. Access the app already holds shows as Already allowed. A request for all of an app's reads or writes shows Choose types, and the token then names the types the owner picked, not all data. If the owner chooses nothing new, the app gets `access_denied`. [What the owner chooses](/guides/authenticate#what-the-owner-chooses) covers it. +- **A device code is easier to type.** The device page shows a code as `XXXX-XXXX`, accepts it in any case with or without the hyphen, and a code ends after 10 minutes. +- **Approving or revoking asks to confirm in a dialog, not a redirect.** `POST /auth/confirm` takes the owner's password or a passkey and replaces the session, so the browser carries on at the same page. A password counts against the attempt windows, and a refusal answers `429 rate_limited` with `Retry-After` and keeps the session. A program that calls a route that needs a recent sign-in gets `403 forbidden` with "Sign in again to perform this operation", and a request with no live session or key gets `401`. A browser may end its own sign-in at any time. [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) covers it. +- **`narrow_only` keeps a key from gaining access.** `PATCH /keys/{id}` with `narrow_only: true` refuses a change that adds access, and the API keys pages send it. `POST /keys` answers `Cache-Control: no-store`, so the secret isn't cached. [Narrow a key](/guides/manage-keys#narrow-a-key) covers it. +- **Marfa keeps a whole number exact, or refuses it.** A `number` or `integer` value that Marfa can't serve back with the same digits, such as `1500000000000000001`, is refused `400 invalid_properties`, and `details.errors` names the field. A `url` value is trimmed and judged as the WHATWG URL Standard does, so an address with a non-ASCII host is valid and one with a forbidden code point is refused. [Field kinds](/catalog/field-kinds) covers it. +- **A tag of `.` or `..` is refused** with `400 validation_error`, as an empty, blank or over-long tag is. A restore still accepts what an archive holds. +- **Narrowing a webhook cancels the deliveries it no longer selects.** Pending deliveries that the narrowed subscription doesn't select end `canceled`. Changing the `url` or setting `active: false` cancels all of them, and widening revives none. [Change what a webhook sends](/guides/receive-webhooks#change-what-a-webhook-sends) covers it. +- **The CLI prints access in the words the approval page uses.** `marfa login`, `whoami`, and `keys list`, `current`, `create` and `update` print No access, Read only and Read and edit for each type, and name powers by their titles. `--json` is unchanged. [CLI](/cli#check-what-a-command-will-use) covers it. +- **A write that fails alone is set aside.** A device marks a queued write `blocked` with the reason `failing_alone` when three drains in a row fail it, the first at least five minutes before the third, and an independent write succeeds in between. Its dependants wait as `awaiting_dependency`. `marfa device queue` prints the last answer the server gave, as `last answer: HTTP : `. [Devices](/devices#verdicts) covers it. +- **A file item keeps its bytes whatever its name.** A folder file named `.md`, `.markdown` or `.txt` becomes a file item with no frontmatter read and no text copy in the store. An empty file waits, and the rest of the scan goes on. A `marfa_id` over 256 bytes is no id. +- **A lock file the CLI can't write is refused with a message.** When the lock file opens read-only, the CLI stops with ` cannot be opened for writing, so it cannot be locked: check that it is writable`. +- **A repeat of a write whose first answer was large is refused, not run again.** Marfa keeps only the status of a first answer over 1,048,576 bytes, so a repeat under the same `Idempotency-Key` answers `422 idempotency_result_not_retained` with `details.original_status`, and writes nothing. A device counts a `2xx` status there as delivered. [Write safely](/guides/write-safely#retry-with-an-idempotency-key) covers it. +- **A waiting edge edit or delete stays in view through a catch-up.** When a catch-up brings an item into a device's copy along with its edges, the device lays its own waiting writes over those edges, as hydration does, instead of showing the server's version until the server answers. + ## v0.0.17 Released October 20, 2026. [v0.0.17 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.17) From d6c041a916c4e21f34cbf0af5e9f351eb7794fb7 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Tue, 20 Oct 2026 23:48:46 +0100 Subject: [PATCH 03/10] docs(guides): describe approval choices, confirming and Home --- content/docs/(overview)/access.mdx | 2 +- content/docs/(overview)/quickstart.mdx | 4 +- content/docs/guides/authenticate.mdx | 54 ++++++++++++++++++---- content/docs/guides/connect-a-web-page.mdx | 4 +- content/docs/guides/deploy.mdx | 4 +- content/docs/guides/manage-keys.mdx | 21 +++++++-- content/docs/guides/troubleshooting.mdx | 6 ++- 7 files changed, 74 insertions(+), 21 deletions(-) diff --git a/content/docs/(overview)/access.mdx b/content/docs/(overview)/access.mdx index b5978511..3f056dbe 100644 --- a/content/docs/(overview)/access.mdx +++ b/content/docs/(overview)/access.mdx @@ -55,6 +55,6 @@ Every key has a source of its own, chosen when it's made, such as `laptop` or `t **You make a key** as the owner, or with a key that holds the `keys.mint` permission, and say what it holds. A key made by another key can hold only the permissions and maps its maker holds, and a key can be set to expire. **The maker's `enforcement_override` isn't carried over**: unless the new key names one, it follows the instance's levers ([Settings](/guides/settings) describes them), so it can list items that its maker's levers hide. [Manage keys](/guides/manage-keys) shows how to make, narrow and revoke keys. -**An app is never given a key.** It registers itself with the instance and sends the owner to approve it, as when you sign in to an app with your Google account. The owner sees each scope the app asks for, such as `core.note:read`, and the app's token holds only what the owner leaves ticked. A browser that signed in more than five minutes ago can approve reading only; write access and permissions need a recent sign-in. The approval stands until the owner revokes it, or until the app goes unused for a year. [Authenticate](/guides/authenticate) shows an app's side of it. +**An app is never given a key.** It registers itself with the instance and sends the owner to approve it, as when you sign in to an app with your Google account. The owner chooses what to allow for each item type the app asks for, such as `core.note:read`, at the level No access, Read only or Read and edit, and the app's token holds only what the owner chooses. An app that asks for all data can be given chosen types instead. A browser that signed in more than five minutes ago can approve reading only; write access and permissions need a recent sign-in, which the page asks the owner to confirm. The approval stands until the owner revokes it, or until the app goes unused for a year. [Authenticate](/guides/authenticate) shows an app's side of it. The rules behind this page are in the [keys and sign-in](https://github.com/withmarfa/marfa/blob/main/conformance/spec/keys-and-oauth.md), [authentication](https://github.com/withmarfa/marfa/blob/main/conformance/spec/authentication.md), [owner counts and exports](https://github.com/withmarfa/marfa/blob/main/conformance/spec/search-and-filters.md#search-and-filtersowner-count-export), and [instance claim](https://github.com/withmarfa/marfa/blob/main/conformance/spec/instance-claim.md) chapters of the contract. diff --git a/content/docs/(overview)/quickstart.mdx b/content/docs/(overview)/quickstart.mdx index b143802e..1f97d966 100644 --- a/content/docs/(overview)/quickstart.mdx +++ b/content/docs/(overview)/quickstart.mdx @@ -5,7 +5,7 @@ description: Start Marfa on your own computer, claim it, make a key, and write a By the end of this page you'll have a Marfa instance running on your computer, with you as its owner and one note in it. You need two terminal windows: one for the server, one for everything else. This instance is for trying Marfa; [Deploy](/guides/deploy) covers one you keep. -**You need** macOS or Linux, with Git, [Node.js](https://nodejs.org) 22.12 or later and [pnpm](https://pnpm.io/installation). On Linux, also install [Rust](https://www.rust-lang.org/tools/install), to build the CLI, and the `acl` package, which the server uses to check who may reach its private socket. +**You need** macOS or Linux, with Git, [Node.js](https://nodejs.org) 22.14 or later and [pnpm](https://pnpm.io/installation). On Linux, also install [Rust](https://www.rust-lang.org/tools/install), to build the CLI, and the `acl` package, which the server uses to check who may reach its private socket. @@ -74,7 +74,7 @@ The CLI asks for a password for the owner, then for the setup code the server pr -Open `http://localhost:8600/setup`, enter the setup code the server printed, and choose the owner's email and password. **Claiming signs you in**, and setup goes on from there to add a passkey, name the instance, connect something and read how it's backed up. +Open `http://localhost:8600/setup`, enter the setup code the server printed, and choose the owner's email and password. **Claiming signs you in**, and setup goes on from there to add a passkey, name the instance, connect something and read how it's backed up. Done opens [Home](/guides/authenticate#start-from-home). diff --git a/content/docs/guides/authenticate.mdx b/content/docs/guides/authenticate.mdx index daf4300c..8c53d565 100644 --- a/content/docs/guides/authenticate.mdx +++ b/content/docs/guides/authenticate.mdx @@ -9,16 +9,37 @@ The owner signs in in a browser to manage the instance and approve apps. For dat **The owner signs in in a browser at `/auth/sign-in`**, using the password chosen when claiming the instance or a registered passkey. -To add a passkey, open `/auth/owner/passkeys` with a browser session authenticated within the last five minutes. This page also lists and removes individual passkeys. **Renaming or removing a passkey requires authentication within the last five minutes**; listing them needs only a live owner session. Removing one prevents further sign-in with it, while existing sign-ins and other passkeys keep working. +**The Password and passkeys page, at `/auth/owner/passkeys`, changes the password and manages passkeys.** It asks for the current password to change it. It adds a passkey, and lists, renames and removes the ones you have, showing when each was added in your browser's time zone. **Adding, renaming or removing a passkey needs a sign-in within the last five minutes**; listing them needs only a live owner session. If your sign-in is older, the page asks you to confirm it's you, as [the next section](#confirm-its-you) describes. Removing a passkey prevents further sign-in with it, while existing sign-ins and other passkeys keep working. + +**Home, at `/`, is where you land after you sign in**, and [Start from Home](#start-from-home) describes it. On the instance's own machine, `marfa --socket owner passkeys list` lists the passkeys. Use `marfa --socket owner passkeys rename --name "Laptop"` to rename one, or `marfa --socket owner passkeys remove ` to remove it. `marfa --socket owner passkeys add` opens the ordinary sign-in page, where you sign in with the password or a passkey, and then the passkey page; `--no-browser` prints the URL instead. **The add command issues no sign-in credential**: you still authenticate in the browser and register the passkey there. [Recover the owner's password](/guides/manage-keys#recover-the-owners-password) remains the way to recover a lost password. Passkeys need a supported browser and HTTPS, or localhost for development. **A valid passkey works even when password attempts are locked.** It creates the same owner browser authority used to approve apps and manage the instance; an app still needs its own token to read and write data. -The owner's browser and the private local command can list, rename and end sign-ins at `/owner/sign-ins`. **Keys and app tokens cannot use these operations.** Browser changes require authentication within the last five minutes. +The owner's browser and the private local command can list, rename and end sign-ins at `/owner/sign-ins`. **Keys and app tokens cannot use these operations.** A browser can end its own sign-in at any time. Ending another sign-in, or ending your own with `revoke_keys` set to `true`, needs a sign-in within the last five minutes. + +## Confirm it's you + +**When a change needs a sign-in from the last five minutes and yours is older, the page asks you to confirm it's you.** A dialog titled "Confirm it's you" asks for the owner's password or a passkey. When you confirm, the page sends the change again, and you don't sign in a second time. + +Confirming replaces your browser session with a new one created at that moment, and ends the old one. The password counts against the same attempt windows as a password sign-in, so too many wrong passwords answer `429 rate_limited` with `Retry-After`, and a passkey still confirms. A refused confirmation leaves your session as it was. These changes ask for it: + +- Adding, renaming or removing a passkey. +- Changing the owner's name or email on the Profile page. +- Revoking a key, disconnecting an app, or signing out another browser. +- Approving an app for more than reading, as [When approval needs a recent sign-in](#when-approval-needs-a-recent-sign-in) describes. + +A program that makes the same request, with no page to show a dialog, gets `403 forbidden` with the message `Sign in again to perform this operation`, and Marfa changes nothing. `POST /auth/confirm` is the operation behind the dialog: it takes the password or a passkey assertion of the owner who is signed in, and a request with no live browser session, or with a key, is refused `401 unauthorized`. [Manage keys](/guides/manage-keys) covers key access, and `marfa sign-ins --help` lists the local sign-in commands. +## Start from Home + +**Open the instance's address in a browser where you are signed in as the owner, and Home answers.** It is headed by the instance's name and address, and it lists the owner's pages in groups. A page that needs a look appears under Needs attention. Home has a search that finds a page or a setting by name, which Cmd-K on a Mac or Ctrl-K elsewhere also opens, and a Sign out button. The older management page, at `/auth/owner/manage`, still holds what the newer pages don't: server health, housekeeping, signed-in browsers, connected apps, connectors, and a link to restoring an archive. Home is never cached, because it belongs to the owner. + +**A browser that isn't signed in is sent to sign in, and back.** Opening an owner page, one under `/auth/owner/`, without a session answers `302` to `/auth/sign-in` with the page you asked for as `return_to`, and signing in returns you there. A page address longer than 2,048 characters returns you to Home instead. On an instance with no owner yet, the redirect goes to `/setup`. A program gets no redirect: a request with an `Authorization` header, or one that doesn't ask for HTML, is answered as before, and `GET /` for a program is still the instance's description. + ## Change the owner's email or name **The owner controls the sign-in address.** On the instance's machine, use its private socket to change the email, display name, or both: @@ -142,7 +163,7 @@ Then open the authorization endpoint in the owner's browser, with the challenge GET /auth/oauth2/authorize?response_type=code&client_id=&redirect_uri=https%3A%2F%2Freader.example.com%2Fcallback&scope=core.note%3Aread%20core.note%3Awrite%20offline_access&code_challenge=&code_challenge_method=S256&state= ``` -The owner signs in if they need to, and sees each scope the app asks for. They can untick any of them. A browser that signed in more than five minutes ago can approve reading only, as [the recent sign-in rules](#when-approval-needs-a-recent-sign-in) say. When they approve, Marfa sends the browser to your redirect address with a `code` and your `state`: +The owner signs in if they need to, then chooses what to allow on the Approve access screen, as [What the owner chooses](#what-the-owner-chooses) describes. When they approve, Marfa sends the browser to your redirect address with a `code` and your `state`: ```text https://reader.example.com/callback?code=&state=&iss=http%3A%2F%2Flocalhost%3A8600%2Fauth @@ -250,7 +271,7 @@ client_id=&scope=core.note%3Aread%20offline_access } ``` -Show the owner the `user_code` and `verification_uri`, or open `verification_uri_complete` for them. They sign in there and approve each requested scope, [with a recent sign-in](#when-approval-needs-a-recent-sign-in) for anything beyond reading. +Show the owner the `user_code` and `verification_uri`, or open `verification_uri_complete` for them. They sign in there, enter the code if it wasn't in the address, and choose what to allow on the same controls as the Approve access screen, [with a recent sign-in](#when-approval-needs-a-recent-sign-in) for anything beyond reading. The code page takes the code in any case, with or without a hyphen after the fourth character, as `LFMC-H9Q4` or `LFMCH9Q4`, and says that codes end after 10 minutes. **Keep the request explicit:** omitting `scope`, or sending only whitespace, is refused with `400 invalid_scope` before a code is issued. Read the approved `scope` in the token response; it can be narrower than you asked for. @@ -273,17 +294,30 @@ Until the owner decides, Marfa answers `400 authorization_pending`, and `400 slo +## What the owner chooses + +**The Approve access screen, and the device approval page, offer a choice for what the app asks and nothing more.** The screen is headed by the app's name and the instance's. A web page's origin is shown without its `https://`. + +- **Your data** has one control for each item type the app asks for, with the levels No access, Read only and Read and edit. A level is offered only up to the highest the app asked for, so an app that asks to read a type is never offered editing. Read and edit includes adding and deleting items. +- **Your profile** appears when the app asks for the profile, and **More control over Marfa** when it asks for a [permission](/catalog/permissions). Each permission starts turned off, and the three that can't be undone, `instance.maintain`, `schema.write` and `items.purge`, are marked. +- **Already allowed** lists what the app held before it asked again. Approving never lowers it, and the screen offers no level below it. + +A control for an item type shows the type's identifier under its label when two types share a label, such as `core.task` and `aic.ticket.task`, which are both "Task". + +**An app that asks for all data with `*:read` or `*:write` can be given types instead.** The screen then offers Choose types beside the levels, with one control for each registered item type. The token holds exactly the types the owner turned on, such as `core.note:read core.task:read`, and not `*:read`, so read the approved `scope` in the token response. Turning on no type is declining. + +Marfa grants what the owner chose, the reads that a write includes, and the scopes the app already held that it still holds when the owner approves. A form that posts a scope the request didn't name is refused `400`, and nothing is granted. **Approving with nothing newly chosen is declining**: the app gets `access_denied` and keeps what it held. + ## When approval needs a recent sign-in -**A browser that signed in more than five minutes ago can approve reading only.** That covers the consent screen and the device approval screen alike. The scopes that read are `:read` for an item type or a pattern of types, `edge.:read` for an edge type or a pattern of edge types, `metadata:read`, `metadata.:read`, `content:read`, `profile:read`, `profile.:read`, `openid`, `profile`, `email` and `offline_access`. +**A browser that signed in more than five minutes ago can approve reading only.** That covers the Approve access screen and the device approval page alike. The scopes that read are `:read` for an item type or a pattern of types, `edge.:read` for an edge type or a pattern of edge types, `metadata:read`, `metadata.:read`, `content:read`, `profile:read`, `profile.:read`, `openid`, `profile`, `email` and `offline_access`. To approve write access, or any of the [permissions](/catalog/permissions) the app doesn't already hold, the owner must have signed in within the last five minutes. **A scope the owner approved for the app before needs no new sign-in.** -Marfa asks the owner for the password or a passkey, then returns them to the same screen: +The page asks the owner to [confirm it's them](#confirm-its-you) when they press Approve, and approves in the same step: -- **The screen is opened after the five minutes.** Marfa sends the owner to sign in first, then back to the screen. -- **The screen was already open when the five minutes passed.** Approving sends the browser to sign in again and back to the same request, if the request hasn't expired. An authorization request is signed for a short window, and a device code lasts `expires_in` seconds. -- **The caller doesn't ask for a page.** A request whose `Accept` header doesn't prefer `text/html` is refused `403 forbidden`. +- **The owner opens a screen that asks for more than reading.** The screen carries the "Confirm it's you" dialog whatever the age of the sign-in, because the owner may leave the screen open past the five minutes. The dialog opens when the owner presses Approve and the sign-in is older than that. A request that only reads has no dialog. +- **A request without a page.** A request whose `Accept` header doesn't prefer `text/html` is refused `403 forbidden`, and Marfa issues no code. The app does nothing differently. An app that uses the authorization code grant waits for the redirect, and an app that uses the device grant keeps polling and gets `authorization_pending` until the owner decides. [Keys and sign-in](https://github.com/withmarfa/marfa/blob/main/conformance/spec/keys-and-oauth.md#keys-and-oauthconsent-older-reads) holds the exact rules, starting with `keys-and-oauth/consent-older-reads`. @@ -300,7 +334,7 @@ token=marfa_rt_…&token_type_hint=refresh_token&client_id= Marfa answers `200`, even for a token it doesn't hold. The owner can also disconnect an app in the browser, and a credential holding `grants.manage` lists the apps signed in with `GET /auth/grants` and ends one with `DELETE /auth/grants/{id}`. -**Disconnecting an app from a browser needs a sign-in within the last five minutes.** A browser that signed in earlier is refused `403 forbidden` and leaves the app working. A credential holding `grants.manage` is not affected by when the owner last signed in, and neither is the private local command. +**Disconnecting an app from a browser needs a sign-in within the last five minutes.** A browser that signed in earlier is asked to [confirm it's the owner](#confirm-its-you) first, and a request without a page is refused `403 forbidden` and leaves the app working. A credential holding `grants.manage` is not affected by when the owner last signed in, and neither is the private local command. The endpoints under `/auth/oauth2/` and `/auth/device/` **answer errors in OAuth's own shape**, such as `{ "error": "invalid_grant", "error_description": "invalid refresh token" }`, not in the [error envelope](/api-reference/errors) the rest of the API uses. diff --git a/content/docs/guides/connect-a-web-page.mdx b/content/docs/guides/connect-a-web-page.mdx index a3102e12..a031f386 100644 --- a/content/docs/guides/connect-a-web-page.mdx +++ b/content/docs/guides/connect-a-web-page.mdx @@ -108,7 +108,7 @@ authorize.search = new URLSearchParams({ open(authorize, "marfa-approval", "popup,width=480,height=640"); ``` -The window asks the owner to sign in if they need to, and to sign in again if the page asks for more than reading and the owner signed in more than five minutes ago. It then shows the page's name, a caution that Marfa has not verified it, **the page's origin**, and each scope it asks for. The owner can untick any scope. Approving allows that origin to call the instance from a browser, as the next section explains. +The window asks the owner to sign in if they need to. It then shows the page's name, a caution that Marfa has not verified it, **the page's origin**, and a choice for each thing it asks for, as [What the owner chooses](/guides/authenticate#what-the-owner-chooses) describes. If the page asks for more than reading and the owner signed in more than five minutes ago, the owner confirms it's them when they approve. The owner can turn off or lower any choice. Approving allows that origin to call the instance from a browser, as the next section explains. @@ -172,7 +172,7 @@ const token = await ( } ``` -**A code works once**, and `scope` lists what the owner left ticked, which can be less than the page asked for. Marfa answers this request with `Access-Control-Allow-Origin` naming the page's origin only after the owner approves it, so a page that skips the approval can't read the answer. +**A code works once**, and `scope` lists what the owner chose, which can be less than the page asked for. Marfa answers this request with `Access-Control-Allow-Origin` naming the page's origin only after the owner approves it, so a page that skips the approval can't read the answer. diff --git a/content/docs/guides/deploy.mdx b/content/docs/guides/deploy.mdx index 34ab2f72..5696e64f 100644 --- a/content/docs/guides/deploy.mdx +++ b/content/docs/guides/deploy.mdx @@ -152,7 +152,7 @@ The CLI prints the link as `url`, at the address `MARFA_AUTH_BASE_URL` names. ** ## Run it without a container -In a clone of the repository, build the server: +**The server needs Node.js 22.14 or later.** The container image runs Node.js 24. In a clone of the repository, build the server: ```bash pnpm install --frozen-lockfile @@ -185,4 +185,6 @@ A server that crashes can leave its socket file behind. On restart, the server r `GET /` answers the `release` the image was built as, beside `version`, which is the commit. A build from a clone has no `release`. +In a browser, the same address answers a page instead of the description: [Home](/guides/authenticate#start-from-home) for the signed-in owner. A program that doesn't ask for HTML gets the description. + The rules behind this page are in the [instance chapter](https://github.com/withmarfa/marfa/blob/main/conformance/spec/instance.md) of the contract, and the container recipe is described in the [deployment guide](https://github.com/withmarfa/marfa/blob/main/deploy/README.md). diff --git a/content/docs/guides/manage-keys.mdx b/content/docs/guides/manage-keys.mdx index 210b66b4..7e5157cd 100644 --- a/content/docs/guides/manage-keys.mdx +++ b/content/docs/guides/manage-keys.mdx @@ -5,7 +5,19 @@ description: Give each app or script a key that reaches only what it needs, make A script or service you run yourself uses a key, which reaches only what it was made with. An app can instead sign in with OAuth, as [Authenticate](/guides/authenticate) shows. A notes app needs notes and nothing else; a script that runs for a week needs a key that stops working after a week. [Access](/access) explains the maps and permissions a key holds. -Two kinds of caller can make keys: the owner, through the instance's private socket or signed in to the instance's Manage Marfa pages in a browser, and a key or an app's token that holds the `keys.mint` permission. The CLI examples use the socket, so run them on the instance's machine, under the account that runs the server. +Two kinds of caller can make keys: the owner, through the instance's private socket or signed in to the instance's API keys page in a browser, and a key or an app's token that holds the `keys.mint` permission. The CLI examples use the socket, so run them on the instance's machine, under the account that runs the server. + +## Use the API keys page + +**The owner can make, narrow and revoke keys in a browser.** Open API keys from [Home](/guides/authenticate#start-from-home), at `/auth/owner/keys`. The page lists the keys, and Create key makes one: + +- **Details** holds the key's name and when it ends. +- **Access** starts with everything turned off. Turn on only what the key needs, at the levels No access, Read only or Read and edit, as the approval screen offers them for an [app](/guides/authenticate#what-the-owner-chooses). +- **Advanced** holds the source the key writes as, the other sources it may write as, where its items land, whether it follows Marfa's write rules, and its access to integration data. + +**The page shows the key once.** After you create it, Copy your key holds the secret, and the page takes it away when you leave. No other page under `/auth/owner/keys` ever holds a key's secret. Opening a key shows its details and what it holds, and you can give it less access or an earlier end, never more: to give more, make a new key. The Save bar appears after a change and counts the changes, and Revoke this key sits apart, below the form. The page refuses to save over a key that changed after the page opened, and asks you to reload. + +The rest of this page uses the socket and the API, which reach the same operations. ## Make a key for an app or a script @@ -28,11 +40,12 @@ Content-Type: application/json } ``` -Marfa answers the key's plaintext, which begins `marfa_k1_`, once. No later answer shows it, so **copy it to the app now**. +Marfa answers the key's plaintext, which begins `marfa_k1_`, once, and tells caches not to keep the answer (`Cache-Control: no-store`). No later answer shows it, so **copy it to the app now**. ```text marfa_k1_614e182634b2e78eb69e0a3cda8806b739be5a4917c5765a5420dfe766c63947 key 01a12311-8b01-7c3c-879e-3ff7346ce72f (Notes app); the plaintext is shown once, keep it now + core.note Read and edit ``` The source, here `notes-app`, is stamped on every item the key creates, so you can always tell which app wrote what. **Each key in use has a source of its own**, and a source another key already has is refused `409 conflict`. With this key, creating a note works, and creating a bookmark is refused `403 type_not_permitted`. @@ -164,6 +177,8 @@ Content-Type: application/json A key holding `keys.mint` changes only the keys that hold nothing it doesn't. A key holding `keys.manage` reaches every key, but can only take access away. +**An update can say it must only narrow.** Add `"narrow_only": true` to `PATCH /keys/{id}`, and Marfa refuses `403 forbidden` any change that would give the key more, whoever sends it: a permission, a map entry or a claimed source it doesn't hold, a later `expires_at` or none for a key that has one, or an `enforcement_override` other than its own. A key that an app made, and a key changed by a caller holding `keys.manage`, are always held to this. The API keys page sends `narrow_only` with every save, so a page opened before another change can't undo it. + ## Revoke a key **Revoking ends a key at once**: the next request it sends is refused `401 unauthorized`. @@ -177,7 +192,7 @@ DELETE /keys/01a12311-8b01-7c3c-879e-3ff7346ce72f Authorization: Bearer ``` -**Revoking a key from a browser needs a sign-in within the last five minutes.** A browser that signed in earlier is refused `403 forbidden` and leaves the key working. The private socket and a key that holds `keys.mint` or `keys.manage` are not affected by when the owner last signed in. +**Revoking a key from a browser needs a sign-in within the last five minutes.** The API keys page asks a browser that signed in earlier to [confirm it's the owner](/guides/authenticate#confirm-its-you). A request without a page is refused `403 forbidden` and leaves the key working. The private socket and a key that holds `keys.mint` or `keys.manage` are not affected by when the owner last signed in. Revoke a key you think has leaked, then make a new one for the app. diff --git a/content/docs/guides/troubleshooting.mdx b/content/docs/guides/troubleshooting.mdx index 531e691f..40157943 100644 --- a/content/docs/guides/troubleshooting.mdx +++ b/content/docs/guides/troubleshooting.mdx @@ -116,9 +116,9 @@ Give the key that grant, or use a key that holds it. [Manage keys](/guides/manag -The owner's browser signed in more than five minutes ago, and the operation needs a recent sign-in. Revoking a key, disconnecting an app, and approving write access or a permission for an app are such operations. Marfa changes nothing. +The owner's browser signed in more than five minutes ago, and the operation needs a recent sign-in. Revoking a key, disconnecting an app, changing the profile or a passkey, and approving write access or a permission for an app are such operations. Marfa changes nothing. -Sign in again at `/auth/sign-in?prompt=login`, then repeat the operation. Without `prompt=login`, the page only says you are signed in. An approval screen sends you to sign in and back when you open it or submit it. [Authenticate](/guides/authenticate#when-approval-needs-a-recent-sign-in) lists what a browser that signed in earlier can still approve. +A page in the owner's console handles this itself: it asks you to confirm it's you with the password or a passkey, then sends the change again. You see the message only from a program that makes the request, or when the confirmation is refused. A program can't confirm: sign in again in a browser, or use the private socket. [Authenticate](/guides/authenticate#confirm-its-you) describes the confirmation, and [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) lists what a browser that signed in earlier can still approve. @@ -201,6 +201,8 @@ Cut text between characters, not between code units. In JavaScript, `String.prot The item's properties don't fit its type: a required field is missing, or a value has the wrong shape. `details.errors` names each field and what is wrong with it. A `core.note`, for example, needs a `body`. +Two other values are refused the same way. A whole number that Marfa wouldn't serve back with the same digits, such as `1500000000000000001`, is refused with the message `Must be a whole number that Marfa serves back with the same digits…`: send it as a string. A `url` value that the WHATWG URL Standard refuses, such as a host with a forbidden character, is refused too. [Field kinds](/catalog/field-kinds) gives both rules. + Read the type's fields with `marfa types get `, or `GET /types/`, and send what it asks for. [Items and types](/items-and-types) explains what a type defines. From abbca525963670a7ae0e8ac8b960fb5de199cab4 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Tue, 20 Oct 2026 23:48:46 +0100 Subject: [PATCH 04/10] docs(catalog): document exact numbers, URLs, tags and retries --- .../docs/(overview)/properties-metadata-and-edges.mdx | 2 ++ content/docs/catalog/field-kinds.mdx | 10 +++++++--- content/docs/guides/write-safely.mdx | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/content/docs/(overview)/properties-metadata-and-edges.mdx b/content/docs/(overview)/properties-metadata-and-edges.mdx index 65af7154..cea54ac3 100644 --- a/content/docs/(overview)/properties-metadata-and-edges.mdx +++ b/content/docs/(overview)/properties-metadata-and-edges.mdx @@ -51,6 +51,8 @@ Content-Type: application/json { "tags": ["favorite"] } ``` +**A tag can't be empty, blank or longer than 128 UTF-16 code units, and it can't be `.` or `..`.** Marfa refuses such a tag `400 validation_error` and names it in `details.errors[].message`, on every write that carries tags except a restore. A path can't name `.` or `..`, because a URL parser drops them, so such a tag could never be removed with `DELETE /items/{id}/tags/{tag}`. + An extension is a JSON object stored on an item under a namespace, such as `com.example.reader`. Whoever writes a namespace decides what goes in it, and **one app's extension stays out of another's way**: a key reads and writes only the namespaces it was given. ```http diff --git a/content/docs/catalog/field-kinds.mdx b/content/docs/catalog/field-kinds.mdx index d62aad30..984f5220 100644 --- a/content/docs/catalog/field-kinds.mdx +++ b/content/docs/catalog/field-kinds.mdx @@ -6,10 +6,10 @@ description: The kinds of value a type's field can hold. | Kind | Holds | | ----------- | ------------------------------------------------------------------------------------------------------------------------------ | | `string` | Text, up to the field's `maxLength` in UTF-16 code units, or 100,000 where it sets none. | -| `number` | A number. | -| `integer` | A whole number. | +| `number` | A number. A whole number must be one Marfa serves back with the same digits, as the notes below say. | +| `integer` | A whole number, with the same rule on its digits. | | `boolean` | `true` or `false`. | -| `url` | A URL. | +| `url` | An absolute URL that the WHATWG URL Standard accepts, as the notes below say. | | `email` | An email address. | | `datetime` | A date as `YYYY-MM-DD`, or a date and time with a zone, such as `2026-03-01T09:30:00Z`. | | `date` | A calendar date as `YYYY-MM-DD`. | @@ -19,3 +19,7 @@ description: The kinds of value a type's field can hold. | `thumbnail` | A small image as a PNG, JPEG or WebP data URI that decodes to at most 16 KiB. | A `string` field can also name a `format` that only labels it: `bcp47` for a language tag, as on `language`, and `iso3166` for a country code, as on `country`. + +**Marfa refuses a whole number it can't serve back with the same digits.** It holds a number as a 64-bit float and serves it in the shortest digits that name that float, as JavaScript's `Number.prototype.toString` writes them. A write that names `1500000000000000001`, `1152921504606846976` (2^60) or `-0` is refused `400 invalid_properties`, because Marfa would serve them as `1500000000000000000`, `1152921504606847000` and `0`. `details.errors` names the value in `field`, such as `list.1`, with the message `Must be a whole number that Marfa serves back with the same digits, not , which it serves as . Send it as a string.` A bulk write is refused whole, and `details.index` gives the entry's position. A number Marfa serves with the digits sent, such as `1152921504606847000`, and a number with a fraction or an exponent are taken. **The rule covers every property value at any depth**, an edge's `properties` and an extension's data too, and a device refuses the same numbers before it queues the write. A value that must keep every digit goes in a `string` field. + +**Marfa judges a `url` value as the WHATWG URL Standard does.** It removes white space from both ends first, the white space that JavaScript's `String.prototype.trim` removes except U+0085, and U+FEFF. It then accepts the value where the standard parses it as an absolute URL, whichever Node.js version runs the server. A host that is ASCII after percent-decoding is taken whatever its labels hold, so `http://xn--` is a URL. A host with other characters must be valid IDNA, and a host with a forbidden domain code point, or a last label that is a number where the host isn't a valid IPv4 address, is refused `400 invalid_properties`. Marfa stores the value without the white space it removed and without any tab, line feed or carriage return. A device judges the same way, with one exception: it refuses a host that holds a code point its own IDNA tables don't assign, which the server takes. diff --git a/content/docs/guides/write-safely.mdx b/content/docs/guides/write-safely.mdx index 6b84f03e..6b642fd6 100644 --- a/content/docs/guides/write-safely.mdx +++ b/content/docs/guides/write-safely.mdx @@ -201,6 +201,8 @@ const send = () => If Marfa already answered that request, it returns the first answer again, with the header `Idempotency-Replayed: true`, and writes nothing. A repeat of a request refused `409 version_conflict` gets the same `409`. A repeat of one that failed with a `5xx` runs again, and so does a repeat of one refused `404 blob_not_found` because a file item named bytes its writer hadn't uploaded: Marfa keeps no answer for that refusal, so after you upload the bytes, the same key works. +**Marfa keeps only the status of a first answer larger than 1,048,576 bytes.** A repeat of that request answers `422 idempotency_result_not_retained`, with the first answer's status in `details.original_status`, and writes nothing again. A status from `200` to `299` there means the first write landed: read what it changed instead of sending it again. + A key names one request. The same key on a different request, such as the resolved write after a conflict, answers `422 idempotency_key_reused` and writes nothing, so **send each new write under a new key**. A repeat that arrives while the first is still running answers `409 idempotency_key_in_flight`: wait, then send it again. The item operations that write one item, the edge and folder operations, and [Apply a bulk action](/api-reference/bulk-actions/applyBulkAction) take a key. [Errors](/api-reference/errors#retrying) says which refusals to retry. From 788e7340c602839b6fd6a871053d3c2c0c352b9c Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Tue, 20 Oct 2026 23:48:46 +0100 Subject: [PATCH 05/10] docs(overview): cover blocked writes, folders, webhooks and CLI words --- content/docs/(overview)/devices.mdx | 8 +++++++- content/docs/(reference)/cli.mdx | 17 ++++++++++++++--- content/docs/guides/keep-folders-running.mdx | 2 +- content/docs/guides/receive-webhooks.mdx | 7 +++++++ content/docs/guides/sync-a-folder.mdx | 6 +++++- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/content/docs/(overview)/devices.mdx b/content/docs/(overview)/devices.mdx index 4eb5718c..9c90c916 100644 --- a/content/docs/(overview)/devices.mdx +++ b/content/docs/(overview)/devices.mdx @@ -41,11 +41,17 @@ Each write the instance answers gets one verdict: | `merged` | Another change had touched the same field, and the instance settled the two by the type's rule, such as keeping the later title. | | `conflicted` | The two changes collided, and the instance kept both: the item keeps its value, and the write's value goes to a conflicted copy. | | `refused` | The instance refused the write, for example because it broke the item's type. The device keeps its content so an app can offer it back. | -| `blocked` | Something has to change before the write can go, such as an expired credential. | +| `blocked` | Something has to change before the write can go, such as an expired credential, or the write keeps meeting a failure that other writes don't. | | `dead` | The write failed five times in ways that count against it, such as an answer the device couldn't read. A lost connection or a server error never counts. | **Only `refused`, `blocked` and `dead` need an app's attention.** An app decides what to do with each: send it again, withdraw it, or let the person decide. [Versions](/versions) explains how the instance settles two changes to one item. +A `blocked` write has one of six reasons: `credential_refused`, `key_spent`, `ancestor_unavailable`, `conflict_unresolved`, `awaiting_dependency` and `failing_alone`. + +**A device sets a write aside as `failing_alone` only when the server shows it can take other writes.** A gateway can refuse one write in the words of an outage, such as an HTML `413` page for a large upload, and that write would otherwise end every drain and hold back the writes behind it. A write that meets such a failure in three drains in a row, the first at least five minutes earlier, becomes a suspect. The device then sends another write that doesn't depend on the suspect. If the server answers it, the device sends the suspect once more, and if that fails again, the device blocks the suspect as `failing_alone` and carries on with the rest of the queue. Writes that depend on it wait as `awaiting_dependency`. A device that drains less often than every 15 minutes never sets a write aside this way. Releasing the write clears its count and gives it a fresh try, and withdrawing it refuses the writes that wait on it. + +When a write has met such a failure, the queue shows the last answer: `marfa device queue` prints `last answer: HTTP : `, with the start of the body if it is text, cut to 200 characters on one line. + ## Folders A device's working copy can also be a directory of files on your computer: **a folder**. Each item the folder holds is a file, a note becomes a Markdown file, and an edit to either the file or the item reaches the other. diff --git a/content/docs/(reference)/cli.mdx b/content/docs/(reference)/cli.mdx index 5d153543..e4bb7f91 100644 --- a/content/docs/(reference)/cli.mdx +++ b/content/docs/(reference)/cli.mdx @@ -41,7 +41,7 @@ These docs follow Marfa's `main` branch, which can be ahead of the latest releas A command that talks to an instance needs the server's address and a credential. **The CLI can keep both for you**, so a bare command such as `marfa status` knows where to go. There are two ways to give it a credential. -**Sign in as an app** with `marfa login`. The CLI shows a code and opens a page on the server, where you sign in as the owner and approve what it asks for. The CLI asks for more than reading, so the page asks you to sign in again if you signed in more than five minutes ago and haven't approved the CLI for those scopes before. It then keeps the app's token in the keychain: +**Sign in as an app** with `marfa login`. The CLI shows a code and opens a page on the server, where you sign in as the owner and approve what it asks for. The CLI asks for more than reading, so if you signed in more than five minutes ago and haven't approved the CLI for those scopes before, the page asks you to confirm it's you when you approve. It then keeps the app's token in the keychain: ```bash marfa login --url https://marfa.example.com @@ -50,11 +50,20 @@ marfa login --url https://marfa.example.com To print the token set instead of keeping it, add `--print-token`. The command then doesn't read or write the keychain, so the server must come from `--url` or `MARFA_API_URL`. ```text -Open https://marfa.example.com/auth/device?user_code=V3Z5RBBC and enter the code V3Z5RBBC +Open https://marfa.example.com/auth/device?user_code=V3Z5RBBC and enter the code V3Z5-RBBC Waiting for the approval... ``` -The token holds only the scopes you approve. The CLI asks for everything an owner can grant except `instance.read`, `instance.maintain`, `connectors.manage`, `blobs.manage` and `keys.manage`, so the commands that need one of those need a key instead. `marfa logout` revokes the token and forgets it. +The token holds only the scopes you approve, and `marfa login` ends by printing them in the words the approval page uses: + +```text +signed in to https://marfa.example.com; the token is in the keychain + core.note Read and edit + core.task Read only + Powers: See and disconnect other apps +``` + +The CLI asks for everything an owner can grant except `instance.read`, `instance.maintain`, `connectors.manage`, `blobs.manage` and `keys.manage`, so the commands that need one of those need a key instead. `marfa logout` revokes the token and forgets it. Or **keep a key** you already have with `marfa keys keep`. It reads the key from `MARFA_API_KEY`, or, if that isn't set, from standard input, so you can paste it and press Return. It checks the key with the server, then keeps it in the keychain. @@ -121,6 +130,8 @@ a key from the keychain The instance name comes from its live configuration and defaults to Marfa. [Settings](/guides/settings#change-settings-while-it-runs) shows how to change it. After the name comes the **release** the server was built as, then the commit it was built from. `marfa status` names them the same way. A server built from a clone shows only the commit, because it has no release. +**Access is printed in the words the approval page uses.** Under an app's token, `marfa whoami` lists what the owner approved, and `marfa keys list`, `current`, `create` and `update` list what a key holds. Each item type shows as No access, Read only or Read and edit, named by its identifier, and each permission by its title, such as "See health, metrics, storage and job status". `--json` prints the fields as before. + When a command reaches the wrong instance, or is refused, start here. ## On the instance's own machine diff --git a/content/docs/guides/keep-folders-running.mdx b/content/docs/guides/keep-folders-running.mdx index 083abbcd..837aad63 100644 --- a/content/docs/guides/keep-folders-running.mdx +++ b/content/docs/guides/keep-folders-running.mdx @@ -26,7 +26,7 @@ Every line names the folder's directory, and with `--json` each line is one JSON **The watch follows the list.** A folder you add with `marfa folders add` joins it, and one you remove leaves it, with no restart. Each folder uses the server its copy came from and the credential the keychain keeps for that server, so folders on different servers share one watch and no key is sent to a server it wasn't made for. -**One folder that can't be watched doesn't stop the others.** The watch reports it once and tries again. A folder fails to open while its first sync waits for `marfa folders confirm`, while another process works it, or when the keychain holds no credential for its server. Folders whose directories can't be found, such as one on a disk that isn't connected, are reported together in one line. They stay listed, and `marfa folders remove` takes one off. +**One folder that can't be watched doesn't stop the others.** The watch reports it once and tries again. A folder fails to open while its first sync waits for `marfa folders confirm`, while another process works it, or when the keychain holds no credential for its server. Folders whose directories can't be found, such as one on a disk that isn't connected, are reported together in one line. They stay listed, and `marfa folders remove` takes one off. A folder whose path now holds a different directory stays listed too, and the watch carries on with it when the original returns, as [Sync a folder](/guides/sync-a-folder#a-missing-or-unreadable-directory) describes. To watch a single folder, name its directory: `marfa folders watch Notes`. diff --git a/content/docs/guides/receive-webhooks.mdx b/content/docs/guides/receive-webhooks.mdx index 7452da68..1a575699 100644 --- a/content/docs/guides/receive-webhooks.mdx +++ b/content/docs/guides/receive-webhooks.mdx @@ -200,6 +200,13 @@ await marfa.POST("/webhooks/{id}/deliveries/{delivery_id}/redeliver", { **Only a `dead_letter` delivery can be sent again**; any other answers `409 conflict`. A delivery's record is kept for as long as the instance keeps its audit log. +## Change what a webhook sends + +[Update a webhook](/api-reference/webhooks/updateWebhook) changes its `url`, its `events`, its `type_filter` or whether it is `active`. **A change settles the deliveries that wait for a retry before it answers**, so a receiver is never sent an event the owner has since excluded. A delivery being sent when the change commits ends the same way. + +- **Narrowing `events` or `type_filter`** settles each pending delivery they no longer select as `canceled`, with the error `The subscription's events or type filter no longer select this event.` Widening them again revives nothing already settled, and a change that still selects an event settles nothing. +- **Changing the `url` or setting `active` to `false`** settles every pending delivery as `canceled`, so none goes to the old address. + ## Receive deliveries on a private network By default, **Marfa posts only to public addresses**. A host that isn't public is a loopback address such as `localhost`, a private address, a link-local or unique-local address, or a carrier-grade NAT address such as the `100.64.0.0/10` range that Tailscale uses. Marfa refuses a webhook whose `url` has such a host, both when you register it and when you change its `url`, with `400 validation_error`. The message names the setting that allows it, `MARFA_WEBHOOK_ALLOW_PRIVATE_ADDRESSES`. diff --git a/content/docs/guides/sync-a-folder.mdx b/content/docs/guides/sync-a-folder.mdx index 5baf5ad5..5f7aa879 100644 --- a/content/docs/guides/sync-a-folder.mdx +++ b/content/docs/guides/sync-a-folder.mdx @@ -210,6 +210,8 @@ A walking tour of the old harbor. The file took its type from the folder's settings, its title from its name, and its `marfa_id` once the instance created the item. A `.txt` file is all body. Any other file the search admits, such as a photo, becomes a file item, with its bytes uploaded as a blob, as [Handle files](/guides/handle-files) describes. +**A file item keeps its bytes, whatever its name.** A file item that sits at a name ending in `.md`, `.markdown` or `.txt`, is sent as bytes however they begin: Marfa reads no frontmatter in it, never flags it `unreadable` or `encoding`, and keeps no text copy of its bytes in the folder's store, so a very large file doesn't need memory for one. A file at such a name that is empty waits until it holds bytes, and the other files in the scan still go. A file that isn't bound to a file item is read as a document, as above. + ## Edits on either side Edit a file and sync, and the edit reaches its item: @@ -257,7 +259,9 @@ The first line of a report counts files. A file whose create or edit was sent ea A scan that cannot read or identify an entry is incomplete. **It does not treat files it failed to see as deleted**, and it defers deletions already waiting until a complete scan succeeds. Fix the directory or disk access and sync again. -Each directory has its own working copy and queue, recorded in the machine's folder registry. **Adding a folder inside a registered folder, or around one, is refused.** +Each directory has its own working copy and queue, recorded in the machine's folder registry, together with an identity of the directory it admitted. **Adding a folder inside a registered folder, or around one, is refused.** + +**A folder stays listed when another directory takes its place.** If a restore or a sync tool replaces the directory with another, empty at first, Marfa keeps the folder's entry and doesn't read the new directory as the folder. When the original directory returns to its path, a watch of every folder carries on with it, and you don't add the folder again. Marfa removes an entry only when the admitted directory itself holds no folder state. `marfa folders list --json` gives each entry a `root`, an opaque value to compare for equality and not to parse. A scan also leaves another folder's state directory and contents alone. If the registry cannot be read, deletion waits when the device cannot rule out a file having moved. From ddf356bccdc408b23bd2a2c6980302e100bac41a Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Tue, 20 Oct 2026 23:49:30 +0100 Subject: [PATCH 06/10] docs(catalog): use the glossary's terms --- content/docs/catalog/field-kinds.mdx | 2 +- content/docs/guides/manage-keys.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/catalog/field-kinds.mdx b/content/docs/catalog/field-kinds.mdx index 984f5220..110aab2d 100644 --- a/content/docs/catalog/field-kinds.mdx +++ b/content/docs/catalog/field-kinds.mdx @@ -22,4 +22,4 @@ A `string` field can also name a `format` that only labels it: `bcp47` for a lan **Marfa refuses a whole number it can't serve back with the same digits.** It holds a number as a 64-bit float and serves it in the shortest digits that name that float, as JavaScript's `Number.prototype.toString` writes them. A write that names `1500000000000000001`, `1152921504606846976` (2^60) or `-0` is refused `400 invalid_properties`, because Marfa would serve them as `1500000000000000000`, `1152921504606847000` and `0`. `details.errors` names the value in `field`, such as `list.1`, with the message `Must be a whole number that Marfa serves back with the same digits, not , which it serves as . Send it as a string.` A bulk write is refused whole, and `details.index` gives the entry's position. A number Marfa serves with the digits sent, such as `1152921504606847000`, and a number with a fraction or an exponent are taken. **The rule covers every property value at any depth**, an edge's `properties` and an extension's data too, and a device refuses the same numbers before it queues the write. A value that must keep every digit goes in a `string` field. -**Marfa judges a `url` value as the WHATWG URL Standard does.** It removes white space from both ends first, the white space that JavaScript's `String.prototype.trim` removes except U+0085, and U+FEFF. It then accepts the value where the standard parses it as an absolute URL, whichever Node.js version runs the server. A host that is ASCII after percent-decoding is taken whatever its labels hold, so `http://xn--` is a URL. A host with other characters must be valid IDNA, and a host with a forbidden domain code point, or a last label that is a number where the host isn't a valid IPv4 address, is refused `400 invalid_properties`. Marfa stores the value without the white space it removed and without any tab, line feed or carriage return. A device judges the same way, with one exception: it refuses a host that holds a code point its own IDNA tables don't assign, which the server takes. +**Marfa judges a `url` value as the WHATWG URL Standard does.** It removes whitespace from both ends first, the whitespace that JavaScript's `String.prototype.trim` removes except U+0085, and U+FEFF. It then accepts the value where the standard parses it as an absolute URL, whichever Node.js version runs the server. A host that is ASCII after percent-decoding is taken whatever its labels hold, so `http://xn--` is a URL. A host with other characters must be valid IDNA, and a host with a forbidden domain code point, or a last label that is a number where the host isn't a valid IPv4 address, is refused `400 invalid_properties`. Marfa stores the value without the whitespace it removed and without any tab, line feed or carriage return. A device judges the same way, with one exception: it refuses a host that holds a code point its own IDNA tables don't assign, which the server takes. diff --git a/content/docs/guides/manage-keys.mdx b/content/docs/guides/manage-keys.mdx index 7e5157cd..41d2d172 100644 --- a/content/docs/guides/manage-keys.mdx +++ b/content/docs/guides/manage-keys.mdx @@ -13,7 +13,7 @@ Two kinds of caller can make keys: the owner, through the instance's private soc - **Details** holds the key's name and when it ends. - **Access** starts with everything turned off. Turn on only what the key needs, at the levels No access, Read only or Read and edit, as the approval screen offers them for an [app](/guides/authenticate#what-the-owner-chooses). -- **Advanced** holds the source the key writes as, the other sources it may write as, where its items land, whether it follows Marfa's write rules, and its access to integration data. +- **Advanced** holds the source the key writes as, the other sources it may write as, where its items land, whether it follows Marfa's write rules, and its access to what connectors attach to your items. **The page shows the key once.** After you create it, Copy your key holds the secret, and the page takes it away when you leave. No other page under `/auth/owner/keys` ever holds a key's secret. Opening a key shows its details and what it holds, and you can give it less access or an earlier end, never more: to give more, make a new key. The Save bar appears after a change and counts the changes, and Revoke this key sits apart, below the form. The page refuses to save over a key that changed after the page opened, and asks you to reload. From 40489f29e6669b6a92e1c3ccf4a35994d43faf66 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Tue, 20 Oct 2026 23:57:31 +0100 Subject: [PATCH 07/10] docs(release): tighten three passages from review --- content/docs/(reference)/changelog.mdx | 2 +- content/docs/(reference)/cli.mdx | 2 +- content/docs/catalog/field-kinds.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/(reference)/changelog.mdx b/content/docs/(reference)/changelog.mdx index f9c0f4ef..92ae0db3 100644 --- a/content/docs/(reference)/changelog.mdx +++ b/content/docs/(reference)/changelog.mdx @@ -29,7 +29,7 @@ For apps and devices: - **A tag of `.` or `..` is refused** with `400 validation_error`, as an empty, blank or over-long tag is. A restore still accepts what an archive holds. - **Narrowing a webhook cancels the deliveries it no longer selects.** Pending deliveries that the narrowed subscription doesn't select end `canceled`. Changing the `url` or setting `active: false` cancels all of them, and widening revives none. [Change what a webhook sends](/guides/receive-webhooks#change-what-a-webhook-sends) covers it. - **The CLI prints access in the words the approval page uses.** `marfa login`, `whoami`, and `keys list`, `current`, `create` and `update` print No access, Read only and Read and edit for each type, and name powers by their titles. `--json` is unchanged. [CLI](/cli#check-what-a-command-will-use) covers it. -- **A write that fails alone is set aside.** A device marks a queued write `blocked` with the reason `failing_alone` when three drains in a row fail it, the first at least five minutes before the third, and an independent write succeeds in between. Its dependants wait as `awaiting_dependency`. `marfa device queue` prints the last answer the server gave, as `last answer: HTTP : `. [Devices](/devices#verdicts) covers it. +- **A write that fails alone is set aside.** A device marks a queued write `blocked` with the reason `failing_alone` when three drains in a row fail it, the first at least five minutes before the third, and an independent write succeeds in between. Its dependents wait as `awaiting_dependency`. `marfa device queue` prints the last answer the server gave, as `last answer: HTTP : `. [Devices](/devices#verdicts) covers it. - **A file item keeps its bytes whatever its name.** A folder file named `.md`, `.markdown` or `.txt` becomes a file item with no frontmatter read and no text copy in the store. An empty file waits, and the rest of the scan goes on. A `marfa_id` over 256 bytes is no id. - **A lock file the CLI can't write is refused with a message.** When the lock file opens read-only, the CLI stops with ` cannot be opened for writing, so it cannot be locked: check that it is writable`. - **A repeat of a write whose first answer was large is refused, not run again.** Marfa keeps only the status of a first answer over 1,048,576 bytes, so a repeat under the same `Idempotency-Key` answers `422 idempotency_result_not_retained` with `details.original_status`, and writes nothing. A device counts a `2xx` status there as delivered. [Write safely](/guides/write-safely#retry-with-an-idempotency-key) covers it. diff --git a/content/docs/(reference)/cli.mdx b/content/docs/(reference)/cli.mdx index e4bb7f91..c4fca790 100644 --- a/content/docs/(reference)/cli.mdx +++ b/content/docs/(reference)/cli.mdx @@ -130,7 +130,7 @@ a key from the keychain The instance name comes from its live configuration and defaults to Marfa. [Settings](/guides/settings#change-settings-while-it-runs) shows how to change it. After the name comes the **release** the server was built as, then the commit it was built from. `marfa status` names them the same way. A server built from a clone shows only the commit, because it has no release. -**Access is printed in the words the approval page uses.** Under an app's token, `marfa whoami` lists what the owner approved, and `marfa keys list`, `current`, `create` and `update` list what a key holds. Each item type shows as No access, Read only or Read and edit, named by its identifier, and each permission by its title, such as "See health, metrics, storage and job status". `--json` prints the fields as before. +**Access is printed in the words the approval page uses.** Under an app's token, `marfa whoami` lists what the owner approved, and `marfa keys list`, `current`, `create` and `update` list what a key holds. Each item type shows as No access, Read only or Read and edit, named by its identifier, and each permission by its title, such as "See health, metrics, storage and job status". `--json` prints structured output. When a command reaches the wrong instance, or is refused, start here. diff --git a/content/docs/catalog/field-kinds.mdx b/content/docs/catalog/field-kinds.mdx index 110aab2d..86aff7af 100644 --- a/content/docs/catalog/field-kinds.mdx +++ b/content/docs/catalog/field-kinds.mdx @@ -22,4 +22,4 @@ A `string` field can also name a `format` that only labels it: `bcp47` for a lan **Marfa refuses a whole number it can't serve back with the same digits.** It holds a number as a 64-bit float and serves it in the shortest digits that name that float, as JavaScript's `Number.prototype.toString` writes them. A write that names `1500000000000000001`, `1152921504606846976` (2^60) or `-0` is refused `400 invalid_properties`, because Marfa would serve them as `1500000000000000000`, `1152921504606847000` and `0`. `details.errors` names the value in `field`, such as `list.1`, with the message `Must be a whole number that Marfa serves back with the same digits, not , which it serves as . Send it as a string.` A bulk write is refused whole, and `details.index` gives the entry's position. A number Marfa serves with the digits sent, such as `1152921504606847000`, and a number with a fraction or an exponent are taken. **The rule covers every property value at any depth**, an edge's `properties` and an extension's data too, and a device refuses the same numbers before it queues the write. A value that must keep every digit goes in a `string` field. -**Marfa judges a `url` value as the WHATWG URL Standard does.** It removes whitespace from both ends first, the whitespace that JavaScript's `String.prototype.trim` removes except U+0085, and U+FEFF. It then accepts the value where the standard parses it as an absolute URL, whichever Node.js version runs the server. A host that is ASCII after percent-decoding is taken whatever its labels hold, so `http://xn--` is a URL. A host with other characters must be valid IDNA, and a host with a forbidden domain code point, or a last label that is a number where the host isn't a valid IPv4 address, is refused `400 invalid_properties`. Marfa stores the value without the whitespace it removed and without any tab, line feed or carriage return. A device judges the same way, with one exception: it refuses a host that holds a code point its own IDNA tables don't assign, which the server takes. +**Marfa judges a `url` value as the WHATWG URL Standard does.** It first trims both ends with JavaScript's `String.prototype.trim`, which removes U+FEFF but not U+0085. It then accepts the value where the standard parses it as an absolute URL, whichever Node.js version runs the server. A host that is ASCII after percent-decoding is taken whatever its labels hold, so `http://xn--` is a URL. A host with other characters must be valid IDNA, and a host with a forbidden domain code point, or a last label that is a number where the host isn't a valid IPv4 address, is refused `400 invalid_properties`. Marfa stores the value without the whitespace it removed and without any tab, line feed or carriage return. A device judges the same way, with one exception: it refuses a host that holds a code point its own IDNA tables don't assign, which the server takes. From 2ac30739dcbc5e13fe25e0d9d67ac9b6fba9cbab Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Wed, 21 Oct 2026 00:02:19 +0100 Subject: [PATCH 08/10] docs(release): state each v0.0.18 rule as the contract holds it --- content/docs/(overview)/devices.mdx | 2 +- .../properties-metadata-and-edges.mdx | 2 +- content/docs/(reference)/changelog.mdx | 22 +++++++++++-------- content/docs/(reference)/cli.mdx | 2 +- content/docs/catalog/field-kinds.mdx | 2 +- content/docs/guides/authenticate.mdx | 14 ++++++------ content/docs/guides/connect-a-web-page.mdx | 2 +- content/docs/guides/manage-keys.mdx | 2 +- content/docs/guides/receive-webhooks.mdx | 4 ++-- content/docs/guides/sync-a-folder.mdx | 4 ++-- content/docs/guides/troubleshooting.mdx | 2 +- 11 files changed, 31 insertions(+), 27 deletions(-) diff --git a/content/docs/(overview)/devices.mdx b/content/docs/(overview)/devices.mdx index 9c90c916..ee3e4f72 100644 --- a/content/docs/(overview)/devices.mdx +++ b/content/docs/(overview)/devices.mdx @@ -48,7 +48,7 @@ Each write the instance answers gets one verdict: A `blocked` write has one of six reasons: `credential_refused`, `key_spent`, `ancestor_unavailable`, `conflict_unresolved`, `awaiting_dependency` and `failing_alone`. -**A device sets a write aside as `failing_alone` only when the server shows it can take other writes.** A gateway can refuse one write in the words of an outage, such as an HTML `413` page for a large upload, and that write would otherwise end every drain and hold back the writes behind it. A write that meets such a failure in three drains in a row, the first at least five minutes earlier, becomes a suspect. The device then sends another write that doesn't depend on the suspect. If the server answers it, the device sends the suspect once more, and if that fails again, the device blocks the suspect as `failing_alone` and carries on with the rest of the queue. Writes that depend on it wait as `awaiting_dependency`. A device that drains less often than every 15 minutes never sets a write aside this way. Releasing the write clears its count and gives it a fresh try, and withdrawing it refuses the writes that wait on it. +**A device sets a write aside as `failing_alone` only when the server shows it can take other writes.** A gateway can refuse one write in the words of an outage, such as an HTML `413` page for a large upload, and that write would otherwise end every drain and hold back the writes behind it. A write that meets such a failure in three drains in a row, the first at least five minutes before the third and no two more than 15 minutes apart, becomes a suspect. The device then sends another write that doesn't depend on the suspect. If the server answers it in any way that isn't such a failure, a refusal included, the device sends the suspect once more in the same drain. If that fails again, the device blocks the suspect as `failing_alone` and carries on with the rest of the queue. Writes that depend on it wait as `awaiting_dependency`. A `429` neither counts toward the three nor sets a write aside. A device that drains less often than every 15 minutes never sets a write aside this way. Releasing the write clears its count and gives it a fresh try, and withdrawing it refuses the writes that wait on it. When a write has met such a failure, the queue shows the last answer: `marfa device queue` prints `last answer: HTTP : `, with the start of the body if it is text, cut to 200 characters on one line. diff --git a/content/docs/(overview)/properties-metadata-and-edges.mdx b/content/docs/(overview)/properties-metadata-and-edges.mdx index cea54ac3..9bd1a92a 100644 --- a/content/docs/(overview)/properties-metadata-and-edges.mdx +++ b/content/docs/(overview)/properties-metadata-and-edges.mdx @@ -51,7 +51,7 @@ Content-Type: application/json { "tags": ["favorite"] } ``` -**A tag can't be empty, blank or longer than 128 UTF-16 code units, and it can't be `.` or `..`.** Marfa refuses such a tag `400 validation_error` and names it in `details.errors[].message`, on every write that carries tags except a restore. A path can't name `.` or `..`, because a URL parser drops them, so such a tag could never be removed with `DELETE /items/{id}/tags/{tag}`. +**A tag can't be empty, blank or longer than 128 UTF-16 code units, and it can't be `.` or `..`.** Marfa refuses such a tag `400 validation_error` on every write that carries tags except a restore, and for `.` or `..` the message in `details.errors[]` names the tag. A path can't name `.` or `..`, because a URL parser drops them, so such a tag could never be removed with `DELETE /items/{id}/tags/{tag}`. An extension is a JSON object stored on an item under a namespace, such as `com.example.reader`. Whoever writes a namespace decides what goes in it, and **one app's extension stays out of another's way**: a key reads and writes only the namespaces it was given. diff --git a/content/docs/(reference)/changelog.mdx b/content/docs/(reference)/changelog.mdx index 92ae0db3..4e0d99d5 100644 --- a/content/docs/(reference)/changelog.mdx +++ b/content/docs/(reference)/changelog.mdx @@ -13,26 +13,30 @@ Released October 20, 2026. [v0.0.18 on GitHub](https://github.com/withmarfa/marf For running an instance: -- **The owner has a Home page at `/`.** A browser signed in as the owner sees the instance's name and address, what needs attention, a search for pages, and Sign out. A browser that isn't signed in is redirected to the sign-in page and returned to the page it asked for, and an unclaimed instance sends it to `/setup`. A program that requests `/` still gets the description of the instance. [Start from Home](/guides/authenticate#start-from-home) covers it. +- **The owner has a Home page at `/`.** A browser signed in as the owner sees the instance's name and address, what needs attention, a search for pages, and Sign out. A browser that isn't signed in gets a page that says the instance is running, with a Sign in link. An owner page under `/auth/owner/` sends a browser with no session to the sign-in page and returns it to the page it asked for, and an unclaimed instance sends it to `/setup`. A program that requests `/` still gets the description of the instance. [Start from Home](/guides/authenticate#start-from-home) covers it. - **The owner manages API keys in the browser.** The API keys pages list the keys, create one, and edit or revoke one. A key's access starts off, and the secret shows once and is removed when the owner leaves the page. A save is refused if the key changed after the page opened. [Use the API keys page](/guides/manage-keys#use-the-api-keys-page) covers it. -- **The owner has Profile and Password and passkeys pages.** Changing the password and adding, renaming or removing a passkey ask the owner to confirm it's them when the sign-in is more than five minutes old. A passkey name that is empty or too long is refused `400 validation_error` when the passkey is verified. [Authenticate](/guides/authenticate#sign-in-as-the-owner) covers it. -- **A folder that a different directory replaces stays listed.** A folder registry entry records the directory's identity. When another directory takes the folder's place, the entry stays and the watch resumes. Marfa removes the entry only when the directory it admits holds no folder state. [Sync a folder](/guides/sync-a-folder#a-missing-or-unreadable-directory) covers it. +- **The owner has Profile and Password and passkeys pages.** Adding, renaming or removing a passkey asks the owner to confirm it's them when the sign-in is more than five minutes old. Changing the password asks for the current password instead. A passkey name that is empty or too long is refused `400 validation_error` when the passkey is verified. [Authenticate](/guides/authenticate#sign-in-as-the-owner) covers it. +- **A folder that a different directory replaces stays listed.** A folder registry entry records the directory's identity. When another directory takes the folder's place, the entry stays, and the watch resumes when the directory the folder was admitted at returns. Marfa removes an entry when the directory at its path holds no folder state and isn't known to differ from the one admitted. [Sync a folder](/guides/sync-a-folder#a-missing-or-unreadable-directory) covers it. +- **Sign out stays beside the title on a phone.** On Home, the Sign out button no longer drops under the title and address. +- **Destructive actions sit in their own card, and Save appears only after a change.** Revoking a key and removing a passkey are in a bordered card of their own. A key's page shows Save and Discard, with a count of the changes, only once something has changed. - **The server needs Node.js 22.14 or later.** The container image runs Node.js 24. [Deploy](/guides/deploy) covers it. For apps and devices: -- **The owner chooses what an app gets.** The approval screen lists each type with No access, Read only or Read and edit, then the owner's profile and the powers over Marfa that are off until the owner turns them on. Three powers that can't be undone are marked. Access the app already holds shows as Already allowed. A request for all of an app's reads or writes shows Choose types, and the token then names the types the owner picked, not all data. If the owner chooses nothing new, the app gets `access_denied`. [What the owner chooses](/guides/authenticate#what-the-owner-chooses) covers it. +- **The owner chooses what an app gets.** The approval screen lists each type with No access, Read only or Read and edit, then the owner's profile and the powers over Marfa that are off until the owner turns them on. Three powers that can't be undone are marked. Access the app already holds shows as Already allowed. A request for all of an app's reads or writes shows Choose types, and the token then names the types the owner picked, not all data. If the app asks for more than `openid` and `offline_access` and the owner turns none of it on, the app gets `access_denied` and keeps what it held. A request of only those two can be approved. [What the owner chooses](/guides/authenticate#what-the-owner-chooses) covers it. - **A device code is easier to type.** The device page shows a code as `XXXX-XXXX`, accepts it in any case with or without the hyphen, and a code ends after 10 minutes. -- **Approving or revoking asks to confirm in a dialog, not a redirect.** `POST /auth/confirm` takes the owner's password or a passkey and replaces the session, so the browser carries on at the same page. A password counts against the attempt windows, and a refusal answers `429 rate_limited` with `Retry-After` and keeps the session. A program that calls a route that needs a recent sign-in gets `403 forbidden` with "Sign in again to perform this operation", and a request with no live session or key gets `401`. A browser may end its own sign-in at any time. [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) covers it. +- **Approving or revoking asks to confirm in a dialog, not a redirect.** `POST /auth/confirm` takes the owner's password or a passkey and replaces the session, so the browser carries on at the same page. A password counts against the attempt windows: a wrong one answers `401 unauthorized`, and once the windows are spent the answer is `429 rate_limited` with `Retry-After`. A refused confirmation keeps the session. A program that calls a route that needs a recent sign-in gets `403 forbidden` with "Sign in again to perform this operation". `POST /auth/confirm` answers `401` to a request with no live owner session or with a key. A browser may end its own sign-in at any time. [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) covers it. - **`narrow_only` keeps a key from gaining access.** `PATCH /keys/{id}` with `narrow_only: true` refuses a change that adds access, and the API keys pages send it. `POST /keys` answers `Cache-Control: no-store`, so the secret isn't cached. [Narrow a key](/guides/manage-keys#narrow-a-key) covers it. -- **Marfa keeps a whole number exact, or refuses it.** A `number` or `integer` value that Marfa can't serve back with the same digits, such as `1500000000000000001`, is refused `400 invalid_properties`, and `details.errors` names the field. A `url` value is trimmed and judged as the WHATWG URL Standard does, so an address with a non-ASCII host is valid and one with a forbidden code point is refused. [Field kinds](/catalog/field-kinds) covers it. +- **Marfa keeps a whole number exact, or refuses it.** A `number` or `integer` value that Marfa can't serve back with the same digits, such as `1500000000000000001`, is refused `400 invalid_properties`, and `details.errors` names the field. A `url` value is trimmed and judged as the WHATWG URL Standard does. For a special scheme such as `http`, a host with a non-ASCII character must be valid IDNA, and a host with a forbidden code point is refused. [Field kinds](/catalog/field-kinds) covers it. - **A tag of `.` or `..` is refused** with `400 validation_error`, as an empty, blank or over-long tag is. A restore still accepts what an archive holds. - **Narrowing a webhook cancels the deliveries it no longer selects.** Pending deliveries that the narrowed subscription doesn't select end `canceled`. Changing the `url` or setting `active: false` cancels all of them, and widening revives none. [Change what a webhook sends](/guides/receive-webhooks#change-what-a-webhook-sends) covers it. - **The CLI prints access in the words the approval page uses.** `marfa login`, `whoami`, and `keys list`, `current`, `create` and `update` print No access, Read only and Read and edit for each type, and name powers by their titles. `--json` is unchanged. [CLI](/cli#check-what-a-command-will-use) covers it. -- **A write that fails alone is set aside.** A device marks a queued write `blocked` with the reason `failing_alone` when three drains in a row fail it, the first at least five minutes before the third, and an independent write succeeds in between. Its dependents wait as `awaiting_dependency`. `marfa device queue` prints the last answer the server gave, as `last answer: HTTP : `. [Devices](/devices#verdicts) covers it. -- **A file item keeps its bytes whatever its name.** A folder file named `.md`, `.markdown` or `.txt` becomes a file item with no frontmatter read and no text copy in the store. An empty file waits, and the rest of the scan goes on. A `marfa_id` over 256 bytes is no id. +- **A write that fails alone is set aside.** A device marks a queued write `blocked` with the reason `failing_alone` when it fails in three drains in a row, the first at least five minutes before the third and no two more than 15 minutes apart, an independent write then gets a non-failure answer (a refusal counts), and the write fails again when sent once more in that drain. A `429` neither counts nor sets a write aside. Its dependents wait as `awaiting_dependency`. `marfa device queue` prints the last answer the server gave, as `last answer: HTTP : `. [Devices](/devices#verdicts) covers it. +- **A file item keeps its bytes at a document name.** A file already bound to a file item, at a name ending `.md`, `.markdown` or `.txt`, is sent as bytes whatever they hold, with no frontmatter read. If it is empty it waits, and the rest of the scan goes on. A file at such a name that isn't bound to a file item is still read as a document. A `marfa_id` over 256 bytes is no id. +- **`--no-keychain` keeps the CLI off the keychain.** `--no-keychain` or `MARFA_NO_KEYCHAIN=1` makes every command treat the keychain as absent, and `marfa login --print-token` no longer reads or writes it. A command that sends a key from `--key` or `MARFA_API_KEY` and has no server named is refused `no_server`. [CLI](/cli#run-without-the-keychain) covers it. +- **The approval rows of two types with the same label are told apart.** When two item types share a label, such as `core.task` and `aic.ticket.task`, each shows its identifier under the label, and the type search matches the identifier. - **A lock file the CLI can't write is refused with a message.** When the lock file opens read-only, the CLI stops with ` cannot be opened for writing, so it cannot be locked: check that it is writable`. -- **A repeat of a write whose first answer was large is refused, not run again.** Marfa keeps only the status of a first answer over 1,048,576 bytes, so a repeat under the same `Idempotency-Key` answers `422 idempotency_result_not_retained` with `details.original_status`, and writes nothing. A device counts a `2xx` status there as delivered. [Write safely](/guides/write-safely#retry-with-an-idempotency-key) covers it. +- **A repeat of a write whose first answer was large is refused, not run again.** Marfa keeps only the status of a first answer over 1,048,576 bytes, so a repeat under the same `Idempotency-Key` answers `422 idempotency_result_not_retained` with `details.original_status`, and writes nothing. A device counts a `2xx` status there as accepted, except for a create that names no `id`, which it refuses. [Write safely](/guides/write-safely#retry-with-an-idempotency-key) covers it. - **A waiting edge edit or delete stays in view through a catch-up.** When a catch-up brings an item into a device's copy along with its edges, the device lays its own waiting writes over those edges, as hydration does, instead of showing the server's version until the server answers. ## v0.0.17 diff --git a/content/docs/(reference)/cli.mdx b/content/docs/(reference)/cli.mdx index c4fca790..0690346d 100644 --- a/content/docs/(reference)/cli.mdx +++ b/content/docs/(reference)/cli.mdx @@ -63,7 +63,7 @@ signed in to https://marfa.example.com; the token is in the keychain Powers: See and disconnect other apps ``` -The CLI asks for everything an owner can grant except `instance.read`, `instance.maintain`, `connectors.manage`, `blobs.manage` and `keys.manage`, so the commands that need one of those need a key instead. `marfa logout` revokes the token and forgets it. +The CLI asks for every scope an owner can grant, the permissions included, so the page offers each of them and you turn on the ones you want. `marfa logout` revokes the token and forgets it. Or **keep a key** you already have with `marfa keys keep`. It reads the key from `MARFA_API_KEY`, or, if that isn't set, from standard input, so you can paste it and press Return. It checks the key with the server, then keeps it in the keychain. diff --git a/content/docs/catalog/field-kinds.mdx b/content/docs/catalog/field-kinds.mdx index 86aff7af..d8d8b019 100644 --- a/content/docs/catalog/field-kinds.mdx +++ b/content/docs/catalog/field-kinds.mdx @@ -22,4 +22,4 @@ A `string` field can also name a `format` that only labels it: `bcp47` for a lan **Marfa refuses a whole number it can't serve back with the same digits.** It holds a number as a 64-bit float and serves it in the shortest digits that name that float, as JavaScript's `Number.prototype.toString` writes them. A write that names `1500000000000000001`, `1152921504606846976` (2^60) or `-0` is refused `400 invalid_properties`, because Marfa would serve them as `1500000000000000000`, `1152921504606847000` and `0`. `details.errors` names the value in `field`, such as `list.1`, with the message `Must be a whole number that Marfa serves back with the same digits, not , which it serves as . Send it as a string.` A bulk write is refused whole, and `details.index` gives the entry's position. A number Marfa serves with the digits sent, such as `1152921504606847000`, and a number with a fraction or an exponent are taken. **The rule covers every property value at any depth**, an edge's `properties` and an extension's data too, and a device refuses the same numbers before it queues the write. A value that must keep every digit goes in a `string` field. -**Marfa judges a `url` value as the WHATWG URL Standard does.** It first trims both ends with JavaScript's `String.prototype.trim`, which removes U+FEFF but not U+0085. It then accepts the value where the standard parses it as an absolute URL, whichever Node.js version runs the server. A host that is ASCII after percent-decoding is taken whatever its labels hold, so `http://xn--` is a URL. A host with other characters must be valid IDNA, and a host with a forbidden domain code point, or a last label that is a number where the host isn't a valid IPv4 address, is refused `400 invalid_properties`. Marfa stores the value without the whitespace it removed and without any tab, line feed or carriage return. A device judges the same way, with one exception: it refuses a host that holds a code point its own IDNA tables don't assign, which the server takes. +**Marfa judges a `url` value as the WHATWG URL Standard does.** It first trims both ends with JavaScript's `String.prototype.trim`, which removes U+FEFF but not U+0085. It then accepts the value where the standard parses it as an absolute URL, whichever Node.js version runs the server. For a URL with a special scheme, such as `http` or `https`, a host that is ASCII after percent-decoding is taken whatever its labels hold, so `http://xn--` is a URL. A host with a character that isn't ASCII must be valid IDNA, so `http://bücher.xn--` is refused. A host with a forbidden domain code point, or a last label that is a number where the host isn't a valid IPv4 address, is refused too. Marfa answers each refusal `400 invalid_properties`. Marfa stores the value without the whitespace it removed and without any tab, line feed or carriage return. A device judges the same way, with one exception: it refuses a special-scheme URL whose host holds a code point its own IDNA tables don't assign, whether or not the server takes the value. diff --git a/content/docs/guides/authenticate.mdx b/content/docs/guides/authenticate.mdx index 8c53d565..ab1d5e1c 100644 --- a/content/docs/guides/authenticate.mdx +++ b/content/docs/guides/authenticate.mdx @@ -17,20 +17,20 @@ On the instance's own machine, `marfa --socket owner passkeys list` lists Passkeys need a supported browser and HTTPS, or localhost for development. **A valid passkey works even when password attempts are locked.** It creates the same owner browser authority used to approve apps and manage the instance; an app still needs its own token to read and write data. -The owner's browser and the private local command can list, rename and end sign-ins at `/owner/sign-ins`. **Keys and app tokens cannot use these operations.** A browser can end its own sign-in at any time. Ending another sign-in, or ending your own with `revoke_keys` set to `true`, needs a sign-in within the last five minutes. +The owner's browser and the private local command can list, rename and end sign-ins at `/owner/sign-ins`. **Keys and app tokens cannot use these operations.** A browser can end its own sign-in at any time. Ending another sign-in needs a sign-in within the last five minutes. `revoke_keys` applies only to an app: naming a browser or a key with `revoke_keys` set to `true` answers `400 validation_error`. ## Confirm it's you **When a change needs a sign-in from the last five minutes and yours is older, the page asks you to confirm it's you.** A dialog titled "Confirm it's you" asks for the owner's password or a passkey. When you confirm, the page sends the change again, and you don't sign in a second time. -Confirming replaces your browser session with a new one created at that moment, and ends the old one. The password counts against the same attempt windows as a password sign-in, so too many wrong passwords answer `429 rate_limited` with `Retry-After`, and a passkey still confirms. A refused confirmation leaves your session as it was. These changes ask for it: +Confirming replaces your browser session with a new one created at that moment, and ends the old one. The password counts against the same attempt windows as a password sign-in. A wrong password answers `401 unauthorized`, and once the windows are spent the answer is `429 rate_limited` with `Retry-After`. A passkey still confirms. A refused confirmation leaves your session as it was. These changes ask for it: - Adding, renaming or removing a passkey. - Changing the owner's name or email on the Profile page. - Revoking a key, disconnecting an app, or signing out another browser. - Approving an app for more than reading, as [When approval needs a recent sign-in](#when-approval-needs-a-recent-sign-in) describes. -A program that makes the same request, with no page to show a dialog, gets `403 forbidden` with the message `Sign in again to perform this operation`, and Marfa changes nothing. `POST /auth/confirm` is the operation behind the dialog: it takes the password or a passkey assertion of the owner who is signed in, and a request with no live browser session, or with a key, is refused `401 unauthorized`. +A program that makes the same request, with no page to show a dialog, gets `403 forbidden` with the message `Sign in again to perform this operation`, and Marfa changes nothing. `POST /auth/confirm` is the operation behind the dialog: it takes the password or a passkey assertion of the owner who is signed in. A program that carries the owner's live browser session can call it with that proof, and a request with no live owner session, or with a key, is refused `401 unauthorized`. [Manage keys](/guides/manage-keys) covers key access, and `marfa sign-ins --help` lists the local sign-in commands. @@ -38,7 +38,7 @@ A program that makes the same request, with no page to show a dialog, gets `403 **Open the instance's address in a browser where you are signed in as the owner, and Home answers.** It is headed by the instance's name and address, and it lists the owner's pages in groups. A page that needs a look appears under Needs attention. Home has a search that finds a page or a setting by name, which Cmd-K on a Mac or Ctrl-K elsewhere also opens, and a Sign out button. The older management page, at `/auth/owner/manage`, still holds what the newer pages don't: server health, housekeeping, signed-in browsers, connected apps, connectors, and a link to restoring an archive. Home is never cached, because it belongs to the owner. -**A browser that isn't signed in is sent to sign in, and back.** Opening an owner page, one under `/auth/owner/`, without a session answers `302` to `/auth/sign-in` with the page you asked for as `return_to`, and signing in returns you there. A page address longer than 2,048 characters returns you to Home instead. On an instance with no owner yet, the redirect goes to `/setup`. A program gets no redirect: a request with an `Authorization` header, or one that doesn't ask for HTML, is answered as before, and `GET /` for a program is still the instance's description. +**A browser that isn't signed in sees a page that says the instance is running**, with a Sign in link, at `/`. **An owner page sends it to sign in, and back.** Opening an owner page, one under `/auth/owner/`, without a session answers `302` to `/auth/sign-in` with the page you asked for as `return_to`, and signing in returns you there. A page address longer than 2,048 characters returns you to Home instead. On an instance with no owner yet, the redirect goes to `/setup`. A program gets no redirect: a request with an `Authorization` header, or one that doesn't ask for HTML, is answered as before, and `GET /` for a program is still the instance's description. ## Change the owner's email or name @@ -304,9 +304,9 @@ Until the owner decides, Marfa answers `400 authorization_pending`, and `400 slo A control for an item type shows the type's identifier under its label when two types share a label, such as `core.task` and `aic.ticket.task`, which are both "Task". -**An app that asks for all data with `*:read` or `*:write` can be given types instead.** The screen then offers Choose types beside the levels, with one control for each registered item type. The token holds exactly the types the owner turned on, such as `core.note:read core.task:read`, and not `*:read`, so read the approved `scope` in the token response. Turning on no type is declining. +**An app that asks for all data with `*:read` or `*:write` can be given types instead.** The screen then offers Choose types beside the levels, with one control for each registered item type that the app's registration can request by name. It leaves out types under `system` and `marfa`, and types the app already holds in full. The token holds the types the owner turned on, such as `core.note:read core.task:read`, and not `*:read`, so read the approved `scope` in the token response. Turning on no type is declining. -Marfa grants what the owner chose, the reads that a write includes, and the scopes the app already held that it still holds when the owner approves. A form that posts a scope the request didn't name is refused `400`, and nothing is granted. **Approving with nothing newly chosen is declining**: the app gets `access_denied` and keeps what it held. +Marfa grants what the owner chose, the reads that a write includes, and the scopes the app already held that it still holds when the owner approves. A form that posts a scope the request didn't name is refused `400`, and nothing is granted. **Approving with none of the newly requested access turned on is declining.** When the request names access beyond `openid` and `offline_access` that the app doesn't already hold, the app gets `access_denied` and keeps what it held. A request of only `openid` and `offline_access` offers nothing to turn off, and the owner can approve it. ## When approval needs a recent sign-in @@ -316,7 +316,7 @@ To approve write access, or any of the [permissions](/catalog/permissions) the a The page asks the owner to [confirm it's them](#confirm-its-you) when they press Approve, and approves in the same step: -- **The owner opens a screen that asks for more than reading.** The screen carries the "Confirm it's you" dialog whatever the age of the sign-in, because the owner may leave the screen open past the five minutes. The dialog opens when the owner presses Approve and the sign-in is older than that. A request that only reads has no dialog. +- **The owner opens a screen that asks for more than reading and more than the app already holds.** The screen carries the "Confirm it's you" dialog whatever the age of the sign-in, because the owner may leave the screen open past the five minutes. The dialog opens when the owner presses Approve and the sign-in is older than that. A request that asks for nothing beyond reading, or beyond what the app holds, has no dialog. - **A request without a page.** A request whose `Accept` header doesn't prefer `text/html` is refused `403 forbidden`, and Marfa issues no code. The app does nothing differently. An app that uses the authorization code grant waits for the redirect, and an app that uses the device grant keeps polling and gets `authorization_pending` until the owner decides. [Keys and sign-in](https://github.com/withmarfa/marfa/blob/main/conformance/spec/keys-and-oauth.md#keys-and-oauthconsent-older-reads) holds the exact rules, starting with `keys-and-oauth/consent-older-reads`. diff --git a/content/docs/guides/connect-a-web-page.mdx b/content/docs/guides/connect-a-web-page.mdx index a031f386..74825887 100644 --- a/content/docs/guides/connect-a-web-page.mdx +++ b/content/docs/guides/connect-a-web-page.mdx @@ -108,7 +108,7 @@ authorize.search = new URLSearchParams({ open(authorize, "marfa-approval", "popup,width=480,height=640"); ``` -The window asks the owner to sign in if they need to. It then shows the page's name, a caution that Marfa has not verified it, **the page's origin**, and a choice for each thing it asks for, as [What the owner chooses](/guides/authenticate#what-the-owner-chooses) describes. If the page asks for more than reading and the owner signed in more than five minutes ago, the owner confirms it's them when they approve. The owner can turn off or lower any choice. Approving allows that origin to call the instance from a browser, as the next section explains. +The window asks the owner to sign in if they need to. It then shows the page's name, a caution that Marfa has not verified it, **the page's origin**, and a choice for each thing it asks for, as [What the owner chooses](/guides/authenticate#what-the-owner-chooses) describes. If the page asks for more than reading and the owner signed in more than five minutes ago, the owner confirms it's them when they approve. The owner can turn off or lower any access the page newly asks for. Access the page already holds stays as it is, and the window offers no level below it. Approving allows that origin to call the instance from a browser, as the next section explains. diff --git a/content/docs/guides/manage-keys.mdx b/content/docs/guides/manage-keys.mdx index 41d2d172..b69c0101 100644 --- a/content/docs/guides/manage-keys.mdx +++ b/content/docs/guides/manage-keys.mdx @@ -177,7 +177,7 @@ Content-Type: application/json A key holding `keys.mint` changes only the keys that hold nothing it doesn't. A key holding `keys.manage` reaches every key, but can only take access away. -**An update can say it must only narrow.** Add `"narrow_only": true` to `PATCH /keys/{id}`, and Marfa refuses `403 forbidden` any change that would give the key more, whoever sends it: a permission, a map entry or a claimed source it doesn't hold, a later `expires_at` or none for a key that has one, or an `enforcement_override` other than its own. A key that an app made, and a key changed by a caller holding `keys.manage`, are always held to this. The API keys page sends `narrow_only` with every save, so a page opened before another change can't undo it. +**An update can say it must only narrow.** Add `"narrow_only": true` to `PATCH /keys/{id}`, and Marfa refuses `403 forbidden` any change that would give the key more, whoever sends it: a permission, a map entry or a claimed source it doesn't hold, a later `expires_at` or none for a key that has one, or an `enforcement_override` other than its own. The limits on access and on `expires_at` also apply, without `narrow_only`, to a key that an app made and to a key changed by a caller holding `keys.manage`. The limit on `enforcement_override` applies only with `narrow_only`. The API keys page sends `narrow_only` with every save, so a page opened before another change can't undo it. ## Revoke a key diff --git a/content/docs/guides/receive-webhooks.mdx b/content/docs/guides/receive-webhooks.mdx index 1a575699..5bb2d662 100644 --- a/content/docs/guides/receive-webhooks.mdx +++ b/content/docs/guides/receive-webhooks.mdx @@ -202,9 +202,9 @@ await marfa.POST("/webhooks/{id}/deliveries/{delivery_id}/redeliver", { ## Change what a webhook sends -[Update a webhook](/api-reference/webhooks/updateWebhook) changes its `url`, its `events`, its `type_filter` or whether it is `active`. **A change settles the deliveries that wait for a retry before it answers**, so a receiver is never sent an event the owner has since excluded. A delivery being sent when the change commits ends the same way. +[Update a webhook](/api-reference/webhooks/updateWebhook) changes its `url`, its `events`, its `type_filter` or whether it is `active`. **A change ends the pending deliveries it no longer allows**, so a receiver is never sent an event the owner has since excluded. -- **Narrowing `events` or `type_filter`** settles each pending delivery they no longer select as `canceled`, with the error `The subscription's events or type filter no longer select this event.` Widening them again revives nothing already settled, and a change that still selects an event settles nothing. +- **Narrowing `events` or `type_filter`** settles each pending delivery they no longer select as `canceled`, with the error `The subscription's events or type filter no longer select this event.`, before the update answers. A delivery being sent when the change commits ends the same way. Widening them again revives nothing already settled, and a change that still selects an event settles nothing. - **Changing the `url` or setting `active` to `false`** settles every pending delivery as `canceled`, so none goes to the old address. ## Receive deliveries on a private network diff --git a/content/docs/guides/sync-a-folder.mdx b/content/docs/guides/sync-a-folder.mdx index 5f7aa879..4055ea09 100644 --- a/content/docs/guides/sync-a-folder.mdx +++ b/content/docs/guides/sync-a-folder.mdx @@ -210,7 +210,7 @@ A walking tour of the old harbor. The file took its type from the folder's settings, its title from its name, and its `marfa_id` once the instance created the item. A `.txt` file is all body. Any other file the search admits, such as a photo, becomes a file item, with its bytes uploaded as a blob, as [Handle files](/guides/handle-files) describes. -**A file item keeps its bytes, whatever its name.** A file item that sits at a name ending in `.md`, `.markdown` or `.txt`, is sent as bytes however they begin: Marfa reads no frontmatter in it, never flags it `unreadable` or `encoding`, and keeps no text copy of its bytes in the folder's store, so a very large file doesn't need memory for one. A file at such a name that is empty waits until it holds bytes, and the other files in the scan still go. A file that isn't bound to a file item is read as a document, as above. +**A file bound to a file item keeps its bytes, whatever its name.** When a file is bound to a file item and sits at a name ending in `.md`, `.markdown` or `.txt`, Marfa sends its bytes however they begin: it reads no frontmatter in it and never flags it `unreadable` or `encoding`. If the file is empty, it waits until it holds bytes, and the other files in the scan still go. A file at such a name that isn't bound to a file item, a new file included, is read as a document, as above. ## Edits on either side @@ -261,7 +261,7 @@ A scan that cannot read or identify an entry is incomplete. **It does not treat Each directory has its own working copy and queue, recorded in the machine's folder registry, together with an identity of the directory it admitted. **Adding a folder inside a registered folder, or around one, is refused.** -**A folder stays listed when another directory takes its place.** If a restore or a sync tool replaces the directory with another, empty at first, Marfa keeps the folder's entry and doesn't read the new directory as the folder. When the original directory returns to its path, a watch of every folder carries on with it, and you don't add the folder again. Marfa removes an entry only when the admitted directory itself holds no folder state. `marfa folders list --json` gives each entry a `root`, an opaque value to compare for equality and not to parse. +**A folder stays listed when another directory takes its place.** If a restore or a sync tool replaces the directory with another, empty at first, Marfa keeps the folder's entry and doesn't read the new directory as the folder. When the original directory returns to its path, a watch of every folder carries on with it, and you don't add the folder again. Marfa removes an entry when the directory at its path holds no folder state and isn't known to differ from the one admitted. `marfa folders list --json` gives each entry a `root`, an opaque value to compare for equality and not to parse. A scan also leaves another folder's state directory and contents alone. If the registry cannot be read, deletion waits when the device cannot rule out a file having moved. diff --git a/content/docs/guides/troubleshooting.mdx b/content/docs/guides/troubleshooting.mdx index 40157943..85ba2ad2 100644 --- a/content/docs/guides/troubleshooting.mdx +++ b/content/docs/guides/troubleshooting.mdx @@ -118,7 +118,7 @@ Give the key that grant, or use a key that holds it. [Manage keys](/guides/manag The owner's browser signed in more than five minutes ago, and the operation needs a recent sign-in. Revoking a key, disconnecting an app, changing the profile or a passkey, and approving write access or a permission for an app are such operations. Marfa changes nothing. -A page in the owner's console handles this itself: it asks you to confirm it's you with the password or a passkey, then sends the change again. You see the message only from a program that makes the request, or when the confirmation is refused. A program can't confirm: sign in again in a browser, or use the private socket. [Authenticate](/guides/authenticate#confirm-its-you) describes the confirmation, and [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) lists what a browser that signed in earlier can still approve. +A page in the owner's console handles this itself: it asks you to confirm it's you with the password or a passkey, then sends the change again. You see the message only from a program that makes the request, or when the confirmation is refused. A program can call `POST /auth/confirm` only if it carries the owner's live browser session and the owner's password or a passkey assertion. A bearer key, or a request with no live owner session, is refused `401 unauthorized`, so otherwise sign in again in a browser, or use the private socket. [Authenticate](/guides/authenticate#confirm-its-you) describes the confirmation, and [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) lists what a browser that signed in earlier can still approve. From 8c0e725a16542a3ca270c6ce6ea43ec376326ff2 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Wed, 21 Oct 2026 00:08:33 +0100 Subject: [PATCH 09/10] docs(swift): name failing_alone, the queue's last failure and exact whole numbers --- content/docs/(reference)/swift-sdk.mdx | 19 +++++++++++++++++++ content/docs/guides/swift-app.mdx | 8 ++++++++ 2 files changed, 27 insertions(+) diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index ea725ae8..74109d03 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -21,6 +21,8 @@ The SDK supports iOS 27 and macOS 27, and builds with Xcode 27 on a Mac with App Replace `` with the version of the [latest release](https://github.com/withmarfa/marfa-swift/releases/latest). Every release adds 0.0.1 to the version, whatever it changes, so pin one version and move to the next on purpose. +**An added enum case breaks an exhaustive switch** that has no default. A switch over `BlockedReason` must handle `.failingAlone` as well as the other cases, or provide a default. + Each release carries the core already built, so your app needs no Rust toolchain. The core is built for Apple silicon only, so in your app target's build settings, set Excluded Architectures for Any iOS Simulator SDK to `x86_64`. Without it, some simulator builds fail to link. ## Open a working copy @@ -86,6 +88,10 @@ _ = try await copy.items.update(id, Edit(properties: ["title": "Books to read"], Each write shows in the copy at once, with or without a connection, and waits in the queue. An edit changes the properties it names and keeps the rest. With `replaceProperties: true`, its properties become the item's whole set, so that's how you remove a property. +If Marfa would serve a whole number back with other digits, the write throws `MarfaError.validation` with code `invalid_properties` **before anything is queued**. This includes `.integer(9_007_199_254_740_993)`, even inside an array or object, in an edge's properties or in an extension's data. + +Send a value that must keep every digit as a string. [Field kinds](/catalog/field-kinds) gives the number rule. + ## Read the bin `copy.items.bin(type:after:limit:)` reads **the server's bin online**, independently of the copy's slice, and adds none of its items to the copy. Follow `nextCursor` even when a page is short or empty: @@ -180,6 +186,19 @@ updateItem accepted A refused write that carried content stays even then, so your app can offer the content back, until you call `queue.discard(_:)`. [Devices](/devices#verdicts) explains each verdict. +**`BlockedReason.failingAlone`** means the write keeps meeting a failure while the server answers other writes. It stays blocked until you release or withdraw it. Dependent writes have `waiting` set to `true`, with no verdict. + +The queue exposes what the write met: + +| Property | Meaning | +| --- | --- | +| `QueuedWrite.environmentalStreak` | The number of drains in a row in which the write met a failure that was no answer to it. A `429` adds nothing; a gap over 15 minutes starts the count at 1 again. | +| `QueuedWrite.lastFailure` | An optional `LastFailure` describing the last such failure. A `429` updates it without increasing the streak. Failures in credential renewal or the device's own storage leave it unchanged. | +| `LastFailure.status` | The optional HTTP status, including a gateway's answer or a `429`. `nil` means no status was kept: headers may have arrived before reading the body failed. | +| `LastFailure.text` | The start of a text body, with whitespace collapsed, cut to 200 Unicode scalar values and followed by `...` if cut. `nil` for an empty body or one that isn't text. | + +An answer to the write or a refusal clears the streak and failure details. Setting it aside as `.failingAlone` keeps the failure details for your app to show. Releasing the write clears both at once, before the next answer. + ## Keep a view current A `LiveQuery` reads from the working copy and reads again whenever the copy changes, so a SwiftUI view that reads its `value` stays current with no refresh code. Create one on the main actor, in the model that owns the copy, and give it a `Reading`: diff --git a/content/docs/guides/swift-app.mdx b/content/docs/guides/swift-app.mdx index 35dc0244..64451dcf 100644 --- a/content/docs/guides/swift-app.mdx +++ b/content/docs/guides/swift-app.mdx @@ -220,6 +220,8 @@ Add the functions that write, and the one that sends the queue: `items.create` and `items.update` **return at once**: the change shows in the working copy, and the write waits in the queue. An `Edit` carries the version of the note the app read, so the instance can tell whether someone else changed it since, and it changes only the title. +If you add numeric properties, **a whole number Marfa would serve back with other digits throws `MarfaError.validation` with code `invalid_properties`**, before it queues the write. Send a value that must keep every digit as a string; `handle(_:)` shows the validation message. + `send()` drains the queue: it sends the writes in order and reports what the instance answered. If the server can't be reached, the report says so in `unavailable`, and **the writes stay queued for later**. Each answered write has a verdict, and the app tells you about the three that change what you see: `merged`, `conflicted` and `refused`. Then `forgetAnswered()` clears the answered writes, so `unsent` holds only what's still to send or needs attention. `apply(_:)` reads a report, so the next step can reuse it for the drains the follow runs. @@ -406,6 +408,12 @@ struct NoteRow: View { Press Command-B to build. The project builds for both My Mac and the iOS simulators. +**A write blocked as `BlockedReason.failingAlone` needs attention**: it keeps failing while the server answers other writes. `describe(_:)` shows its reason. Release the write to try again, or withdraw it. + +To show what happened, read `QueuedWrite.lastFailure`, an optional `LastFailure` with `status` and `text`; `environmentalStreak` counts the qualifying drains. The [Swift SDK reference](/swift-sdk#keep-the-copy-in-step) gives their meaning and when they clear. + +If you replace the reason's text with a switch over `BlockedReason`, handle `.failingAlone`: **an added enum case breaks an exhaustive switch** without a default. + From 81b38dfa36dd6b58b026d72b8bb44d1eaff5cb00 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Wed, 21 Oct 2026 00:12:13 +0100 Subject: [PATCH 10/10] docs(release): link the contract for approval, consent and queue rules --- content/docs/(overview)/access.mdx | 2 +- content/docs/(overview)/devices.mdx | 6 ++++-- content/docs/(reference)/changelog.mdx | 4 ++-- content/docs/(reference)/cli.mdx | 4 ++-- content/docs/(reference)/swift-sdk.mdx | 2 +- content/docs/guides/authenticate.mdx | 19 ++++++++----------- content/docs/guides/connect-a-web-page.mdx | 2 +- content/docs/guides/troubleshooting.mdx | 4 ++-- 8 files changed, 21 insertions(+), 22 deletions(-) diff --git a/content/docs/(overview)/access.mdx b/content/docs/(overview)/access.mdx index 3f056dbe..56b5ed38 100644 --- a/content/docs/(overview)/access.mdx +++ b/content/docs/(overview)/access.mdx @@ -55,6 +55,6 @@ Every key has a source of its own, chosen when it's made, such as `laptop` or `t **You make a key** as the owner, or with a key that holds the `keys.mint` permission, and say what it holds. A key made by another key can hold only the permissions and maps its maker holds, and a key can be set to expire. **The maker's `enforcement_override` isn't carried over**: unless the new key names one, it follows the instance's levers ([Settings](/guides/settings) describes them), so it can list items that its maker's levers hide. [Manage keys](/guides/manage-keys) shows how to make, narrow and revoke keys. -**An app is never given a key.** It registers itself with the instance and sends the owner to approve it, as when you sign in to an app with your Google account. The owner chooses what to allow for each item type the app asks for, such as `core.note:read`, at the level No access, Read only or Read and edit, and the app's token holds only what the owner chooses. An app that asks for all data can be given chosen types instead. A browser that signed in more than five minutes ago can approve reading only; write access and permissions need a recent sign-in, which the page asks the owner to confirm. The approval stands until the owner revokes it, or until the app goes unused for a year. [Authenticate](/guides/authenticate) shows an app's side of it. +**An app is never given a key.** It registers itself with the instance and sends the owner to approve it, as when you sign in to an app with your Google account. The owner chooses what to allow for each item type the app asks for, such as `core.note:read`, at the level No access, Read only or Read and edit, and the app's token holds only what the owner chooses. An app that asks for all data can be given chosen types instead. Approving a write or a permission that the app doesn't already hold needs a sign-in from the last five minutes, which the page asks the owner to confirm. The approval stands until the owner revokes it, or until the app goes unused for a year. [Authenticate](/guides/authenticate) shows an app's side of it. The rules behind this page are in the [keys and sign-in](https://github.com/withmarfa/marfa/blob/main/conformance/spec/keys-and-oauth.md), [authentication](https://github.com/withmarfa/marfa/blob/main/conformance/spec/authentication.md), [owner counts and exports](https://github.com/withmarfa/marfa/blob/main/conformance/spec/search-and-filters.md#search-and-filtersowner-count-export), and [instance claim](https://github.com/withmarfa/marfa/blob/main/conformance/spec/instance-claim.md) chapters of the contract. diff --git a/content/docs/(overview)/devices.mdx b/content/docs/(overview)/devices.mdx index ee3e4f72..728e18b9 100644 --- a/content/docs/(overview)/devices.mdx +++ b/content/docs/(overview)/devices.mdx @@ -48,9 +48,11 @@ Each write the instance answers gets one verdict: A `blocked` write has one of six reasons: `credential_refused`, `key_spent`, `ancestor_unavailable`, `conflict_unresolved`, `awaiting_dependency` and `failing_alone`. -**A device sets a write aside as `failing_alone` only when the server shows it can take other writes.** A gateway can refuse one write in the words of an outage, such as an HTML `413` page for a large upload, and that write would otherwise end every drain and hold back the writes behind it. A write that meets such a failure in three drains in a row, the first at least five minutes before the third and no two more than 15 minutes apart, becomes a suspect. The device then sends another write that doesn't depend on the suspect. If the server answers it in any way that isn't such a failure, a refusal included, the device sends the suspect once more in the same drain. If that fails again, the device blocks the suspect as `failing_alone` and carries on with the rest of the queue. Writes that depend on it wait as `awaiting_dependency`. A `429` neither counts toward the three nor sets a write aside. A device that drains less often than every 15 minutes never sets a write aside this way. Releasing the write clears its count and gives it a fresh try, and withdrawing it refuses the writes that wait on it. +**A device sets a write aside as `failing_alone` when it keeps failing in the words of an outage while the server still answers other writes.** A gateway can refuse one write in those words, such as an HTML `413` page for a large upload, and that write would otherwise end every drain and hold back the writes behind it. After the write has failed in several drains over some minutes, the device tests it: it sends another write that doesn't depend on it and, if the server answers in a way that lets the device go on, sends the failing write once more. If that fails in the same way, the device blocks the write as `failing_alone` and carries on with the rest of the queue, and the writes that depend on it wait as `awaiting_dependency`. The exact rules are in the contract: [`queue-and-verdicts/stuck-write-suspect`](https://github.com/withmarfa/marfa/blob/main/conformance/spec/queue-and-verdicts.md#queue-and-verdictsstuck-write-suspect) for when a write becomes a suspect, and [`queue-and-verdicts/stuck-write-test-refused`](https://github.com/withmarfa/marfa/blob/main/conformance/spec/queue-and-verdicts.md#queue-and-verdictsstuck-write-test-refused) for an answer that ends the test. -When a write has met such a failure, the queue shows the last answer: `marfa device queue` prints `last answer: HTTP : `, with the start of the body if it is text, cut to 200 characters on one line. +Releasing the write gives it a fresh try, and withdrawing it refuses the writes that wait on it. + +The queue shows the last answer a write met in this way. `marfa device queue` prints it after `last answer:`, as `HTTP ` with the start of the body when the body is text, or `no answer`. ## Folders diff --git a/content/docs/(reference)/changelog.mdx b/content/docs/(reference)/changelog.mdx index 4e0d99d5..5eb661ad 100644 --- a/content/docs/(reference)/changelog.mdx +++ b/content/docs/(reference)/changelog.mdx @@ -23,7 +23,7 @@ For running an instance: For apps and devices: -- **The owner chooses what an app gets.** The approval screen lists each type with No access, Read only or Read and edit, then the owner's profile and the powers over Marfa that are off until the owner turns them on. Three powers that can't be undone are marked. Access the app already holds shows as Already allowed. A request for all of an app's reads or writes shows Choose types, and the token then names the types the owner picked, not all data. If the app asks for more than `openid` and `offline_access` and the owner turns none of it on, the app gets `access_denied` and keeps what it held. A request of only those two can be approved. [What the owner chooses](/guides/authenticate#what-the-owner-chooses) covers it. +- **The owner chooses what an app gets.** The approval screen lists each type with No access, Read only or Read and edit, then the owner's profile and the powers over Marfa that are off until the owner turns them on. Three powers that can't be undone are marked. Access the app already holds shows as Already allowed. A request for all data also shows Choose types, and if the owner picks types the token names them instead of all data. If the request asks for access that the app doesn't already hold and the owner turns all of it off, the app gets `access_denied` and keeps what it held. A request for nothing but `openid` and `offline_access` offers nothing to turn off, so the owner can approve it. [What the owner chooses](/guides/authenticate#what-the-owner-chooses) covers it. - **A device code is easier to type.** The device page shows a code as `XXXX-XXXX`, accepts it in any case with or without the hyphen, and a code ends after 10 minutes. - **Approving or revoking asks to confirm in a dialog, not a redirect.** `POST /auth/confirm` takes the owner's password or a passkey and replaces the session, so the browser carries on at the same page. A password counts against the attempt windows: a wrong one answers `401 unauthorized`, and once the windows are spent the answer is `429 rate_limited` with `Retry-After`. A refused confirmation keeps the session. A program that calls a route that needs a recent sign-in gets `403 forbidden` with "Sign in again to perform this operation". `POST /auth/confirm` answers `401` to a request with no live owner session or with a key. A browser may end its own sign-in at any time. [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) covers it. - **`narrow_only` keeps a key from gaining access.** `PATCH /keys/{id}` with `narrow_only: true` refuses a change that adds access, and the API keys pages send it. `POST /keys` answers `Cache-Control: no-store`, so the secret isn't cached. [Narrow a key](/guides/manage-keys#narrow-a-key) covers it. @@ -31,7 +31,7 @@ For apps and devices: - **A tag of `.` or `..` is refused** with `400 validation_error`, as an empty, blank or over-long tag is. A restore still accepts what an archive holds. - **Narrowing a webhook cancels the deliveries it no longer selects.** Pending deliveries that the narrowed subscription doesn't select end `canceled`. Changing the `url` or setting `active: false` cancels all of them, and widening revives none. [Change what a webhook sends](/guides/receive-webhooks#change-what-a-webhook-sends) covers it. - **The CLI prints access in the words the approval page uses.** `marfa login`, `whoami`, and `keys list`, `current`, `create` and `update` print No access, Read only and Read and edit for each type, and name powers by their titles. `--json` is unchanged. [CLI](/cli#check-what-a-command-will-use) covers it. -- **A write that fails alone is set aside.** A device marks a queued write `blocked` with the reason `failing_alone` when it fails in three drains in a row, the first at least five minutes before the third and no two more than 15 minutes apart, an independent write then gets a non-failure answer (a refusal counts), and the write fails again when sent once more in that drain. A `429` neither counts nor sets a write aside. Its dependents wait as `awaiting_dependency`. `marfa device queue` prints the last answer the server gave, as `last answer: HTTP : `. [Devices](/devices#verdicts) covers it. +- **A write that fails alone is set aside.** A device marks a queued write `blocked` with the reason `failing_alone` when it keeps failing in the words of an outage, over some minutes, while the server still answers other writes. It tests the write first by sending another write that doesn't depend on it, and sets the write aside only if it fails in the same way when sent once more. Its dependents wait as `awaiting_dependency`. `marfa device queue` prints the last answer the write met after `last answer:`. [Devices](/devices#verdicts) covers it, and [`queue-and-verdicts/stuck-write-suspect`](https://github.com/withmarfa/marfa/blob/main/conformance/spec/queue-and-verdicts.md#queue-and-verdictsstuck-write-suspect) has the exact rule. - **A file item keeps its bytes at a document name.** A file already bound to a file item, at a name ending `.md`, `.markdown` or `.txt`, is sent as bytes whatever they hold, with no frontmatter read. If it is empty it waits, and the rest of the scan goes on. A file at such a name that isn't bound to a file item is still read as a document. A `marfa_id` over 256 bytes is no id. - **`--no-keychain` keeps the CLI off the keychain.** `--no-keychain` or `MARFA_NO_KEYCHAIN=1` makes every command treat the keychain as absent, and `marfa login --print-token` no longer reads or writes it. A command that sends a key from `--key` or `MARFA_API_KEY` and has no server named is refused `no_server`. [CLI](/cli#run-without-the-keychain) covers it. - **The approval rows of two types with the same label are told apart.** When two item types share a label, such as `core.task` and `aic.ticket.task`, each shows its identifier under the label, and the type search matches the identifier. diff --git a/content/docs/(reference)/cli.mdx b/content/docs/(reference)/cli.mdx index 0690346d..9cef1e6d 100644 --- a/content/docs/(reference)/cli.mdx +++ b/content/docs/(reference)/cli.mdx @@ -41,7 +41,7 @@ These docs follow Marfa's `main` branch, which can be ahead of the latest releas A command that talks to an instance needs the server's address and a credential. **The CLI can keep both for you**, so a bare command such as `marfa status` knows where to go. There are two ways to give it a credential. -**Sign in as an app** with `marfa login`. The CLI shows a code and opens a page on the server, where you sign in as the owner and approve what it asks for. The CLI asks for more than reading, so if you signed in more than five minutes ago and haven't approved the CLI for those scopes before, the page asks you to confirm it's you when you approve. It then keeps the app's token in the keychain: +**Sign in as an app** with `marfa login`. The CLI shows a code and opens a page on the server, where you sign in as the owner and approve what it asks for. If you turn on access beyond reading that the CLI doesn't already hold, and you signed in more than five minutes ago, the page asks you to confirm it's you when you approve. It then keeps the app's token in the keychain: ```bash marfa login --url https://marfa.example.com @@ -63,7 +63,7 @@ signed in to https://marfa.example.com; the token is in the keychain Powers: See and disconnect other apps ``` -The CLI asks for every scope an owner can grant, the permissions included, so the page offers each of them and you turn on the ones you want. `marfa logout` revokes the token and forgets it. +The CLI asks for the scopes in `OWNER_SCOPES` in [its source](https://github.com/withmarfa/marfa/blob/main/core/marfa-cli/src/auth.rs): all data, all edges, metadata, the permissions and the sign-in scopes. The page offers those, and you turn on the ones you want. `marfa logout` revokes the token and forgets it. Or **keep a key** you already have with `marfa keys keep`. It reads the key from `MARFA_API_KEY`, or, if that isn't set, from standard input, so you can paste it and press Return. It checks the key with the server, then keeps it in the keychain. diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index 74109d03..46cc2a0d 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -192,7 +192,7 @@ The queue exposes what the write met: | Property | Meaning | | --- | --- | -| `QueuedWrite.environmentalStreak` | The number of drains in a row in which the write met a failure that was no answer to it. A `429` adds nothing; a gap over 15 minutes starts the count at 1 again. | +| `QueuedWrite.environmentalStreak` | The number of drains in a row in which the write met a failure that was no answer to it. [`queue-and-verdicts/streak-counted`](https://github.com/withmarfa/marfa/blob/main/conformance/spec/queue-and-verdicts.md#queue-and-verdictsstreak-counted) and [`queue-and-verdicts/streak-run-restarts`](https://github.com/withmarfa/marfa/blob/main/conformance/spec/queue-and-verdicts.md#queue-and-verdictsstreak-run-restarts) say what counts and when the count starts again. | | `QueuedWrite.lastFailure` | An optional `LastFailure` describing the last such failure. A `429` updates it without increasing the streak. Failures in credential renewal or the device's own storage leave it unchanged. | | `LastFailure.status` | The optional HTTP status, including a gateway's answer or a `429`. `nil` means no status was kept: headers may have arrived before reading the body failed. | | `LastFailure.text` | The start of a text body, with whitespace collapsed, cut to 200 Unicode scalar values and followed by `...` if cut. `nil` for an empty body or one that isn't text. | diff --git a/content/docs/guides/authenticate.mdx b/content/docs/guides/authenticate.mdx index ab1d5e1c..5d923925 100644 --- a/content/docs/guides/authenticate.mdx +++ b/content/docs/guides/authenticate.mdx @@ -28,7 +28,7 @@ Confirming replaces your browser session with a new one created at that moment, - Adding, renaming or removing a passkey. - Changing the owner's name or email on the Profile page. - Revoking a key, disconnecting an app, or signing out another browser. -- Approving an app for more than reading, as [When approval needs a recent sign-in](#when-approval-needs-a-recent-sign-in) describes. +- Approving a write or a permission the app doesn't already hold, as [When approval needs a recent sign-in](#when-approval-needs-a-recent-sign-in) describes. A program that makes the same request, with no page to show a dialog, gets `403 forbidden` with the message `Sign in again to perform this operation`, and Marfa changes nothing. `POST /auth/confirm` is the operation behind the dialog: it takes the password or a passkey assertion of the owner who is signed in. A program that carries the owner's live browser session can call it with that proof, and a request with no live owner session, or with a key, is refused `401 unauthorized`. @@ -271,7 +271,7 @@ client_id=&scope=core.note%3Aread%20offline_access } ``` -Show the owner the `user_code` and `verification_uri`, or open `verification_uri_complete` for them. They sign in there, enter the code if it wasn't in the address, and choose what to allow on the same controls as the Approve access screen, [with a recent sign-in](#when-approval-needs-a-recent-sign-in) for anything beyond reading. The code page takes the code in any case, with or without a hyphen after the fourth character, as `LFMC-H9Q4` or `LFMCH9Q4`, and says that codes end after 10 minutes. +Show the owner the `user_code` and `verification_uri`, or open `verification_uri_complete` for them. They sign in there, enter the code if it wasn't in the address, and choose what to allow on the same controls as the Approve access screen, under the [same rule for a recent sign-in](#when-approval-needs-a-recent-sign-in). The code page takes the code in any case, with or without a hyphen after the fourth character, as `LFMC-H9Q4` or `LFMCH9Q4`, and says that codes end after 10 minutes. **Keep the request explicit:** omitting `scope`, or sending only whitespace, is refused with `400 invalid_scope` before a code is issued. Read the approved `scope` in the token response; it can be narrower than you asked for. @@ -306,20 +306,17 @@ A control for an item type shows the type's identifier under its label when two **An app that asks for all data with `*:read` or `*:write` can be given types instead.** The screen then offers Choose types beside the levels, with one control for each registered item type that the app's registration can request by name. It leaves out types under `system` and `marfa`, and types the app already holds in full. The token holds the types the owner turned on, such as `core.note:read core.task:read`, and not `*:read`, so read the approved `scope` in the token response. Turning on no type is declining. -Marfa grants what the owner chose, the reads that a write includes, and the scopes the app already held that it still holds when the owner approves. A form that posts a scope the request didn't name is refused `400`, and nothing is granted. **Approving with none of the newly requested access turned on is declining.** When the request names access beyond `openid` and `offline_access` that the app doesn't already hold, the app gets `access_denied` and keeps what it held. A request of only `openid` and `offline_access` offers nothing to turn off, and the owner can approve it. +Marfa grants what the owner turned on, the reads that a write includes, and the scopes the request names that the app still holds when the owner approves. A form can only narrow the request: one that posts a scope the request didn't name is refused `400` and grants nothing, with the types chosen in place of all data as the exception, such as `core.note:read` under `*:read`. **Approving is declining when the request asks for access beyond `openid` and `offline_access` that the app doesn't already hold, and the owner turns all of that off.** The app then gets `access_denied` and keeps what it held. A request for nothing more than those two offers nothing to turn off, and the owner can approve it. -## When approval needs a recent sign-in - -**A browser that signed in more than five minutes ago can approve reading only.** That covers the Approve access screen and the device approval page alike. The scopes that read are `:read` for an item type or a pattern of types, `edge.:read` for an edge type or a pattern of edge types, `metadata:read`, `metadata.:read`, `content:read`, `profile:read`, `profile.:read`, `openid`, `profile`, `email` and `offline_access`. +The exact rules are in the contract: [`keys-and-oauth/approval-beyond-shown`](https://github.com/withmarfa/marfa/blob/main/conformance/spec/keys-and-oauth.md#keys-and-oauthapproval-beyond-shown) and [`keys-and-oauth/approval-types-beyond`](https://github.com/withmarfa/marfa/blob/main/conformance/spec/keys-and-oauth.md#keys-and-oauthapproval-types-beyond) for what a form may post, [`keys-and-oauth/approval-held-now`](https://github.com/withmarfa/marfa/blob/main/conformance/spec/keys-and-oauth.md#keys-and-oauthapproval-held-now) for the held scopes, and [`keys-and-oauth/consent-nothing-denies`](https://github.com/withmarfa/marfa/blob/main/conformance/spec/keys-and-oauth.md#keys-and-oauthconsent-nothing-denies) for when approving is declining. -To approve write access, or any of the [permissions](/catalog/permissions) the app doesn't already hold, the owner must have signed in within the last five minutes. **A scope the owner approved for the app before needs no new sign-in.** +## When approval needs a recent sign-in -The page asks the owner to [confirm it's them](#confirm-its-you) when they press Approve, and approves in the same step: +**Approving can need a sign-in from the last five minutes.** Whether it does depends on the access the owner turns on that the app doesn't already hold, not on what the app asked for: turning on only reading never needs one, and a write or a permission the app doesn't already hold does. The Approve access screen and the device approval page follow the same rule. -- **The owner opens a screen that asks for more than reading and more than the app already holds.** The screen carries the "Confirm it's you" dialog whatever the age of the sign-in, because the owner may leave the screen open past the five minutes. The dialog opens when the owner presses Approve and the sign-in is older than that. A request that asks for nothing beyond reading, or beyond what the app holds, has no dialog. -- **A request without a page.** A request whose `Accept` header doesn't prefer `text/html` is refused `403 forbidden`, and Marfa issues no code. +When the owner's sign-in is older, the page asks them to [confirm it's them](#confirm-its-you) as they press Approve, and approves in the same step. An approval sent without a recent sign-in, as a program might send it, is refused `403 forbidden` with the message `Sign in again to perform this operation`, and Marfa issues no code. -The app does nothing differently. An app that uses the authorization code grant waits for the redirect, and an app that uses the device grant keeps polling and gets `authorization_pending` until the owner decides. [Keys and sign-in](https://github.com/withmarfa/marfa/blob/main/conformance/spec/keys-and-oauth.md#keys-and-oauthconsent-older-reads) holds the exact rules, starting with `keys-and-oauth/consent-older-reads`. +The app does nothing differently. An app that uses the authorization code grant waits for the redirect, and an app that uses the device grant keeps polling and gets `authorization_pending` until the owner decides. [Keys and sign-in](https://github.com/withmarfa/marfa/blob/main/conformance/spec/keys-and-oauth.md#keys-and-oauthconsent-recent-needed) holds the exact rules in `keys-and-oauth/consent-recent-needed` and `keys-and-oauth/consent-older-reads`. ## Revoke access diff --git a/content/docs/guides/connect-a-web-page.mdx b/content/docs/guides/connect-a-web-page.mdx index 74825887..579336cf 100644 --- a/content/docs/guides/connect-a-web-page.mdx +++ b/content/docs/guides/connect-a-web-page.mdx @@ -108,7 +108,7 @@ authorize.search = new URLSearchParams({ open(authorize, "marfa-approval", "popup,width=480,height=640"); ``` -The window asks the owner to sign in if they need to. It then shows the page's name, a caution that Marfa has not verified it, **the page's origin**, and a choice for each thing it asks for, as [What the owner chooses](/guides/authenticate#what-the-owner-chooses) describes. If the page asks for more than reading and the owner signed in more than five minutes ago, the owner confirms it's them when they approve. The owner can turn off or lower any access the page newly asks for. Access the page already holds stays as it is, and the window offers no level below it. Approving allows that origin to call the instance from a browser, as the next section explains. +The window asks the owner to sign in if they need to. It then shows the page's name, a caution that Marfa has not verified it, **the page's origin**, and a choice for each thing it asks for, as [What the owner chooses](/guides/authenticate#what-the-owner-chooses) describes. If the owner turns on access beyond reading that the page doesn't already hold, and signed in more than five minutes ago, the owner confirms it's them when they approve. The owner can turn off or lower any access the page newly asks for. Access the page already holds stays as it is, and the window offers no level below it. Approving allows that origin to call the instance from a browser, as the next section explains. diff --git a/content/docs/guides/troubleshooting.mdx b/content/docs/guides/troubleshooting.mdx index 85ba2ad2..48a48347 100644 --- a/content/docs/guides/troubleshooting.mdx +++ b/content/docs/guides/troubleshooting.mdx @@ -116,9 +116,9 @@ Give the key that grant, or use a key that holds it. [Manage keys](/guides/manag -The owner's browser signed in more than five minutes ago, and the operation needs a recent sign-in. Revoking a key, disconnecting an app, changing the profile or a passkey, and approving write access or a permission for an app are such operations. Marfa changes nothing. +The owner's browser signed in more than five minutes ago, and the operation needs a recent sign-in. Revoking a key, disconnecting an app, changing the profile or a passkey, and approving a write or a permission that an app doesn't already hold are such operations. Marfa changes nothing. -A page in the owner's console handles this itself: it asks you to confirm it's you with the password or a passkey, then sends the change again. You see the message only from a program that makes the request, or when the confirmation is refused. A program can call `POST /auth/confirm` only if it carries the owner's live browser session and the owner's password or a passkey assertion. A bearer key, or a request with no live owner session, is refused `401 unauthorized`, so otherwise sign in again in a browser, or use the private socket. [Authenticate](/guides/authenticate#confirm-its-you) describes the confirmation, and [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) lists what a browser that signed in earlier can still approve. +A page in the owner's console handles this itself: it asks you to confirm it's you with the password or a passkey, then sends the change again. You see the message only from a program that makes the request, or when the confirmation is refused. A program can call `POST /auth/confirm` only if it carries the owner's live browser session and the owner's password or a passkey assertion. A bearer key, or a request with no live owner session, is refused `401 unauthorized`, so otherwise sign in again in a browser, or use the private socket. [Authenticate](/guides/authenticate#confirm-its-you) describes the confirmation, and [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) says which approvals need one.