diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index 7ebcbfe9..d21f8164 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -8,5 +8,8 @@ "app/spicedb/concepts/commands/page.mdx", // Autogenerated "app/spicedb/getting-started/installing-zed/page.mdx", + // oxfmt de-indents the list inside , which breaks nextra's MDX + // compile ("expected closing tag "). Leave these hand-formatted. + "app/materialize/concepts/snapshots/page.mdx", ], } diff --git a/app/_meta.ts b/app/_meta.ts index bc2eaffa..c7679d71 100644 --- a/app/_meta.ts +++ b/app/_meta.ts @@ -4,21 +4,33 @@ export default { index: { title: "Documentation", display: "hidden", + theme: { + layout: "full", + toc: false, + sidebar: false, + breadcrumb: false, + pagination: false, + timestamp: false, + copyPage: false, + }, }, spicedb: { - title: "SpiceDB Documentation", + title: "SpiceDB", type: "page", }, authzed: { - title: "AuthZed Product Documentation", + title: "Managed SpiceDB", type: "page", }, - "best-practices": { - title: "Best Practices", + materialize: { + title: "Materialize", type: "page", }, mcp: { title: "MCP", type: "page", }, + changes: { + display: "hidden", + }, } satisfies MetaRecord; diff --git a/app/authzed/concepts/authzed-materialize/page.mdx b/app/authzed/concepts/authzed-materialize/page.mdx deleted file mode 100644 index 0c8a51af..00000000 --- a/app/authzed/concepts/authzed-materialize/page.mdx +++ /dev/null @@ -1,610 +0,0 @@ -import { Callout } from "nextra/components"; - -# AuthZed Materialize - - - AuthZed Materialize is available to users of AuthZed [Dedicated] as part of an early access - program. Don't hesitate to get in touch with your AuthZed account team if you would like to - participate. - - -AuthZed Materialize takes inspiration from the Leopard index component described in the [Zanzibar paper](https://zanzibar.tech/2IoYDUFMAE:0:T). -Much like the concept of a materialized view in relational databases, AuthZed Materialize is a service that you configure with a list of permissions that you want it to precompute, and it will calculate how those permissions change after relationships -are written (specifically, when those relationships affect a subject's membership in a permission set or a set’s permission on a specific resource), or when a new schema is written. -These precomputed permissions can then be used either to provide faster checks and lookups through Accelerated Queries, or streamed to your own application database to do operations like searching, sorting, and filtering much more efficiently. - -In summary, AuthZed Materialize allows you to: - -- Speed up `CheckPermission` and `CheckBulkPermissions`. -- Speed up `LookupResources` and `LookupSubjects`, especially when there is a large number of resources. -- Build authorization-aware UIs, e.g. by providing a filtered and/or sorted list of more than several thousand authorized objects. -- Perform ACL filtering in other secondary indexes, like a search index (e.g. Elasticsearch). - -[Dedicated]: ../guides/picking-a-product#dedicated - -## Limitations - -- Your schema can contain any of the following, but they cannot be on the path of your configured Materialize permissions or it will throw an error: - - [Caveats] - - [Wildcard] subject types - - [.all intersections] - -- [Expiring relationships] aren't supported. -- Materialize takes time to compute the denormalized relationship updates, so if you are streaming the changes to your database, your application must be able to tolerate some lag. - -[Caveats]: https://authzed.com/docs/spicedb/concepts/caveats -[Wildcard]: https://authzed.com/docs/spicedb/concepts/schema#wildcards -[.all intersections]: https://authzed.com/docs/spicedb/concepts/schema#all-intersection-arrow -[expiring relationships]: https://authzed.com/docs/spicedb/concepts/expiring-relationships -[Dedicated]: ../guides/picking-a-product#dedicated - -## Client SDK - -All SpiceDB SDKs have the generated gRPC and protobuf code - -- [authzed-go v0.15.0](https://github.com/authzed/authzed-go/releases/tag/v0.15.0) -- [authzed-java 0.10.0](https://github.com/authzed/authzed-java/releases/tag/0.10.0) -- [authzed-py v0.17.0](https://github.com/authzed/authzed-py/releases/tag/v0.17.0) -- [authzed-rb v0.11.0](https://github.com/authzed/authzed-rb/releases/tag/v0.11.0) -- [authzed-node v0.17.0](https://github.com/authzed/authzed-node/releases/tag/v0.17.0) - -AuthZed Materialize's gRPC API definition is available from [API version 1.35](https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0) - -## Recommended Architecture - -### Consuming Client - -![authzed-materialize](/images/authzed-materialize.png) - -Customers will need to build a client to act as an "event processor" that consumes permission updates and writes those updates to a datastore like Postgres. -The consumer should be designed with resumability in mind by keeping track of the last revision consumed, just as any other stream processor. - -### Durability - -Every SpiceDB permission update will come with a `ZedToken`. -The consumer must keep track of that revision token to be able to resume the change stream from the last event consumed when a failure happens, like stream disconnection, consumer restart, or server-side restarts. - -When a consumer failure happens, the process should determine the last revision `ZedToken` consumed, and send that alongside your request. -The consumer should be coded with idempotency in mind in the event of such failures, meaning it should be prepared to process stream messages that have already been processed. - -Storing the revision `ZedToken` in the same database where the computed permissions are being stored is a good practice as it enables storing those transactionally, which gives you the guarantee that whatever revision the consumer restarts from, won’t cause events to be skipped, which would lead to an inconsistent state of the world. - -There may be scenarios where a revision has so many changes that storing transactionally can degrade the performance/availability of the target database. -In situations like these, one may want to store the events in batches, and in such cases, the revision should only be stored when the consumer determines the last batch has been processed. -If a failure happened in between those batches, the consumer will be able to restart processing from the start of the revision and idempotently overwrite whatever events were already in place. - - - Change events are stored up to 24h to make sure Materialize storage does not grow unbounded and - affect its performance. - - -## Configuration - -Just as with relational database materialized views, you need to provide Materialize with the "queries" you’d like it to pre-compute. -The configuration is described as a list of `resource#permission@subject` tuples. -Example: - -```zed -resource#view@user -resource#edit@user -``` - - - During early access provisioning, Materialize instances are not self-service, so you’ll need to - provide the permissions to be computed by Materialize directly to your AuthZed account team. - - -### Relational Database - -You can find a runnable version of these examples [here](https://dbfiddle.uk/dX10Cu3Z). - -These are tables you likely already have in your database - -1. something representing the user -2. something representing the object we want to filter - -```sql -CREATE TABLE users ( - id varchar(100) PRIMARY KEY, - name varchar(40) -); -CREATE TABLE documents ( - id varchar(100) PRIMARY KEY, - name varchar(40), - contents_bucket varchar(100) -); -``` - -The `member_to_set` and `set_to_set` tables below are just used to track data from [LookupPermissionSets] and [WatchPermissionSets], all you need to do is store the fields directly from those APIs. - -```sql -CREATE TABLE member_to_set ( - member_type varchar(100), - member_id varchar(100), - member_relation varchar(100), - set_type varchar(100), - set_id varchar(100), - set_relation varchar(100) -); - -CREATE TABLE set_to_set ( - child_type varchar(100), - child_id varchar(100), - child_relation varchar(100), - parent_type varchar(100), - parent_id varchar(100), - parent_relation varchar(100) -); -``` - -Seed some base data; this would already exist in the application: - -```sql -INSERT INTO users (id, name) VALUES ('123', 'evan'), ('456', 'victor'); -INSERT INTO documents (id, name) VALUES ('123', 'evan secret doc'), ('456', 'victor shared doc'); -``` - -Sync data from [LookupPermissionSets]/[WatchPermissionSets]. -The APIs return type/id/relation name: - -```sql -INSERT INTO member_to_set (member_type, member_id, member_relation, set_type, set_id, set_relation) - VALUES ('user', '123', '', 'document', '123', 'view'), - ('user', '123', '', 'group', 'shared', 'member'), - ('user', '456', '', 'group', 'shared', 'member'); - -INSERT INTO set_to_set (child_type, child_id, child_relation, parent_type, parent_id, parent_relation) - VALUES ('group', 'shared', 'member', 'document', '456', 'view'); -``` - -To query, join the local application data with [LookupPermissionSets]/[WatchPermissionSets] data to filter by specific permissions. - -Find all documents `evan` can `view:` - -```sql -SELECT d.id FROM documents d - LEFT JOIN set_to_set s2s ON d.id = s2s.parent_id - INNER JOIN member_to_set m2s ON (m2s.set_id = s2s.child_id AND m2s.set_type = s2s.child_type AND m2s.set_relation = s2s.child_relation) OR (d.id = m2s.set_id ) - INNER JOIN users u ON u.id = m2s.member_id - WHERE - u.name = 'evan' AND - m2s.member_type = 'user' AND - m2s.member_relation = '' AND (( - s2s.parent_type = 'document' AND - s2s.parent_relation='view' - ) OR ( - m2s.set_type = 'document' AND - m2s.set_relation = 'view' - )); -``` - -| id | -| :-- | -| 123 | -| 456 | - -The same query, by changing only the username, will find all documents `victor` can `view`: - -```sql -SELECT d.id FROM documents d - LEFT JOIN set_to_set s2s ON d.id = s2s.parent_id - INNER JOIN member_to_set m2s ON (m2s.set_id = s2s.child_id AND m2s.set_type = s2s.child_type AND m2s.set_relation = s2s.child_relation) OR (d.id = m2s.set_id ) - INNER JOIN users u ON u.id = m2s.member_id - WHERE - u.name = 'victor' AND - m2s.member_type = 'user' AND - m2s.member_relation = '' AND (( - s2s.parent_type = 'document' AND - s2s.parent_relation='view' - ) OR ( - m2s.set_type = 'document' AND - m2s.set_relation = 'view' - )); -``` - -| id | -| :-- | -| 456 | - -The above example shows the most flexible way to do this: you can update your SpiceDB schema and sync new permission sets data without SQL schema changes but at the cost of more verbose SQL queries. - -If you know that you only care about `document#view@user,` then you can store the data more concisely and query more simply. -This strategy can also be used to shard the data coming from the Materialize APIs so that it does not all land in one table. - -Simplified permission sets storage (just for `document#view@user`): - -```sql -CREATE TABLE user_to_set ( - user_id varchar(100), - parent_set varchar(300) -); - -CREATE TABLE set_to_document_view ( - child_set varchar(300), - document_id varchar(100) -); -``` - -Storing from [LookupPermissionSets]/[WatchPermissionSets] in this model requires some simple transformations compared to the previous example: - -```sql -INSERT INTO user_to_set (user_id, parent_set) - VALUES ('123', 'document:123#view'), - ('123', 'group:shared#member'), - ('456', 'group:shared#member'); - -INSERT INTO set_to_document_view (child_set, document_id) - VALUES ('document:123#view', '123'), - ('group:shared#member', '456'); -``` - -Note that an extra entry (`document:123#view`, `123`) was added to simplify the join side (avoiding the `left join` in the previous example). -The queries are a bit simpler, though they can't be used to answer any permission check other than `document#view@user`. - -Find all documents `evan` can `view`: - -```sql -SELECT d.id FROM documents d - INNER JOIN set_to_document_view s2s ON d.id = s2s.document_id - INNER JOIN user_to_set m2s ON m2s.parent_set = s2s.child_set - INNER JOIN users u ON u.id = m2s.user_id - WHERE u.name = 'evan'; -``` - -| id | -| :-- | -| 123 | -| 456 | - -Find all documents `victor` can `view`: - -```sql -SELECT d.id FROM documents d - INNER JOIN set_to_document_view s2s ON d.id = s2s.document_id - INNER JOIN user_to_set m2s ON m2s.parent_set = s2s.child_set - INNER JOIN users u ON u.id = m2s.user_id - WHERE u.name = 'victor'; -``` - -| id | -| :-- | -| 456 | - -## API Specification - -### [WatchPermissionSets] - -This is an update stream of all the permissions Materialize is configured to watch. -You can use this to store all permissions tracked in the system closer to your application database to be used in database-native ACL filtering. -Permissions can also be stored in secondary indexes like Elasticsearch. - -The API consists of various event types that capture deltas that occurred since a client started listening. -It will also notify of events like a [breaking schema change] that necessitate rebuilding of the index. - -#### Request - -```json -{ - "optional_starting_after": "the_zed_token" -} -``` - -The `optional_starting_after` field in the request denotes the SpiceDB revision to start streaming changes. -It will start streaming from the revision right after the indicated one. -If no `optional_starting_after` is provided, Materialize will determine the latest revision at the moment of the request, and start streaming changes from there on. - -#### Response - -##### Revision Checkpoint Event - -Sent when changes happened in SpiceDB, but didn't affect Materialize. -Customers should keep track of this revision in their internal database to know where to resume from in the event of stream disconnection or stream consumer restart/failure. - -```json -{ - "completed_revision": { - "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA==" - } -} -``` - -##### Member Added To Set Event - -```json -{ - "change": { - "at_revision": { - "token": "GiAKHjE3MTUzMzkzMDg0MTY2NzUxNzcuMDAwMDAwMDAwMA==" - }, - "operation": "SET_OPERATION_ADDED", - "parent_set": { - "object_type": "thumper/resource", - "object_id": "seconddoc", - "permission_or_relation": "reader" - }, - "child_member": { - "object_type": "thumper/user", - "object_id": "fred", - "optional_permission_or_relation": "" - } - } -} -``` - -##### Member Removed From Set Event - -```json -{ - "change": { - "at_revision": { - "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA==" - }, - "operation": "SET_OPERATION_REMOVED", - "parent_set": { - "object_type": "thumper/resource", - "object_id": "seconddoc", - "permission_or_relation": "reader" - }, - "child_member": { - "object_type": "thumper/user", - "object_id": "fred", - "optional_permission_or_relation": "" - } - } -} -``` - -##### Set Added To Set Event - -```json -{ - "change": { - "at_revision": { - "token": "GiAKHjE3MTUzMzkzMDg0MTY2NzUxNzcuMDAwMDAwMDAwMA==" - }, - "operation": "SET_OPERATION_ADDED", - "parent_set": { - "object_type": "thumper/resource", - "object_id": "seconddoc", - "permission_or_relation": "reader" - }, - "child_set": { - "object_type": "thumper/team", - "object_id": "engineering", - "permission_or_relation": "members" - } - } -} -``` - -##### Set Removed From Set Event - -```json -{ - "change": { - "at_revision": { - "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA==" - }, - "operation": "SET_OPERATION_REMOVED", - "parent_set": { - "object_type": "thumper/resource", - "object_id": "seconddoc", - "permission_or_relation": "reader" - }, - "child_set": { - "object_type": "thumper/team", - "object_id": "engineering", - "permission_or_relation": "members" - } - } -} -``` - -##### [Breaking Schema Change] Event - -When the origin SpiceDB instance introduces a schema change that invalidates all currently computed permission sets, Materialize will issue a special event indicating this happened: - -```json -{ - "breaking_schema_change": { - "change_at": { - "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA==" - } - } -} -``` - -The event indicates the revision at which the schema change happened. - -When the client receives this event, all previously indexed permission sets are rendered stale, and the client must rebuild the index with a call to [LookupPermissionSets] at the revision the schema change was introduced. - -Not every change to the origin permission system schema is considered breaking. - -###### Detecting Breaking Schema Changes In Development Environment - -The AuthZed team has optimized Materialize to reduce the number of instances where a change is considered breaking and thus renders permission set stale. -To determine if a schema change is breaking, we provide the `materialize-cli` tool. - - - `materialize-cli` is still in early development, please reach out to us if you want to try it as - part of AuthZed Materialize early access. - - -#### Errors - -##### FailedPrecondition: Revision Does Not Exist - -Whenever the client receives a `FailedPrecondition`, they should retry with a backoff. -In this case, the client is asking for a revision that hasn’t been yet processed by Materialize. -You may receive this error when: - -- the Materialize instances are restarting and catching up with all changes that have happened since it took a snapshot of your SpiceDB instance. -- A [BreakingSchemaChange] was emitted, and by happenstance, your client had to reconnect. - The Materialize server hasn’t yet rebuilt a new snapshot of your SpiceDB instance with the new schema to serve new events. - -### [LookupPermissionSets] - -This API complements [WatchPermissionSets]. -When you first bring on a system that needs permissions data, [LookupPermissionSets] lets you create an initial snapshot of the permissions data, and then you can use the [WatchPermissionSets] API to keep the snapshot updated. - -The API is resumable via cursors, meaning that the client is responsible for specifying the cursor that denotes the permission set to start streaming from, and the number of them to be streamed. -If no cursor is provided, Materialize will start streaming from what it considers the first change. -The number of events to stream is required. - -The API also supports specifying an optional revision via the `optional_at_revision`, which indicates Materialize should start streaming events at a revision at least as fresh as the provided one. -The server will guarantee the revision selected is equal to or more recent than the requested revision. -This is useful when the client has been notified a [breaking schema change] occurred and that they should rebuild their indexes. -If both `optional_at_revision` and `optional_starting_after` are provided, the latter always takes precedence. - - - Client **must** provide the revision token after a [breaking schema change] through - `optional_starting_after`, otherwise Materialize will start streaming permission sets for whatever - snapshot revision is available at the moment, and won't reflect the schema changes. - - -The current cursor is provided with each event in the stream, so if the consumer client crashes it knows where to restart from, alongside the revision at which the data is computed. -Once an event is received, the recommended course of action is to store the following as part of the same transaction in your database: - -- The data to insert into the permission sets table -- The cursor into a table denoting the current state of the backfill -- The current revision token into a table denoting the snapshot revision of the stored Materialize data - -In the event of the customer consumer being restarted, it should: - -- Select the current cursor from the backfill cursor table -- Issue a [LookupPermissionSets] request with `optional_starting_after` set to the stored cursor -- Resume ingestion as usual - - - While AuthZed treats correctness very seriously, bugs may be identified that affect the - correctness of the denormalized permissions computed by Materialize. Those incidents should be - rare, but consumers must have all the machinery in place to re-index via [LookupPermissionSets] at - any given time. - - -#### Reindexing After A Breaking Schema Change - -Another scenario for invoking [LookupPermissionSets] is after a [breaking schema change] written to the origin SpiceDB instance. -In this case, the index is rendered stale and a client must rebuild it by calling [LookupPermissionSets] at the revision the schema change was introduced. -During this period, the previously ingested permission sets data will be stale. -We are working on several options to minimize the lag caused and improve the developer experience: - -- On-Band: Stream breaking schema changes over [WatchPermissionSets] instead of requiring a [LookupPermissionSets] call. - This will reduce the amount of changes to stream and reduce the time to reindex. -- Off-Band: Support Staging Schemas in SpiceDB so that your application can call [LookupPermissionSets] over the staged schema changes - -These are the two recommended strategies to handle breaking schema events in your application: - -##### On-Band LookupPermissionSets Ingestion - -With on-band ingestion, your application reindexes all permission set data right after receiving a [breaking schema change]. -This will naturally lead to lag, but depending on the volume of data, your application may be able to withstand this. -The tradeoff here is development velocity versus lag. -If your application can't withstand lag during reindexing, please consider the off-band strategy. - -In this scenario, we recommend using the _versioned permission set tables strategy_: your application will keep track of various versions of the permission set. -One will be the currently ingested and being updated with [WatchPermissionSets], and the new version is the result of a [BreakingSchemaChange] and is ingested with [LookupPermissionSets] while the previous version of the permission sets are being served. -You should keep track of what is the current revision being served. - -##### Off-Band LookupPermissionSets Ingestion - -With an off-band ingestion strategy the client will avoid the lag by following a strategy similar to non-breaking relational database migrations: by transforming your schema following a four-phase migration. - -A new permission will be written to your SpiceDB schema that includes the changes, and it will be added to a new Materialize instance run in parallel to the current one, similar to a blue/green deployment. -You will be able to run [LookupPermissionSets] against the new instance to obtain all the permission sets plus the ones corresponding to the newly added permission. -Once your index is ingested and is updated with [WatchPermissionSets], your application should be able to switch to use the new permission and the old permission can be dropped from Materialize first, and then from your schema. - -This strategy requires more steps and careful planning, but in exchange completely avoids any lag. - - - For the time being, Materialize instances are not self-serve, so you'll need to work with your - Account Team to execute the off-band ingestion strategy. - - -#### Request - -```json -{ - "limit": "the_number_of_events_to_stream", - "optional_at_revision": "minimum revision to start streaming from", - "optional_starting_after": "continue stream from the specified cursor" -} -``` - -#### Response - -##### Permission Set Sent Over The Stream - -```json -{ - "change": { - "at_revision": { - "token": "GiAKHjE3MTUzMzk0Mzg2MjA4NzI1MDIuMDAwMDAwMDAwMA==" - }, - "operation": "SET_OPERATION_ADDED", - "parent_set": { - "object_type": "thumper/resource", - "object_id": "seconddoc", - "permission_or_relation": "reader" - }, - "child_member": { - "object_type": "thumper/user", - "object_id": "tom", - "optional_permission_or_relation": "" - } - }, - "cursor": { - "limit": 10000, - "token": { - "token": "GiAKHjE3MTUzMzk0Mzg2MjA4NzI1MDIuMDAwMDAwMDAwMA==" - }, - "starting_index": 1, - "completed_members": false - } -} -``` - -The payload comes with the permission set data to store in your database table, and the cursor that points to that permission set in case resumption is necessary. -The computed revision is also provided as part of the request via `at_revision` so that once the permission set is streamed, the consumer knows where to start streaming [WatchPermissionSets] from. - -The consumer should continue to stream permission sets indefinitely until it has not received further messages over the stream. -Please note that the server may return `EOF` to denote the stream is closed, but that does not mean there aren't more changes to serve. -The client **must** open a new stream with the last cursor, and continue streaming until an iteration of the stream yielded zero events. -At this point, the backfill is completed, and the consumer can start processing change events using [WatchPermissionSets], using the stored snapshot revision. - -#### Errors - -##### InvalidArgument: Cursor Limit Does Not Match Request Limit - -The limit specified in the request, and the limit specified in the initiating request that led to the currently provided cursor differ. -To solve this, make sure you use the same limit for the initiating request as for every subsequent request. -The limit is optional once you provide a cursor since it’s stored in it. - -##### FailedPrecondition: Snapshot Not Found For Revision, Try Again Later - -Whenever the client receives a `FailedPrecondition`, they should retry with a backoff. -In this case, the client is asking for a revision that hasn’t been yet processed by Materialize. -You may receive this error when your client calls [LookupPermissionSets] right after receiving [BreakingSchemaChange] through the WatchPermissionsSets API. -The client should retry with the same revision later on. - -##### Aborted: Requested Revision Is No Longer Available - -This error is returned when a new Materialize has deployed a new snapshot of the origin SpiceDB permission system. -This happens on a regular cadence and is part of Materialize's internal maintenance operations. -When this error is returned, it indicates the client should restart [LookupPermissionSets] afresh, dropping the cursor in `optional_starting_after`, and also dropping `optional_at_revision`. -Every previously stored data should also be discarded. -If the volume of data to ingest via [LookupPermissionSets] is large enough it takes many hours to consume, please get in touch with AuthZed support to tweak your instance accordingly. - -### Managing Client State - -This diagram shows the various states your client application will need to transition through when calling the [LookupPermissionSets] and the [WatchPermissionSets] APIs. - -![authzed-materialize](/images/materialize-client-state-diagram.png) - -[WatchPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.WatchPermissionSets -[LookupPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.LookupPermissionSets -[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources -[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects -[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange -[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange -[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange diff --git a/app/authzed/guides/picking-a-product/page.mdx b/app/authzed/guides/picking-a-product/page.mdx index 22565d8c..879dabc6 100644 --- a/app/authzed/guides/picking-a-product/page.mdx +++ b/app/authzed/guides/picking-a-product/page.mdx @@ -10,26 +10,26 @@ This document is designed to give a high-level overview of the features supporte | Feature | [Open Source] | [Cloud] | [Dedicated] | [Enterprise] | | ---------------------------- | :-----------: | :-----: | :---------: | :----------: | -| Self-Hosted | ✅ | ❌ | ❌ | ✅ | -| No-commit pricing | ✅ | ✅ | ❌ | ❌ | -| [Materialize (Early Access)] | ❌ | ❌ | ✅ | ❌ | -| [Management Dashboard] | ❌ | ✅ | ✅ | ❌ | -| [Private Networking] | DIY | ❌ | ✅ | DIY | -| [Workload Isolation] | DIY | ✅ | ✅ | DIY | -| [Automated Updates] | DIY | ✅ | ✅ | DIY | -| [SOC2 Compliance] | DIY | ✅ | ✅ | DIY | -| [Audit Logging] | ❌ | ✅ | ✅ | ✅ | -| [Rate Limiting] | ❌ | ✅ | ✅ | ✅ | -| [Multi-Region Deployments] | DIY | ❌ | ✅ | DIY | -| [Security Embargo] | ❌ | ✅ | ✅ | ✅ | -| [Restricted API Access] | DIY | ✅ | ✅ | ✅ | -| [Expedited Support] | ❌ | ✅ | ✅ | ✅ | +| Self-Hosted | | | | | +| No-commit pricing | | | | | +| [Materialize (Early Access)] | | | | | +| [Management Dashboard] | | | | | +| [Private Networking] | DIY | | | DIY | +| [Workload Isolation] | DIY | | | DIY | +| [Automated Updates] | DIY | | | DIY | +| [SOC2 Compliance] | DIY | | | DIY | +| [Audit Logging] | | | | | +| [Rate Limiting] | | | | | +| [Multi-Region Deployments] | DIY | | | DIY | +| [Security Embargo] | | | | | +| [Restricted API Access] | DIY | | | | +| [Expedited Support] | | | | | [Cloud]: #cloud [Dedicated]: #dedicated [Enterprise]: #enterprise [Open Source]: #open-source -[Materialize (Early Access)]: ../concepts/authzed-materialize +[Materialize (Early Access)]: /materialize/getting-started/overview [Audit Logging]: ../concepts/audit-logging [Automated Updates]: ../concepts/update-channels [Management Dashboard]: ../concepts/management-dashboard diff --git a/app/authzed/guides/postgres-fdw/page.mdx b/app/authzed/guides/postgres-fdw/page.mdx index 01f4e993..3cceb35d 100644 --- a/app/authzed/guides/postgres-fdw/page.mdx +++ b/app/authzed/guides/postgres-fdw/page.mdx @@ -443,7 +443,7 @@ The FDW has some limitations to be aware of: For super-fast joins or checks on large datasets, consider [AuthZed - Materialize](/authzed/concepts/authzed-materialize). Once set up, Materialize works seamlessly + Materialize](/materialize/getting-started/overview). Once set up, Materialize works seamlessly with the FDW with no SQL changes required to your queries. @@ -477,4 +477,4 @@ If queries return no results: - Learn more about [Restricted API Access](/authzed/concepts/restricted-api-access) - Explore [SpiceDB concepts](/spicedb/concepts/schema) to better understand your data -- Review [best practices](/best-practices) for production deployments +- Review [best practices](/spicedb/best-practices) for production deployments diff --git a/app/changes/page.tsx b/app/changes/page.tsx new file mode 100644 index 00000000..e86e6dcf --- /dev/null +++ b/app/changes/page.tsx @@ -0,0 +1,55 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import changed from "@/lib/changed-pages.json"; + +export const metadata = { title: "Changed in this branch" }; + +// Review aid only — never reachable on production (matches the empty-manifest +// gate in scripts/build-changed-manifest.mjs). +const SHOW = process.env.NODE_ENV !== "production" || process.env.VERCEL_ENV === "preview"; + +type Entry = { status: "new" | "updated" }; +const MANIFEST = changed as Record; + +// Title-case the last route segment as a readable label. +function label(route: string): string { + const seg = route.split("/").filter(Boolean).pop() ?? route; + return seg.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export default function ChangesPage() { + if (!SHOW) notFound(); + const routes = Object.keys(MANIFEST).sort(); + const created = routes.filter((r) => MANIFEST[r].status === "new"); + const updated = routes.filter((r) => MANIFEST[r].status === "updated"); + + const Section = ({ title, items }: { title: string; items: string[] }) => + items.length === 0 ? null : ( +
+

+ {title} ({items.length}) +

+
    + {items.map((r) => ( +
  • + {label(r)}{" "} + {r} +
  • + ))} +
+
+ ); + + return ( +
+

Changed in this branch

+

+ Content pages that are new or modified versus the published docs (origin/main). + Styling and component changes are excluded. + {routes.length === 0 && " No content changes detected."} +

+
+
+
+ ); +} diff --git a/public/favicon.ico b/app/favicon.ico similarity index 100% rename from public/favicon.ico rename to app/favicon.ico diff --git a/app/globals.css b/app/globals.css index 75256b1f..b1c19af9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -103,3 +103,141 @@ html.dark-mode { .swagger-ui { background-color: transparent; } + +/* ── Announcement bar — terminal style matching the marketing site ─────── + Neutralize Nextra's default banner chrome and let our inner bar own the + full-bleed background, border, padding, and typography. Sandworm tokens + (stone-950 bg, stone-800 border, sand-300 accents, #fff text). */ +.nextra-banner { + padding: 0 !important; + background: transparent !important; + border: 0 !important; + color: inherit !important; + justify-content: flex-start !important; + text-align: left !important; +} +.docs-announce { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: flex-start; + text-align: left; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 1.25rem; + background: hsl(280 36.6% 8%); /* sandworm stone-950 */ + border-bottom: 2px solid hsl(279 11.5% 23.9%); /* sandworm stone-800 */ + color: #fff; + font-size: 0.8125rem; + line-height: 1.4; + font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace; +} +@media (min-width: 1024px) { + .docs-announce { + flex-wrap: nowrap; + align-items: center; + } +} +.docs-announce .da-prompt, +.docs-announce .da-bracket, +.docs-announce .da-star, +.docs-announce .da-spinner { + color: hsl(28 100% 72%); /* sand-300 */ + flex-shrink: 0; +} +/* terminal cursor — the cli-spinners "hamburger" (☱☲☴). Static by default, + animates only during the load burst or on hover (`.spin` toggled by the JS). */ +.docs-announce .da-spinner::before { + content: "☱"; +} +@media (prefers-reduced-motion: no-preference) { + .docs-announce.spin .da-spinner::before { + animation: da-cursor 0.3s steps(1, end) infinite; + } + @keyframes da-cursor { + 0% { + content: "☱"; + } + 33.33% { + content: "☲"; + } + 66.66% { + content: "☴"; + } + } +} +.docs-announce .da-body { + min-width: 0; + flex: 1; +} +.docs-announce .da-body a { + color: #fff; + text-decoration: underline; + white-space: nowrap; + transition: font-weight 0.1s ease; +} +.docs-announce .da-body a:hover { + font-weight: 700; +} +.docs-announce .da-meta { + display: none; + flex-shrink: 0; + align-items: center; + gap: 0.5rem; +} +@media (min-width: 1024px) { + .docs-announce .da-meta { + display: flex; + } +} +.docs-announce .da-meta a { + color: hsl(283 4% 66.1%); + text-decoration: none; +} /* stone-300 */ +.docs-announce .da-meta a:hover { + text-decoration: underline; +} + +/* ── Navbar logo — append a muted mono "/docs" path label (à la "/spicedb"). + Inherits the navbar text colour so it adapts to light/dark automatically. */ +.docs-logo { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} +.docs-logo-slug { + font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace; + font-size: 1.05rem; + font-weight: 500; + line-height: 1; + letter-spacing: -0.01em; + color: currentColor; + opacity: 0.5; +} + +/* ── Navbar CTA — "Book a demo" promoted from the page TOC to the top nav so + the primary conversion action is persistent instead of lost beside a short + table of contents. Sandworm magenta; white text reads in both themes. */ +.nav-cta { + display: none; /* desktop-only — see media query below */ + align-items: center; + padding: 0.35rem 0.85rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + line-height: 1; + white-space: nowrap; + color: #fff; + background: hsl(316 50% 44%); + transition: background-color 0.15s ease; +} +.nav-cta:hover { + background: hsl(316 52% 38%); +} +/* Only show the CTA once there's room for it alongside the 5 nav tabs + + icons; below this it would push the nav into overflow. */ +@media (min-width: 1024px) { + .nav-cta { + display: inline-flex; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 5b46f693..6ee65702 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,7 @@ import LogoIcon from "@/components/icons/logo-icon.svg"; import BannerContents from "@/components/banner"; import Providers from "@/components/providers"; import { TocExtraContent } from "@/components/toc-extra-content"; +import { ContentStatus } from "@/components/content-status"; import Scripts from "@/components/scripts"; import type { Metadata, ResolvingMetadata } from "next"; import { SpeedInsights } from "@vercel/speed-insights/next"; @@ -42,32 +43,65 @@ export const generateMetadata = async ( }; }; +/** SpiceDB GitHub star count, cached hourly; falls back to a sane default. */ +async function getStarCount(): Promise { + try { + const res = await fetch("https://api.github.com/repos/authzed/spicedb", { + next: { revalidate: 3600 }, + headers: { Accept: "application/vnd.github+json" }, + }); + if (!res.ok) throw new Error(String(res.status)); + const n = (await res.json())?.stargazers_count; + if (typeof n !== "number") throw new Error("no count"); + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); + } catch { + return "5.7k"; + } +} + export default async function RootLayout({ children }) { const pageMap = await getPageMap(); const enableSearch = process.env.NEXT_PUBLIC_ENABLE_SEARCH_BLOG_INTEGRATION === "true"; + const starCount = await getStarCount(); const navbar = ( } - logoLink="https://authzed.com" + logo={ + + + /docs + + } + logoLink="/" chatLink="https://authzed.com/discord" projectLink="https://github.com/authzed/spicedb" - /> + > + + Book a demo + + ); return ( - + } navbar={navbar} @@ -94,7 +128,10 @@ export default async function RootLayout({ children }) { > - {children} + + + {children} + diff --git a/app/materialize/_meta.ts b/app/materialize/_meta.ts new file mode 100644 index 00000000..075a9e44 --- /dev/null +++ b/app/materialize/_meta.ts @@ -0,0 +1,6 @@ +export default { + "getting-started": "Getting Started", + concepts: "Concepts", + guides: "Guides", + api: "API Reference", +}; diff --git a/app/materialize/api/_meta.ts b/app/materialize/api/_meta.ts new file mode 100644 index 00000000..065e4263 --- /dev/null +++ b/app/materialize/api/_meta.ts @@ -0,0 +1,5 @@ +export default { + "client-sdks": "Client SDKs", + "watch-permission-sets": "WatchPermissionSets", + "lookup-permission-sets": "LookupPermissionSets", +}; diff --git a/app/materialize/api/client-sdks/page.mdx b/app/materialize/api/client-sdks/page.mdx new file mode 100644 index 00000000..3ac2b2d4 --- /dev/null +++ b/app/materialize/api/client-sdks/page.mdx @@ -0,0 +1,11 @@ +# Client SDKs + +All SpiceDB SDKs have the generated gRPC and protobuf code + +- [authzed-go v0.15.0](https://github.com/authzed/authzed-go/releases/tag/v0.15.0) +- [authzed-java 0.10.0](https://github.com/authzed/authzed-java/releases/tag/0.10.0) +- [authzed-py v0.17.0](https://github.com/authzed/authzed-py/releases/tag/v0.17.0) +- [authzed-rb v0.11.0](https://github.com/authzed/authzed-rb/releases/tag/v0.11.0) +- [authzed-node v0.17.0](https://github.com/authzed/authzed-node/releases/tag/v0.17.0) + +AuthZed Materialize's gRPC API definition is available from [API version 1.35](https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0) diff --git a/app/materialize/api/lookup-permission-sets/page.mdx b/app/materialize/api/lookup-permission-sets/page.mdx new file mode 100644 index 00000000..dc3ea04f --- /dev/null +++ b/app/materialize/api/lookup-permission-sets/page.mdx @@ -0,0 +1,162 @@ +import { Callout } from "nextra/components"; + +# LookupPermissionSets + +This API complements [WatchPermissionSets]. +When you first bring on a system that needs permissions data, [LookupPermissionSets] lets you create an initial snapshot of the permissions data, and then you can use the [WatchPermissionSets] API to keep the snapshot updated. + +The API is resumable via cursors, meaning that the client is responsible for specifying the cursor that denotes the permission set to start streaming from, and the number of them to be streamed. +If no cursor is provided, Materialize will start streaming from what it considers the first change. +The number of events to stream is required. + +The API also supports specifying an optional revision via the `optional_at_revision`, which indicates Materialize should start streaming events at a revision at least as fresh as the provided one. +The server will guarantee the revision selected is equal to or more recent than the requested revision. +This is useful when the client has been notified a [breaking schema change] occurred and that they should rebuild their indexes. +If both `optional_at_revision` and `optional_starting_after` are provided, the latter always takes precedence. + + + Client **must** provide the revision token after a [breaking schema change] through + `optional_starting_after`, otherwise Materialize will start streaming permission sets for whatever + snapshot revision is available at the moment, and won't reflect the schema changes. + + +The current cursor is provided with each event in the stream, so if the consumer client crashes it knows where to restart from, alongside the revision at which the data is computed. +Once an event is received, the recommended course of action is to store the following as part of the same transaction in your database: + +- The data to insert into the permission sets table +- The cursor into a table denoting the current state of the backfill +- The current revision token into a table denoting the snapshot revision of the stored Materialize data + +In the event of the customer consumer being restarted, it should: + +- Select the current cursor from the backfill cursor table +- Issue a [LookupPermissionSets] request with `optional_starting_after` set to the stored cursor +- Resume ingestion as usual + + + While AuthZed treats correctness very seriously, bugs may be identified that affect the + correctness of the denormalized permissions computed by Materialize. Those incidents should be + rare, but consumers must have all the machinery in place to re-index via [LookupPermissionSets] at + any given time. + + +## Reindexing After A Breaking Schema Change + +Another scenario for invoking [LookupPermissionSets] is after a [breaking schema change] written to the origin SpiceDB instance. +In this case, the index is rendered stale and a client must rebuild it by calling [LookupPermissionSets] at the revision the schema change was introduced. +During this period, the previously ingested permission sets data will be stale. +We are working on several options to minimize the lag caused and improve the developer experience: + +- On-Band: Stream breaking schema changes over [WatchPermissionSets] instead of requiring a [LookupPermissionSets] call. + This will reduce the amount of changes to stream and reduce the time to reindex. +- Off-Band: Support Staging Schemas in SpiceDB so that your application can call [LookupPermissionSets] over the staged schema changes + +These are the two recommended strategies to handle breaking schema events in your application: + +### On-Band LookupPermissionSets Ingestion + +With on-band ingestion, your application reindexes all permission set data right after receiving a [breaking schema change]. +This will naturally lead to lag, but depending on the volume of data, your application may be able to withstand this. +The tradeoff here is development velocity versus lag. +If your application can't withstand lag during reindexing, please consider the off-band strategy. + +In this scenario, we recommend using the _versioned permission set tables strategy_: your application will keep track of various versions of the permission set. +One will be the currently ingested and being updated with [WatchPermissionSets], and the new version is the result of a [BreakingSchemaChange] and is ingested with [LookupPermissionSets] while the previous version of the permission sets are being served. +You should keep track of what is the current revision being served. + +### Off-Band LookupPermissionSets Ingestion + +With an off-band ingestion strategy the client will avoid the lag by following a strategy similar to non-breaking relational database migrations: by transforming your schema following a four-phase migration. + +A new permission will be written to your SpiceDB schema that includes the changes, and it will be added to a new Materialize instance run in parallel to the current one, similar to a blue/green deployment. +You will be able to run [LookupPermissionSets] against the new instance to obtain all the permission sets plus the ones corresponding to the newly added permission. +Once your index is ingested and is updated with [WatchPermissionSets], your application should be able to switch to use the new permission and the old permission can be dropped from Materialize first, and then from your schema. + +This strategy requires more steps and careful planning, but in exchange completely avoids any lag. + + + For the time being, Materialize instances are not self-serve, so you'll need to work with your + Account Team to execute the off-band ingestion strategy. + + +## Request + +```json +{ + "limit": "the_number_of_events_to_stream", + "optional_at_revision": "minimum revision to start streaming from", + "optional_starting_after": "continue stream from the specified cursor" +} +``` + +## Response + +### Permission Set Sent Over The Stream + +```json +{ + "change": { + "at_revision": { + "token": "GiAKHjE3MTUzMzk0Mzg2MjA4NzI1MDIuMDAwMDAwMDAwMA==" + }, + "operation": "SET_OPERATION_ADDED", + "parent_set": { + "object_type": "thumper/resource", + "object_id": "seconddoc", + "permission_or_relation": "reader" + }, + "child_member": { + "object_type": "thumper/user", + "object_id": "tom", + "optional_permission_or_relation": "" + } + }, + "cursor": { + "limit": 10000, + "token": { + "token": "GiAKHjE3MTUzMzk0Mzg2MjA4NzI1MDIuMDAwMDAwMDAwMA==" + }, + "starting_index": 1, + "completed_members": false + } +} +``` + +The payload comes with the permission set data to store in your database table, and the cursor that points to that permission set in case resumption is necessary. +The computed revision is also provided as part of the request via `at_revision` so that once the permission set is streamed, the consumer knows where to start streaming [WatchPermissionSets] from. + +The consumer should continue to stream permission sets indefinitely until it has not received further messages over the stream. +Please note that the server may return `EOF` to denote the stream is closed, but that does not mean there aren't more changes to serve. +The client **must** open a new stream with the last cursor, and continue streaming until an iteration of the stream yielded zero events. +At this point, the backfill is completed, and the consumer can start processing change events using [WatchPermissionSets], using the stored snapshot revision. + +## Errors + +### InvalidArgument: Cursor Limit Does Not Match Request Limit + +The limit specified in the request, and the limit specified in the initiating request that led to the currently provided cursor differ. +To solve this, make sure you use the same limit for the initiating request as for every subsequent request. +The limit is optional once you provide a cursor since it's stored in it. + +### FailedPrecondition: Snapshot Not Found For Revision, Try Again Later + +Whenever the client receives a `FailedPrecondition`, they should retry with a backoff. +In this case, the client is asking for a revision that hasn't been yet processed by Materialize. +You may receive this error when your client calls [LookupPermissionSets] right after receiving [BreakingSchemaChange] through the WatchPermissionsSets API. +The client should retry with the same revision later on. + +### Aborted: Requested Revision Is No Longer Available + +This error is returned when a new Materialize has deployed a new snapshot of the origin SpiceDB permission system. +This happens on a regular cadence and is part of Materialize's internal maintenance operations. +When this error is returned, it indicates the client should restart [LookupPermissionSets] afresh, dropping the cursor in `optional_starting_after`, and also dropping `optional_at_revision`. +Every previously stored data should also be discarded. +If the volume of data to ingest via [LookupPermissionSets] is large enough it takes many hours to consume, please get in touch with AuthZed support to tweak your instance accordingly. + +[WatchPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.WatchPermissionSets +[LookupPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.LookupPermissionSets +[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources +[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects +[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange +[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange +[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange diff --git a/app/materialize/api/watch-permission-sets/page.mdx b/app/materialize/api/watch-permission-sets/page.mdx new file mode 100644 index 00000000..710b753d --- /dev/null +++ b/app/materialize/api/watch-permission-sets/page.mdx @@ -0,0 +1,179 @@ +import { Callout } from "nextra/components"; + +# WatchPermissionSets + +This is an update stream of all the permissions Materialize is configured to watch. +You can use this to store all permissions tracked in the system closer to your application database to be used in database-native ACL filtering. +Permissions can also be stored in secondary indexes like Elasticsearch. + +The API consists of various event types that capture deltas that occurred since a client started listening. +It will also notify of events like a [breaking schema change] that necessitate rebuilding of the index. + +## Request + +```json +{ + "optional_starting_after": "the_zed_token" +} +``` + +The `optional_starting_after` field in the request denotes the SpiceDB revision to start streaming changes. +It will start streaming from the revision right after the indicated one. +If no `optional_starting_after` is provided, Materialize will determine the latest revision at the moment of the request, and start streaming changes from there on. + +## Response + +### Revision Checkpoint Event + +Sent when changes happened in SpiceDB, but didn't affect Materialize. +Customers should keep track of this revision in their internal database to know where to resume from in the event of stream disconnection or stream consumer restart/failure. + +```json +{ + "completed_revision": { + "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA==" + } +} +``` + +### Member Added To Set Event + +```json +{ + "change": { + "at_revision": { + "token": "GiAKHjE3MTUzMzkzMDg0MTY2NzUxNzcuMDAwMDAwMDAwMA==" + }, + "operation": "SET_OPERATION_ADDED", + "parent_set": { + "object_type": "thumper/resource", + "object_id": "seconddoc", + "permission_or_relation": "reader" + }, + "child_member": { + "object_type": "thumper/user", + "object_id": "fred", + "optional_permission_or_relation": "" + } + } +} +``` + +### Member Removed From Set Event + +```json +{ + "change": { + "at_revision": { + "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA==" + }, + "operation": "SET_OPERATION_REMOVED", + "parent_set": { + "object_type": "thumper/resource", + "object_id": "seconddoc", + "permission_or_relation": "reader" + }, + "child_member": { + "object_type": "thumper/user", + "object_id": "fred", + "optional_permission_or_relation": "" + } + } +} +``` + +### Set Added To Set Event + +```json +{ + "change": { + "at_revision": { + "token": "GiAKHjE3MTUzMzkzMDg0MTY2NzUxNzcuMDAwMDAwMDAwMA==" + }, + "operation": "SET_OPERATION_ADDED", + "parent_set": { + "object_type": "thumper/resource", + "object_id": "seconddoc", + "permission_or_relation": "reader" + }, + "child_set": { + "object_type": "thumper/team", + "object_id": "engineering", + "permission_or_relation": "members" + } + } +} +``` + +### Set Removed From Set Event + +```json +{ + "change": { + "at_revision": { + "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA==" + }, + "operation": "SET_OPERATION_REMOVED", + "parent_set": { + "object_type": "thumper/resource", + "object_id": "seconddoc", + "permission_or_relation": "reader" + }, + "child_set": { + "object_type": "thumper/team", + "object_id": "engineering", + "permission_or_relation": "members" + } + } +} +``` + +### [Breaking Schema Change] Event + +When the origin SpiceDB instance introduces a schema change that invalidates all currently computed permission sets, Materialize will issue a special event indicating this happened: + +```json +{ + "breaking_schema_change": { + "change_at": { + "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA==" + } + } +} +``` + +The event indicates the revision at which the schema change happened. + +When the client receives this event, all previously indexed permission sets are rendered stale, and the client must rebuild the index with a call to [LookupPermissionSets] at the revision the schema change was introduced. + +Not every change to the origin permission system schema is considered breaking. + +#### Detecting Breaking Schema Changes In Development Environment + +The AuthZed team has optimized Materialize to reduce the number of instances where a change is considered breaking and thus renders permission set stale. +To determine if a schema change is breaking, we provide the `materialize-cli` tool. + + + `materialize-cli` is still in early development, please reach out to us if you want to try it as + part of AuthZed Materialize early access. + + +## Errors + +### FailedPrecondition: Revision Does Not Exist + +Whenever the client receives a `FailedPrecondition`, they should retry with a backoff. +In this case, the client is asking for a revision that hasn't been yet processed by Materialize. +You may receive this error when: + +- the Materialize instances are restarting and catching up with all changes that have happened since it took a snapshot of your SpiceDB instance. +- A [BreakingSchemaChange] was emitted, and by happenstance, your client had to reconnect. + The Materialize server hasn't yet rebuilt a new snapshot of your SpiceDB instance with the new schema to serve new events. + +[WatchPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.WatchPermissionSets +[LookupPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.LookupPermissionSets +[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources +[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects +[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange +[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange +[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange diff --git a/app/materialize/concepts/_meta.ts b/app/materialize/concepts/_meta.ts new file mode 100644 index 00000000..008bf1bc --- /dev/null +++ b/app/materialize/concepts/_meta.ts @@ -0,0 +1,6 @@ +export default { + "permission-sets": "Permission Sets", + hydration: "Hydration", + snapshots: "Snapshots", + "managing-client-state": "Managing Client State", +}; diff --git a/app/materialize/concepts/hydration/page.mdx b/app/materialize/concepts/hydration/page.mdx new file mode 100644 index 00000000..1cecab77 --- /dev/null +++ b/app/materialize/concepts/hydration/page.mdx @@ -0,0 +1,39 @@ +import { Callout } from "nextra/components"; + +# Hydration + + + **Draft — content review needed.** This concept page was promoted from prose that previously lived + inline in the API reference. The mechanics below are accurate to the existing docs; the framing is + new and should be reviewed (and expanded with diagrams/examples) before publishing. + + +**Hydration** is the process of populating your application's local copy of permission data for the first time — the initial backfill that gets you from an empty index to a complete, queryable [Permission Sets](./permission-sets) store. + +When you first bring a system online, you have no permission data locally. +Hydration reads the **current** state of every permission set Materialize is configured to watch, using [LookupPermissionSets](../../api/lookup-permission-sets), and writes it into your datastore. +Once hydration is complete, you switch to [WatchPermissionSets](../../api/watch-permission-sets) to keep that data current — see [Snapshots](./snapshots) for how the handoff stays consistent. + +## The hydration lifecycle + +1. **Backfill** — call [LookupPermissionSets](../../api/lookup-permission-sets), paging through the stream via cursors until an iteration yields zero events. Each event carries the permission-set data to store **and** the cursor to resume from on failure. +2. **Record the snapshot revision** — every backfill event includes the revision (`ZedToken`) the data was computed at. Store it transactionally alongside the data. +3. **Switch to the change stream** — once the backfill completes, open [WatchPermissionSets](../../api/watch-permission-sets) using the stored snapshot revision so no changes are missed between hydration and live updates. + + + Hydration is not only a first-run concern. You must re-hydrate after a **breaking schema change**, + which renders previously indexed permission sets stale. Build the machinery to re-run hydration at + any time. See [Managing Client State](./managing-client-state). + + +## Hydration and snapshots + +Hydration always produces a [snapshot](./snapshots) — a point-in-time, internally consistent view of the permission sets at a specific revision. +The relationship is one-directional: **hydration is the act; a snapshot is the result.** +You hydrate _to_ a snapshot revision, then keep that snapshot live with the change stream. + + + **TODO (Sam):** expand with a concrete worked example and the four-phase / blue-green re-hydration + strategy for breaking schema changes, currently documented under + [LookupPermissionSets](../../api/lookup-permission-sets). + diff --git a/app/materialize/concepts/managing-client-state/page.mdx b/app/materialize/concepts/managing-client-state/page.mdx new file mode 100644 index 00000000..dda561ab --- /dev/null +++ b/app/materialize/concepts/managing-client-state/page.mdx @@ -0,0 +1,13 @@ +# Managing Client State + +This diagram shows the various states your client application will need to transition through when calling the [LookupPermissionSets] and the [WatchPermissionSets] APIs. + +![authzed-materialize](/images/materialize-client-state-diagram.png) + +[WatchPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.WatchPermissionSets +[LookupPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.LookupPermissionSets +[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources +[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects +[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange +[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange +[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange diff --git a/app/materialize/concepts/permission-sets/page.mdx b/app/materialize/concepts/permission-sets/page.mdx new file mode 100644 index 00000000..54b479d1 --- /dev/null +++ b/app/materialize/concepts/permission-sets/page.mdx @@ -0,0 +1,32 @@ +import { Callout } from "nextra/components"; + +# Permission Sets + +A **permission set** is the unit of precomputed authorization data that Materialize produces. +Where SpiceDB answers a permission question on demand by walking the relationship graph, Materialize continuously **precomputes** the membership of the permissions you configure it to watch and exposes that denormalized data to your application. + +Conceptually, a permission set captures two kinds of edges: + +- **Member → set** — a subject (e.g. `user:evan`) is a member of a permission set (e.g. `document:123#view`). +- **Set → set** — one set is nested inside another (e.g. `group:shared#member` grants `document:456#view`). + +Together these let your application reconstruct "which resources can this subject access" — and the inverse — without issuing a `LookupResources` or `CheckPermission` call per request. +This is what makes authorization-aware search, sorting, and filtering over large result sets practical. + +## How you obtain permission sets + +Permission sets reach your application through two complementary APIs: + +- [LookupPermissionSets](../../api/lookup-permission-sets) — reads the **current** permission sets as an initial backfill. See [Hydration](./hydration). +- [WatchPermissionSets](../../api/watch-permission-sets) — streams **changes** to permission sets as relationships and schema evolve. See [Snapshots](./snapshots) for how the two stay consistent. + +## Storing permission sets + +The shape of the data maps cleanly onto relational tables (a `member_to_set` and a `set_to_set` table is the most flexible model), or into a secondary index such as Elasticsearch for ACL-filtered search. +See [Syncing to a Relational Database](../../guides/relational-database) for worked examples. + + + The fields returned by `LookupPermissionSets` and `WatchPermissionSets` are identical — you store + them the same way regardless of which API delivered them. That symmetry is what lets you hydrate + with one API and keep current with the other. + diff --git a/app/materialize/concepts/snapshots/page.mdx b/app/materialize/concepts/snapshots/page.mdx new file mode 100644 index 00000000..230f4985 --- /dev/null +++ b/app/materialize/concepts/snapshots/page.mdx @@ -0,0 +1,39 @@ +import { Callout } from "nextra/components"; + +# Snapshots + + + **Draft — content review needed.** Promoted from prose previously scattered through the API + reference. Mechanics are accurate to the existing docs; framing is new and should be reviewed + before publishing. + + +A **snapshot** is a point-in-time, internally consistent view of every [Permission Set](./permission-sets) Materialize is tracking, computed at a specific SpiceDB revision. +Every piece of permission data Materialize hands you is anchored to the revision (`ZedToken`) it was computed at — that revision _is_ the snapshot's identity. + +Snapshots are what make the two Materialize APIs compose safely: + +- [Hydration](./hydration) reads a snapshot in full via [LookupPermissionSets](../../api/lookup-permission-sets). +- [WatchPermissionSets](../../api/watch-permission-sets) advances that snapshot forward, delivering the deltas that move it from one revision to the next. + +## Why the revision matters + +Because each event carries its revision, your consumer can always answer "as of when is my local data correct?" and resume from exactly the right place after a crash or disconnect. +Storing the snapshot revision **in the same transaction** as the permission data it describes is the core durability guarantee: whatever revision you restart from, no events are skipped and no inconsistent state is observable. + +## Snapshot lifecycle events + +- **Revision checkpoint** — SpiceDB changed, but nothing Materialize watches was affected. Advance your stored revision so you know where to resume. +- **Breaking schema change** — the current snapshot is invalidated; you must build a fresh snapshot by re-[hydrating](./hydration) at the new revision. +- **Snapshot rotation** — Materialize periodically deploys a new internal snapshot of the origin SpiceDB system as part of routine maintenance. A request against a retired snapshot returns `Aborted: Requested Revision Is No Longer Available`, signalling the consumer to re-hydrate from scratch. + + + After a breaking schema change you **must** pass the revision token through `optional_starting_after` + when re-hydrating, or Materialize will stream against whatever snapshot is current and your data + won't reflect the schema change. + + + + **TODO (Sam):** confirm the exact snapshot retention window and rotation cadence, and whether the + 24h change-event retention is the same mechanism or distinct. + diff --git a/app/materialize/getting-started/_meta.ts b/app/materialize/getting-started/_meta.ts new file mode 100644 index 00000000..3a8ce84f --- /dev/null +++ b/app/materialize/getting-started/_meta.ts @@ -0,0 +1,4 @@ +export default { + overview: "Overview", + limitations: "Limitations", +}; diff --git a/app/materialize/getting-started/limitations/page.mdx b/app/materialize/getting-started/limitations/page.mdx new file mode 100644 index 00000000..f3e8c5fe --- /dev/null +++ b/app/materialize/getting-started/limitations/page.mdx @@ -0,0 +1,15 @@ +# Limitations + +- Your schema can contain any of the following, but they cannot be on the path of your configured Materialize permissions or it will throw an error: + - [Caveats] + - [Wildcard] subject types + - [.all intersections] + +- [Expiring relationships] aren't supported. +- Materialize takes time to compute the denormalized relationship updates, so if you are streaming the changes to your database, your application must be able to tolerate some lag. + +[Caveats]: https://authzed.com/docs/spicedb/concepts/caveats +[Wildcard]: https://authzed.com/docs/spicedb/concepts/schema#wildcards +[.all intersections]: https://authzed.com/docs/spicedb/concepts/schema#all-intersection-arrow +[expiring relationships]: https://authzed.com/docs/spicedb/concepts/expiring-relationships +[Dedicated]: https://authzed.com/docs/authzed/guides/picking-a-product#dedicated diff --git a/app/materialize/getting-started/overview/page.mdx b/app/materialize/getting-started/overview/page.mdx new file mode 100644 index 00000000..fc5a8062 --- /dev/null +++ b/app/materialize/getting-started/overview/page.mdx @@ -0,0 +1,23 @@ +import { Callout } from "nextra/components"; + +# What is Materialize? + + + AuthZed Materialize is available to users of AuthZed [Dedicated] as part of an early access + program. Don't hesitate to get in touch with your AuthZed account team if you would like to + participate. + + +AuthZed Materialize takes inspiration from the Leopard index component described in the [Zanzibar paper](https://zanzibar.tech/2IoYDUFMAE:0:T). +Much like the concept of a materialized view in relational databases, AuthZed Materialize is a service that you configure with a list of permissions that you want it to precompute, and it will calculate how those permissions change after relationships +are written (specifically, when those relationships affect a subject's membership in a permission set or a set's permission on a specific resource), or when a new schema is written. +These precomputed permissions can then be used either to provide faster checks and lookups through Accelerated Queries, or streamed to your own application database to do operations like searching, sorting, and filtering much more efficiently. + +In summary, AuthZed Materialize allows you to: + +- Speed up `CheckPermission` and `CheckBulkPermissions`. +- Speed up `LookupResources` and `LookupSubjects`, especially when there is a large number of resources. +- Build authorization-aware UIs, e.g. by providing a filtered and/or sorted list of more than several thousand authorized objects. +- Perform ACL filtering in other secondary indexes, like a search index (e.g. Elasticsearch). + +[Dedicated]: https://authzed.com/docs/authzed/guides/picking-a-product#dedicated diff --git a/app/materialize/guides/_meta.ts b/app/materialize/guides/_meta.ts new file mode 100644 index 00000000..f82819d5 --- /dev/null +++ b/app/materialize/guides/_meta.ts @@ -0,0 +1,4 @@ +export default { + "recommended-architecture": "Recommended Architecture", + "relational-database": "Syncing to a Relational Database", +}; diff --git a/app/materialize/guides/recommended-architecture/page.mdx b/app/materialize/guides/recommended-architecture/page.mdx new file mode 100644 index 00000000..2437630d --- /dev/null +++ b/app/materialize/guides/recommended-architecture/page.mdx @@ -0,0 +1,37 @@ +import { Callout } from "nextra/components"; + +# Recommended Architecture + +## Consuming Client + +![authzed-materialize](/images/authzed-materialize.png) + +Customers will need to build a client to act as an "event processor" that consumes permission updates and writes those updates to a datastore like Postgres. +The consumer should be designed with resumability in mind by keeping track of the last revision consumed, just as any other stream processor. + +## Durability + +Every SpiceDB permission update will come with a `ZedToken`. +The consumer must keep track of that revision token to be able to resume the change stream from the last event consumed when a failure happens, like stream disconnection, consumer restart, or server-side restarts. + +When a consumer failure happens, the process should determine the last revision `ZedToken` consumed, and send that alongside your request. +The consumer should be coded with idempotency in mind in the event of such failures, meaning it should be prepared to process stream messages that have already been processed. + +Storing the revision `ZedToken` in the same database where the computed permissions are being stored is a good practice as it enables storing those transactionally, which gives you the guarantee that whatever revision the consumer restarts from, won't cause events to be skipped, which would lead to an inconsistent state of the world. + +There may be scenarios where a revision has so many changes that storing transactionally can degrade the performance/availability of the target database. +In situations like these, one may want to store the events in batches, and in such cases, the revision should only be stored when the consumer determines the last batch has been processed. +If a failure happened in between those batches, the consumer will be able to restart processing from the start of the revision and idempotently overwrite whatever events were already in place. + + + Change events are stored up to 24h to make sure Materialize storage does not grow unbounded and + affect its performance. + + +[WatchPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.WatchPermissionSets +[LookupPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.LookupPermissionSets +[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources +[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects +[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange +[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange +[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange diff --git a/app/materialize/guides/relational-database/page.mdx b/app/materialize/guides/relational-database/page.mdx new file mode 100644 index 00000000..66cced55 --- /dev/null +++ b/app/materialize/guides/relational-database/page.mdx @@ -0,0 +1,201 @@ +import { Callout } from "nextra/components"; + +# Syncing to a Relational Database + +Just as with relational database materialized views, you need to provide Materialize with the "queries" you'd like it to pre-compute. +The configuration is described as a list of `resource#permission@subject` tuples. +Example: + +```zed +resource#view@user +resource#edit@user +``` + + + During early access provisioning, Materialize instances are not self-service, so you'll need to + provide the permissions to be computed by Materialize directly to your AuthZed account team. + + +## Relational Database + +You can find a runnable version of these examples [here](https://dbfiddle.uk/dX10Cu3Z). + +These are tables you likely already have in your database + +1. something representing the user +2. something representing the object we want to filter + +```sql +CREATE TABLE users ( + id varchar(100) PRIMARY KEY, + name varchar(40) +); +CREATE TABLE documents ( + id varchar(100) PRIMARY KEY, + name varchar(40), + contents_bucket varchar(100) +); +``` + +The `member_to_set` and `set_to_set` tables below are just used to track data from [LookupPermissionSets] and [WatchPermissionSets], all you need to do is store the fields directly from those APIs. + +```sql +CREATE TABLE member_to_set ( + member_type varchar(100), + member_id varchar(100), + member_relation varchar(100), + set_type varchar(100), + set_id varchar(100), + set_relation varchar(100) +); + +CREATE TABLE set_to_set ( + child_type varchar(100), + child_id varchar(100), + child_relation varchar(100), + parent_type varchar(100), + parent_id varchar(100), + parent_relation varchar(100) +); +``` + +Seed some base data; this would already exist in the application: + +```sql +INSERT INTO users (id, name) VALUES ('123', 'evan'), ('456', 'victor'); +INSERT INTO documents (id, name) VALUES ('123', 'evan secret doc'), ('456', 'victor shared doc'); +``` + +Sync data from [LookupPermissionSets]/[WatchPermissionSets]. +The APIs return type/id/relation name: + +```sql +INSERT INTO member_to_set (member_type, member_id, member_relation, set_type, set_id, set_relation) + VALUES ('user', '123', '', 'document', '123', 'view'), + ('user', '123', '', 'group', 'shared', 'member'), + ('user', '456', '', 'group', 'shared', 'member'); + +INSERT INTO set_to_set (child_type, child_id, child_relation, parent_type, parent_id, parent_relation) + VALUES ('group', 'shared', 'member', 'document', '456', 'view'); +``` + +To query, join the local application data with [LookupPermissionSets]/[WatchPermissionSets] data to filter by specific permissions. + +Find all documents `evan` can `view:` + +```sql +SELECT d.id FROM documents d + LEFT JOIN set_to_set s2s ON d.id = s2s.parent_id + INNER JOIN member_to_set m2s ON (m2s.set_id = s2s.child_id AND m2s.set_type = s2s.child_type AND m2s.set_relation = s2s.child_relation) OR (d.id = m2s.set_id ) + INNER JOIN users u ON u.id = m2s.member_id + WHERE + u.name = 'evan' AND + m2s.member_type = 'user' AND + m2s.member_relation = '' AND (( + s2s.parent_type = 'document' AND + s2s.parent_relation='view' + ) OR ( + m2s.set_type = 'document' AND + m2s.set_relation = 'view' + )); +``` + +| id | +| :-- | +| 123 | +| 456 | + +The same query, by changing only the username, will find all documents `victor` can `view`: + +```sql +SELECT d.id FROM documents d + LEFT JOIN set_to_set s2s ON d.id = s2s.parent_id + INNER JOIN member_to_set m2s ON (m2s.set_id = s2s.child_id AND m2s.set_type = s2s.child_type AND m2s.set_relation = s2s.child_relation) OR (d.id = m2s.set_id ) + INNER JOIN users u ON u.id = m2s.member_id + WHERE + u.name = 'victor' AND + m2s.member_type = 'user' AND + m2s.member_relation = '' AND (( + s2s.parent_type = 'document' AND + s2s.parent_relation='view' + ) OR ( + m2s.set_type = 'document' AND + m2s.set_relation = 'view' + )); +``` + +| id | +| :-- | +| 456 | + +The above example shows the most flexible way to do this: you can update your SpiceDB schema and sync new permission sets data without SQL schema changes but at the cost of more verbose SQL queries. + +If you know that you only care about `document#view@user,` then you can store the data more concisely and query more simply. +This strategy can also be used to shard the data coming from the Materialize APIs so that it does not all land in one table. + +Simplified permission sets storage (just for `document#view@user`): + +```sql +CREATE TABLE user_to_set ( + user_id varchar(100), + parent_set varchar(300) +); + +CREATE TABLE set_to_document_view ( + child_set varchar(300), + document_id varchar(100) +); +``` + +Storing from [LookupPermissionSets]/[WatchPermissionSets] in this model requires some simple transformations compared to the previous example: + +```sql +INSERT INTO user_to_set (user_id, parent_set) + VALUES ('123', 'document:123#view'), + ('123', 'group:shared#member'), + ('456', 'group:shared#member'); + +INSERT INTO set_to_document_view (child_set, document_id) + VALUES ('document:123#view', '123'), + ('group:shared#member', '456'); +``` + +Note that an extra entry (`document:123#view`, `123`) was added to simplify the join side (avoiding the `left join` in the previous example). +The queries are a bit simpler, though they can't be used to answer any permission check other than `document#view@user`. + +Find all documents `evan` can `view`: + +```sql +SELECT d.id FROM documents d + INNER JOIN set_to_document_view s2s ON d.id = s2s.document_id + INNER JOIN user_to_set m2s ON m2s.parent_set = s2s.child_set + INNER JOIN users u ON u.id = m2s.user_id + WHERE u.name = 'evan'; +``` + +| id | +| :-- | +| 123 | +| 456 | + +Find all documents `victor` can `view`: + +```sql +SELECT d.id FROM documents d + INNER JOIN set_to_document_view s2s ON d.id = s2s.document_id + INNER JOIN user_to_set m2s ON m2s.parent_set = s2s.child_set + INNER JOIN users u ON u.id = m2s.user_id + WHERE u.name = 'victor'; +``` + +| id | +| :-- | +| 456 | + +[WatchPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.WatchPermissionSets +[LookupPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.LookupPermissionSets +[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources +[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects +[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange +[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange +[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange diff --git a/app/page.mdx b/app/page.mdx index 7012444e..568490b5 100644 --- a/app/page.mdx +++ b/app/page.mdx @@ -1,20 +1,7 @@ --- -title: Authzed Docs +title: AuthZed Documentation --- -Browse documentation for **SpiceDB** or **AuthZed Products** by selecting one below. +import { DocsHomeShell } from "@/components/docs-home-shell"; -import { Cards } from "nextra/components"; - - - -
-

SpiceDB Documentation

-
-
- -
-

AuthZed Products Documentation

-
-
-
+ diff --git a/app/spicedb/_meta.ts b/app/spicedb/_meta.ts index 9be8212d..98632821 100644 --- a/app/spicedb/_meta.ts +++ b/app/spicedb/_meta.ts @@ -5,6 +5,7 @@ export default { ops: "Operations", integrations: "Integrations", tutorials: "Tutorials", + "best-practices": "Best Practices", api: "API Reference", links: "Links", }; diff --git a/app/best-practices/_meta.ts b/app/spicedb/best-practices/_meta.ts similarity index 100% rename from app/best-practices/_meta.ts rename to app/spicedb/best-practices/_meta.ts diff --git a/app/best-practices/page.mdx b/app/spicedb/best-practices/page.mdx similarity index 100% rename from app/best-practices/page.mdx rename to app/spicedb/best-practices/page.mdx diff --git a/app/spicedb/concepts/querying-data/page.mdx b/app/spicedb/concepts/querying-data/page.mdx index 8df40614..f86f314f 100644 --- a/app/spicedb/concepts/querying-data/page.mdx +++ b/app/spicedb/concepts/querying-data/page.mdx @@ -77,7 +77,7 @@ Receive many: It's a good way to do [prefiltering of results](/spicedb/modeling/protecting-a-list-endpoint#filtering-with-lookupresources) in a List endpoint, but it's a heavy request that can cause performance problems when more than 10k results are involved. In that case we recommend postfiltering with [CheckBulk](#checkbulkpermissions), and if that still doesn't work, -we recommend evaluating [Materialize](/authzed/concepts/authzed-materialize). +we recommend evaluating [Materialize](/materialize/getting-started/overview). ## LookupSubjects diff --git a/app/spicedb/concepts/read-after-write/page.mdx b/app/spicedb/concepts/read-after-write/page.mdx index 7791fb14..9bbf58b5 100644 --- a/app/spicedb/concepts/read-after-write/page.mdx +++ b/app/spicedb/concepts/read-after-write/page.mdx @@ -108,5 +108,5 @@ Use `fully_consistent` only when you need a quick solution or when the request v - [Consistency in SpiceDB](/spicedb/concepts/consistency) - [Zed Tokens, Zookies, Consistency for Authorization](https://authzed.com/blog/zedtokens) - [Hotspot Caching in Google Zanzibar and SpiceDB](https://authzed.com/blog/hotspot-caching-in-google-zanzibar-and-spicedb) -- [Best Practices: Understand your consistency needs](/best-practices#understand-your-consistency-needs) -- [Best Practices: Use ZedTokens and "At Least As Fresh"](/best-practices#use-zedtokens-and-at-least-as-fresh-for-best-caching) +- [Best Practices: Understand your consistency needs](/spicedb/best-practices#understand-your-consistency-needs) +- [Best Practices: Use ZedTokens and "At Least As Fresh"](/spicedb/best-practices#use-zedtokens-and-at-least-as-fresh-for-best-caching) diff --git a/app/spicedb/getting-started/discovering-spicedb/page.mdx b/app/spicedb/getting-started/discovering-spicedb/page.mdx index bd9eb976..94c6ff4c 100644 --- a/app/spicedb/getting-started/discovering-spicedb/page.mdx +++ b/app/spicedb/getting-started/discovering-spicedb/page.mdx @@ -75,7 +75,7 @@ Features that distinguish SpiceDB from other systems include: - Deep observability with [Prometheus] metrics, [pprof] profiles, structured logging, and [OpenTelemetry] tracing [gRPC]: https://buf.build/authzed/api/docs/main:authzed.api.v1 -[HTTP/JSON]: https://app.swaggerhub.com/apis-docs/authzed/authzed/1.0 +[HTTP/JSON]: /spicedb/api/http-api [per request]: https://docs.authzed.com/reference/api-consistency [New Enemy Problem]: https://authzed.com/blog/new-enemies/ [schema language]: https://docs.authzed.com/guides/schema diff --git a/app/spicedb/modeling/attributes/page.mdx b/app/spicedb/modeling/attributes/page.mdx index 5b0ea522..9f737697 100644 --- a/app/spicedb/modeling/attributes/page.mdx +++ b/app/spicedb/modeling/attributes/page.mdx @@ -50,7 +50,7 @@ To enable document editing, you need to establish a relationship that connects a Wildcards are adequate for most binary attribute scenarios; however, wildcards are not currently - supported by [Authzed Materialize](/authzed/concepts/authzed-materialize). Those who plan to use + supported by [Authzed Materialize](/materialize/getting-started/overview). Those who plan to use Materialize should use loop relationships for binary attributes. diff --git a/app/spicedb/modeling/validation-testing-debugging/page.mdx b/app/spicedb/modeling/validation-testing-debugging/page.mdx index 8ceda168..3ba7e4b3 100644 --- a/app/spicedb/modeling/validation-testing-debugging/page.mdx +++ b/app/spicedb/modeling/validation-testing-debugging/page.mdx @@ -94,9 +94,9 @@ Below is an example of configuring a Check Watch: Watches can show any of the following states: -- ✅ Permission Allowed +- Permission Allowed - ❔ Permission Caveated -- ❌ Permission Denied +- Permission Denied - ⚠️ Invalid Check ![check-watches](/images/check-watches.png) diff --git a/app/spicedb/ops/performance/page.mdx b/app/spicedb/ops/performance/page.mdx index 50a83708..919370ac 100644 --- a/app/spicedb/ops/performance/page.mdx +++ b/app/spicedb/ops/performance/page.mdx @@ -90,7 +90,7 @@ spicedb serve \ If Materialize is running, SpiceDB can dispatch sub-queries to Materialize, which can significantly speed up permission checks. -[Materialize]: /authzed/concepts/authzed-materialize +[Materialize]: /materialize/getting-started/overview ## By enabling the schema cache @@ -128,7 +128,7 @@ See the [load testing guide](/spicedb/ops/load-testing#spicedb-quantization-perf For PostgreSQL, CockroachDB, and MySQL datastores, connection pool sizing significantly impacts performance under load. Key flags include `--datastore-conn-pool-read-max-open`, `--datastore-conn-pool-write-max-open`, and the corresponding min and jitter settings. -See the [datastores reference](/spicedb/concepts/datastores) for the full list of connection pool flags and defaults, and the [best practices guide](/best-practices#tune-connections-to-datastores) for sizing recommendations. +See the [datastores reference](/spicedb/concepts/datastores) for the full list of connection pool flags and defaults, and the [best practices guide](/spicedb/best-practices#tune-connections-to-datastores) for sizing recommendations. ## By tuning the transaction overlap strategy (CockroachDB only) diff --git a/app/spicedb/ops/postgres-fdw/page.mdx b/app/spicedb/ops/postgres-fdw/page.mdx index ac7b529e..9edb7a3d 100644 --- a/app/spicedb/ops/postgres-fdw/page.mdx +++ b/app/spicedb/ops/postgres-fdw/page.mdx @@ -527,7 +527,7 @@ WHERE resource_type = 'document' ### Large Datasets -For super-fast joins or checks on large datasets, consider [AuthZed Materialize](/authzed/concepts/authzed-materialize). +For super-fast joins or checks on large datasets, consider [AuthZed Materialize](/materialize/getting-started/overview). Once set up, Materialize works seamlessly with the FDW with no SQL changes required to your queries. ## Troubleshooting @@ -576,7 +576,7 @@ If queries are slow: 2. Review your datastore performance (especially important for large datasets) 3. Consider if your queries can be optimized (e.g., using specific resource IDs instead of lookups) 4. Use cursors for large result sets instead of fetching all rows at once -5. For super-fast performance on large datasets, consider [AuthZed Materialize](/authzed/concepts/authzed-materialize), which works seamlessly with the FDW +5. For super-fast performance on large datasets, consider [AuthZed Materialize](/materialize/getting-started/overview), which works seamlessly with the FDW ## Security Considerations diff --git a/components/banner.tsx b/components/banner.tsx index fa3b7951..675dca1e 100644 --- a/components/banner.tsx +++ b/components/banner.tsx @@ -1,19 +1,52 @@ "use client"; import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; -export default function Banner() { +/* Announcement bar — terminal style matching the marketing site + (projects/web AnnouncementBar): sand-300 braille cursor, mono copy, a + bracketed CTA, and a right-aligned GitHub repo + star. Styling lives in + globals.css under `.docs-announce`. The braille cursor runs a short burst on + load, then rests; hovering the bar runs it again. */ +export default function Banner({ stars = "5.7k" }: { stars?: string }) { const pathname = usePathname(); const isCommercial = pathname?.startsWith("/authzed/"); + const [spin, setSpin] = useState(true); - return isCommercial ? ( - - 📄 Have you read Google's Zanzibar paper? We annotated it with additional context and - comparisons with SpiceDB ↗ - - ) : ( - - SpiceDB is 100% open source. Please help us by starring our GitHub repo. ↗ - + useEffect(() => { + const t = setTimeout(() => setSpin(false), 6000); + return () => clearTimeout(t); + }, []); + + return ( +
setSpin(true)} + onMouseLeave={() => setSpin(false)} + > +
); } diff --git a/components/content-status.css b/components/content-status.css new file mode 100644 index 00000000..837c7312 --- /dev/null +++ b/components/content-status.css @@ -0,0 +1,46 @@ +/* Content review banner — flags pages whose content is new/modified vs + origin/main so the team can spot what to review. Theme-adaptive: + darker text on light, lighter on dark. */ +.content-status { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; + margin: 0 0 1.5rem; + padding: 0.5rem 0.85rem; + border: 1px solid; + border-radius: 8px; + font-size: 0.85rem; + line-height: 1.4; +} +.content-status .content-status-dot { + font-size: 0.65em; +} +.content-status .content-status-label { + font-weight: 600; +} +.content-status .content-status-note { + opacity: 0.7; +} +.content-status .content-status-link { + margin-left: auto; + text-decoration: underline; + opacity: 0.85; + color: inherit; +} +.content-status.is-new { + border-color: hsl(146 45% 38% / 0.5); + background: hsl(146 45% 45% / 0.12); + color: hsl(146 55% 26%); +} +.content-status.is-updated { + border-color: hsl(38 72% 45% / 0.5); + background: hsl(38 72% 50% / 0.12); + color: hsl(32 70% 30%); +} +.dark .content-status.is-new { + color: hsl(146 50% 78%); +} +.dark .content-status.is-updated { + color: hsl(40 85% 78%); +} diff --git a/components/content-status.tsx b/components/content-status.tsx new file mode 100644 index 00000000..91a688c7 --- /dev/null +++ b/components/content-status.tsx @@ -0,0 +1,34 @@ +"use client"; + +import "./content-status.css"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import changed from "@/lib/changed-pages.json"; + +type Entry = { status: "new" | "updated" }; +const MANIFEST = changed as Record; + +/* Review aid: if the current page's content is new or modified vs the published + base (origin/main), show a banner so the team can spot what to review. Driven + by lib/changed-pages.json (generated by scripts/build-changed-manifest.mjs). + Renders nothing on unchanged pages. */ +export function ContentStatus() { + const pathname = usePathname() || "/"; + const route = pathname.replace(/\/+$/, "") || "/"; + const entry = MANIFEST[route]; + if (!entry) return null; + + const isNew = entry.status === "new"; + return ( +
+ + {isNew ? "New page" : "Updated content"} + — changed on this branch, for review + + all changes + +
+ ); +} diff --git a/components/cta.tsx b/components/cta.tsx index 1e383f97..d955a623 100644 --- a/components/cta.tsx +++ b/components/cta.tsx @@ -2,22 +2,12 @@ import { Button } from "@/components/ui/button"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +// "Book a demo" now lives in the top nav (app/layout.tsx) so it's persistent +// rather than docked beside a short page TOC. The rail keeps the Cloud +// self-serve prompt, which pairs with the Feedback widget below it. export function TocCTA() { - const pathname = usePathname(); - const isCommercial = pathname?.startsWith("/authzed/"); - - return isCommercial ? ( -
-
Explore your use case
- - - -
- ) : ( + return (
AuthZed Cloud
Hosted, self-service SpiceDB
diff --git a/components/docs-home-shell.tsx b/components/docs-home-shell.tsx new file mode 100644 index 00000000..9b2e4c53 --- /dev/null +++ b/components/docs-home-shell.tsx @@ -0,0 +1,10 @@ +import { getReleases, getWhatsNew } from "@/lib/home-data"; +import { DocsHome } from "@/components/docs-home"; + +/* Server wrapper: fetches the live release + what's-new feeds (ISR-cached in + lib/home-data.ts) and hands them to the client DocsHome as serializable + props. Keeps the presentational component free of data-fetching. */ +export async function DocsHomeShell() { + const [releases, whatsNew] = await Promise.all([getReleases(), getWhatsNew()]); + return ; +} diff --git a/components/docs-home.css b/components/docs-home.css new file mode 100644 index 00000000..4ddb1c97 --- /dev/null +++ b/components/docs-home.css @@ -0,0 +1,908 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); + +/* =========================================================================== + AuthZed Docs home ("Editorial Pro") — scoped styles + Sandworm tokens. + Everything is scoped under .docs-home so it cannot leak into the rest of + the docs site. Auto light/dark: LIGHT is the base, DARK overrides under + `html.dark` (the class next-themes sets — which also resolves "system", so + the page follows the OS / site toggle automatically). All color identity + flows through these tokens, so every rule below adapts without edits. + (Was dark-only; semantic light theme added 2026-06-16.) + ======================================================================== */ + +.docs-home { + /* ---- Stone — LIGHT (purple-tinted: bg light, text dark, hairlines pale) ---- */ + --stone-025: 280 25% 8%; + --stone-100: 279 18% 14%; + --stone-200: 279 14% 20%; + --stone-300: 280 10% 30%; + --stone-400: 280 8% 40%; + --stone-500: 275 7% 50%; + --stone-600: 280 7% 58%; + --stone-700: 280 10% 82%; + --stone-800: 285 14% 88%; + --stone-850: 290 22% 91%; + --stone-900: 300 24% 94%; + --stone-950: 300 30% 96%; + --stone-975: 300 40% 98%; + /* ---- Accents — LIGHT (darker shades so they read on a light ground) ---- */ + --magenta-300: 316 50% 45%; + --magenta-400: 316 48% 47%; + --magenta-500: 316 50% 44%; + --magenta-600: 316 52% 40%; + --magenta-950: 315 33.3% 7.1%; + --red-400: 352 70% 48%; + --teal-300: 178 40% 32%; + --teal-400: 178 38% 35%; + --teal-500: 178 35% 33%; + --teal-700: 178 30% 26%; + --violet-400: 253 60% 52%; + --violet-500: 253 62% 50%; + --violet-700: 255 50% 46%; + --sand-300: 30 75% 42%; + --blue-500: 200 75% 40%; + /* ---- Shared structural tokens ---- */ + --gradient-brand-warm: linear-gradient( + to right, + hsl(var(--sand-300)), + hsl(var(--red-400)), + hsl(var(--violet-500)) + ); + --radius-pill: 9999px; + --radius-xl: 14px; + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + + background: + radial-gradient(120% 80% at 88% -10%, hsl(var(--magenta-500) / 0.08), transparent 60%), + hsl(var(--stone-975)); + color: hsl(var(--stone-025)); + font-family: var(--font-sans); + font-weight: 300; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + font-variant-numeric: tabular-nums lining-nums; + line-height: 1.6; + min-height: 100%; +} + +/* ---- DARK (Sandworm's native register) — next-themes adds .dark on ---- */ +html.dark .docs-home { + --stone-025: 300 6.7% 97.1%; + --stone-100: 270 3% 87.1%; + --stone-200: 270 5.4% 78%; + --stone-300: 283 4% 66.1%; + --stone-400: 280 3.9% 55.1%; + --stone-500: 275 5% 47.1%; + --stone-600: 280 6.1% 38.8%; + --stone-700: 279 8.9% 31%; + --stone-800: 279 11.5% 23.9%; + --stone-850: 276 18.5% 15.9%; + --stone-900: 278 28.6% 11%; + --stone-950: 280 36.6% 8%; + --stone-975: 282 50% 3.9%; + --magenta-300: 318 41.2% 70%; + --magenta-400: 317 40.2% 60%; + --magenta-500: 316 39.2% 51%; + --magenta-600: 316 42% 45%; + --magenta-950: 315 33.3% 7.1%; + --red-400: 355 91% 73.9%; + --teal-300: 175 27.5% 72.9%; + --teal-400: 176 28% 56%; + --teal-500: 177 28.2% 45.9%; + --teal-700: 178 30% 30%; + --violet-400: 253 76.5% 70%; + --violet-500: 253 73.4% 63.1%; + --violet-700: 255 58% 43.9%; + --sand-300: 28 100% 72%; + --blue-500: 195 73.4% 63.1%; + + background: + radial-gradient(120% 80% at 88% -10%, hsl(var(--magenta-950) / 0.55), transparent 60%), + hsl(var(--stone-975)); +} + +/* Resets so Nextra's MDX typography doesn't bleed in */ +.docs-home :where(h1, h2, h3, p, ul) { + margin: 0; +} +.docs-home a { + color: inherit; + text-decoration: none; +} +.docs-home *, +.docs-home *::before, +.docs-home *::after { + box-sizing: border-box; +} + +/* Content frame — matches the Nextra navbar exactly (max-width + 1.5rem + inline padding) so the masthead logo/nav share a left & right edge with + the hero, index, and colophon. --home-max is also applied to the navbar. */ +.docs-home { + --home-max: 1180px; +} +/* Frame padding lives on a shared ancestor so the navbar override can read it + too — keeps the masthead logo/nav inset to the same line as the content. */ +body:has(.docs-home) { + --home-frame-pad: clamp(32px, 5vw, 80px); +} + +.docs-home .page { + position: relative; + max-width: var(--home-max); + margin: 0 auto; + padding: 0 var(--home-frame-pad); +} +/* Manuscript margin rules sit at the OUTER frame edge; content is padded + well inside them so text never touches the stroke. */ +.docs-home .page::before, +.docs-home .page::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: hsl(var(--stone-800) / 0.5); + pointer-events: none; +} +.docs-home .page::before { + left: 0; +} +.docs-home .page::after { + right: 0; +} +@media (max-width: 600px) { + .docs-home .page::before, + .docs-home .page::after { + display: none; + } +} + +/* ---------------- Hero ---------------- */ +.docs-home .hero { + display: grid; + grid-template-columns: 1.08fr 0.92fr; + gap: clamp(28px, 4vw, 60px); + align-items: center; + padding: clamp(30px, 5vh, 60px) 0 clamp(30px, 4.5vh, 56px); + position: relative; +} +.docs-home .hero-text { + min-width: 0; +} + +/* The relationship graph as an ambient field — straight edges, a dense lattice + of objects with many connections, signals firing along relations, and a hard + radial fade at every border so the graph reads as continuing past the frame. */ +.docs-home .hero-graph { + position: relative; + align-self: center; + width: 100%; + max-width: 460px; + margin-inline: auto; + -webkit-mask-image: radial-gradient(72% 72% at 50% 48%, #000 30%, transparent 78%); + mask-image: radial-gradient(72% 72% at 50% 48%, #000 30%, transparent 78%); +} +.docs-home .hero-graph svg { + width: 100%; + height: auto; + display: block; + overflow: visible; +} + +/* relations — faint teal field. Each edge fades on its own clock (timing set + inline) so the visible web is always a different set of grey traces. */ +.docs-home .g-mesh line { + stroke: hsl(var(--teal-500)); + stroke-width: 1; + opacity: 0.12; +} + +/* idle objects — dim teal */ +.docs-home .g-obj { + fill: hsl(var(--teal-500)); + opacity: 0.3; +} + +/* a check — accent colour set per group via --acc; layered glow + signal. + The coloured trace is revealed by the beam (drawn on in sync), not popped in + all at once; each check fades in only during its turn (sequenced below). */ +.docs-home .g-check { + opacity: 0.5; +} +.docs-home .g-trace-halo { + fill: none; + stroke: hsl(var(--acc)); + stroke-width: 4.5; + opacity: 0.2; + stroke-linecap: round; + stroke-linejoin: round; +} +.docs-home .g-trace { + fill: none; + stroke: hsl(var(--acc)); + stroke-width: 1.3; + opacity: 0.5; + stroke-linecap: round; + stroke-linejoin: round; +} +.docs-home .g-trace-spark { + fill: none; + stroke: hsl(var(--acc)); + stroke-width: 2; + opacity: 0; + stroke-linecap: round; + stroke-linejoin: round; +} +.docs-home .g-tnode { + fill: hsl(var(--acc)); + --lit: 0.5; + opacity: var(--lit); +} +.docs-home .g-tnode--end { + --lit: 0.85; +} +.docs-home .g-tcore { + fill: hsl(var(--stone-025)); + --lit: 0.85; + opacity: var(--lit); +} + +@media (prefers-reduced-motion: no-preference) { + /* idle relations breathe in and out, each on its own (inline) clock */ + .docs-home .g-mesh line { + opacity: 0.02; + animation-name: g-edgefade; + animation-iteration-count: infinite; + animation-timing-function: ease-in-out; + } + @keyframes g-edgefade { + 0%, + 100% { + opacity: 0.02; + } + 50% { + opacity: 0.24; + } + } + + /* each check group is dark until its 3s slot of the 18s cycle */ + .docs-home .g-check { + opacity: 0; + animation: g-check-life 18s linear infinite; + animation-delay: calc(var(--i) * -3s); + } + @keyframes g-check-life { + 0% { + opacity: 0; + } + 1.5% { + opacity: 1; + } + 14% { + opacity: 1; + } + 18% { + opacity: 0; + } + 100% { + opacity: 0; + } + } + + /* body + halo are DRAWN ON behind the beam — only coloured once swept over */ + .docs-home .g-trace, + .docs-home .g-trace-halo { + stroke-dasharray: 100 100; + animation: g-draw 18s linear infinite; + animation-delay: calc(var(--i) * -3s); + } + @keyframes g-draw { + 0% { + stroke-dashoffset: 100; + } + 1.5% { + stroke-dashoffset: 100; + } + 11% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: 0; + } + } + + /* the beam — one bright head; gap 1000 >> path so it never wraps round */ + .docs-home .g-trace-spark { + stroke-dasharray: 14 1000; + animation: g-beam 18s linear infinite; + animation-delay: calc(var(--i) * -3s); + } + @keyframes g-beam { + 0% { + stroke-dashoffset: 14; + opacity: 0; + } + 1.5% { + stroke-dashoffset: 14; + opacity: 1; + } + 11% { + stroke-dashoffset: -100; + opacity: 1; + } + 13%, + 100% { + stroke-dashoffset: -100; + opacity: 0; + } + } + + /* each node lights only as the beam reaches it (delay ∝ its path fraction) */ + .docs-home .g-tnode, + .docs-home .g-tcore { + opacity: 0; + animation: g-litnode 18s linear infinite; + animation-delay: calc(var(--i) * -3s + 0.27s + var(--f) * 1.71s); + } + @keyframes g-litnode { + 0% { + opacity: 0; + } + 1.2% { + opacity: var(--lit); + } + 100% { + opacity: var(--lit); + } + } + + .docs-home .g-tw { + animation: g-twinkle 4s ease-in-out infinite; + } + @keyframes g-twinkle { + 0%, + 100% { + opacity: 0.14; + } + 50% { + opacity: 0.4; + } + } +} +@media (max-width: 900px) { + /* Don't let the graph become a stacked row that shoves the docs index down. + Keep a single text column and "push off" the graph: absolutely positioned, + bled off the right and sat behind the text, so it adds ZERO flow height. + Column/stack order is unchanged — the graph never reflows below the text. */ + .docs-home .hero { + grid-template-columns: 1fr; + gap: 0; + overflow: hidden; + } + .docs-home .hero-text { + position: relative; + z-index: 1; + } + .docs-home .hero-graph { + position: absolute; + top: 50%; + right: -6%; + transform: translateY(-50%); + width: min(58%, 360px); + max-width: none; + margin: 0; + z-index: 0; + opacity: 0.45; + pointer-events: none; + } +} +/* On phones there's no room beside the text — drop the decoration entirely + rather than letting it sit under the copy. */ +@media (max-width: 560px) { + .docs-home .hero-graph { + display: none; + } +} +.docs-home .hero h1 { + max-width: 21ch; + font-size: clamp(2.3rem, 4.2vw, 3.8rem); + font-weight: 200; + line-height: 1.04; + letter-spacing: -0.025em; + color: hsl(var(--stone-025)); + hanging-punctuation: first; +} +.docs-home .hero h1 em { + font-style: normal; + font-weight: 600; + color: hsl(var(--magenta-500)); +} +.docs-home .hero .standfirst { + margin: 30px 0 0; + max-width: 48ch; + font-size: clamp(1rem, 1.5vw, 1.18rem); + font-weight: 300; + line-height: 1.62; + color: hsl(var(--stone-300)); +} +.docs-home .hero .standfirst code { + font-family: var(--font-mono); + font-size: 0.82em; + color: hsl(var(--sand-300)); + background: hsl(var(--stone-850) / 0.6); + padding: 1px 6px; + border-radius: 4px; +} +.docs-home .hero-tools { + margin-top: 38px; + display: flex; + align-items: center; + gap: 22px; + flex-wrap: wrap; +} +.docs-home .hero-search { + display: inline-flex; + align-items: center; + gap: 11px; + min-width: min(420px, 80vw); + border: 1px solid hsl(var(--stone-700)); + border-radius: 10px; + padding: 13px 14px; + background: hsl(var(--stone-950) / 0.4); + color: hsl(var(--stone-400)); + font-size: 14px; + cursor: pointer; + text-align: left; + font-family: var(--font-sans); + transition: + border-color 220ms ease, + background 220ms ease; +} +.docs-home .hero-search:hover { + border-color: hsl(var(--stone-500)); + background: hsl(var(--stone-900) / 0.5); +} +.docs-home .hero-search .ico { + color: hsl(var(--stone-500)); + display: flex; +} +.docs-home .hero-search .grow { + flex: 1; +} +.docs-home .hero-search kbd { + font-family: var(--font-mono); + font-size: 11px; + color: hsl(var(--stone-400)); + border: 1px solid hsl(var(--stone-700)); + border-radius: 5px; + padding: 2px 6px; +} +.docs-home .hero-aside { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + color: hsl(var(--stone-500)); + text-transform: uppercase; +} +/* Primary CTA — Sandworm button (marketing `default` variant): mono, high- + contrast neutral fill, magenta on hover. Themed tokens keep it light-on-dark + / dark-on-light. */ +.docs-home .hero-btn { + display: inline-flex; + align-items: center; + gap: 10px; + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 500; + letter-spacing: 0.01em; + color: hsl(var(--stone-950)); + background: hsl(var(--stone-025)); + padding: 13px 20px; + border-radius: 10px; + white-space: nowrap; + transition: + background 0.2s ease, + color 0.2s ease, + transform 0.2s ease; +} +.docs-home .hero-btn:hover { + background: hsl(var(--magenta-600)); + color: #fff; + transform: translateY(-1px); +} + +/* ---------------- What's new / Latest releases (editorial) ---------------- */ +.docs-home .activity { + display: grid; + grid-template-columns: 1fr 1fr; + gap: clamp(40px, 6vw, 88px); + padding: clamp(44px, 6vh, 76px) 0 8px; +} +@media (max-width: 760px) { + .docs-home .activity { + grid-template-columns: 1fr; + gap: 44px; + } +} +.docs-home .activity-label { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: hsl(var(--stone-300)); +} +.docs-home .activity-sub { + margin: 7px 0 0; + font-size: 14px; + color: hsl(var(--stone-500)); +} + +.docs-home .whatsnew, +.docs-home .releases { + margin-top: 18px; +} +.docs-home .wn-row { + display: grid; + grid-template-columns: 116px 1fr; + gap: 16px; + align-items: baseline; + padding: 12px 0; + border-top: 1px solid hsl(var(--stone-850)); +} +.docs-home .wn-row:first-child { + border-top: 0; +} +.docs-home .wn-date { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.03em; + text-transform: uppercase; + color: hsl(var(--stone-500)); + white-space: nowrap; +} +.docs-home .wn-title { + font-size: 14.5px; + color: hsl(var(--stone-200)); + transition: color 0.18s ease; +} +.docs-home .wn-row:hover .wn-title { + color: hsl(var(--magenta-400)); +} + +.docs-home .rel-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding: 13px 0; + border-top: 1px solid hsl(var(--stone-850)); +} +.docs-home .rel-row:first-child { + border-top: 0; +} +.docs-home .rel-ver { + font-family: var(--font-mono); + font-size: 15px; + color: hsl(var(--stone-100)); + display: flex; + align-items: center; + gap: 10px; +} +.docs-home .rel-tag { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: hsl(var(--teal-400)); + border: 1px solid hsl(var(--teal-700)); + border-radius: var(--radius-pill); + padding: 2px 7px; +} +.docs-home .rel-meta { + font-size: 13px; + color: hsl(var(--stone-500)); + text-align: right; +} +.docs-home .rel-row:hover .rel-ver { + color: hsl(var(--teal-300)); +} + +/* ---------------- The Index ---------------- */ +.docs-home .index-head { + display: flex; + align-items: baseline; + justify-content: space-between; + padding-bottom: 14px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.16em; + text-transform: uppercase; + color: hsl(var(--stone-500)); +} +.docs-home .entry { + --accent: var(--magenta-500); + position: relative; + border-top: 1px solid hsl(var(--stone-800)); + padding: clamp(26px, 3.4vw, 40px) 0; + transition: border-color 280ms ease; +} +.docs-home .entry:last-of-type { + border-bottom: 1px solid hsl(var(--stone-800)); +} +.docs-home .entry::before { + content: ""; + position: absolute; + top: -1px; + left: 0; + height: 1px; + width: 100%; + transform: scaleX(0); + transform-origin: left; + background: hsl(var(--accent)); + transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1); +} +.docs-home .entry:hover, +.docs-home .entry:focus-within { + border-top-color: transparent; +} +.docs-home .entry:hover::before, +.docs-home .entry:focus-within::before { + transform: scaleX(1); +} +.docs-home .entry-row { + display: grid; + grid-template-columns: clamp(56px, 5vw, 72px) 1fr auto; + align-items: baseline; + gap: clamp(18px, 2vw, 30px); +} +.docs-home .entry-num { + /* scales near the name's rate so the size ratio stays stable across widths; + nowrap so the two digits never stack vertically in a tight cell */ + font-size: clamp(2.1rem, 3.2vw, 2.7rem); + font-weight: 200; + line-height: 1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: hsl(var(--accent)); /* color-code each surface by its accent */ +} +.docs-home .entry-main { + min-width: 0; +} +.docs-home .entry-name { + display: flex; + align-items: center; + gap: 14px; + font-size: clamp(1.5rem, 2.6vw, 2.1rem); + font-weight: 300; + letter-spacing: -0.02em; + line-height: 1.1; +} +.docs-home .entry-title { + color: hsl(var(--stone-025)); +} +.docs-home .entry-title::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; +} +.docs-home .entry-name .pill { + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: hsl(var(--violet-400)); + border: 1px solid hsl(var(--violet-700)); + border-radius: var(--radius-pill); + padding: 2px 8px; + transform: translateY(-2px); +} +.docs-home .entry-desc { + margin: 9px 0 0; + font-size: 14.5px; + font-weight: 300; + color: hsl(var(--stone-400)); + max-width: 54ch; +} +.docs-home .entry-more { + display: grid; + grid-template-rows: 1fr; /* always open — sublinks stay visible for scanning */ +} +.docs-home .entry-more > div { + overflow: hidden; +} +.docs-home .entry:hover .entry-more, +.docs-home .entry:focus-within .entry-more { + grid-template-rows: 1fr; +} +.docs-home .entry-sublinks { + display: flex; + flex-wrap: wrap; + gap: 6px 22px; + padding-top: 16px; + margin-top: 4px; +} +.docs-home .entry:hover .entry-sublinks, +.docs-home .entry:focus-within .entry-sublinks { + opacity: 1; + transform: none; +} +.docs-home .entry-sublinks a { + position: relative; + z-index: 2; + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.02em; + color: hsl(var(--stone-400)); + padding: 2px 0; + border-bottom: 1px solid transparent; + transition: + color 180ms ease, + border-color 180ms ease; +} +.docs-home .entry-sublinks a:hover { + color: hsl(var(--accent)); + border-bottom-color: hsl(var(--accent) / 0.5); +} +.docs-home .entry-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 14px; + text-align: right; + white-space: nowrap; +} +.docs-home .entry-tag { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: hsl(var(--stone-500)); + transition: color 280ms ease; +} +.docs-home .entry:hover .entry-tag, +.docs-home .entry:focus-within .entry-tag { + color: hsl(var(--stone-300)); +} +.docs-home .entry-arrow { + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid hsl(var(--stone-700)); + display: grid; + place-items: center; + color: hsl(var(--stone-400)); + transition: + border-color 280ms ease, + color 280ms ease, + transform 280ms ease; +} +.docs-home .entry:hover .entry-arrow, +.docs-home .entry:focus-within .entry-arrow { + border-color: hsl(var(--accent)); + color: hsl(var(--accent)); + transform: translateX(4px); +} +@media (max-width: 640px) { + .docs-home .entry-row { + grid-template-columns: 1fr; + gap: 4px; + } + .docs-home .entry-num { + font-size: 1.4rem; + } + .docs-home .entry-meta { + flex-direction: row; + align-items: center; + justify-content: space-between; + margin-top: 14px; + width: 100%; + } +} + +/* ---------------- Colophon ---------------- */ +.docs-home .colophon { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; + padding: 48px 0 72px; + margin-top: 36px; +} +.docs-home .colophon p { + max-width: 46ch; + font-size: 15px; + font-weight: 300; + color: hsl(var(--stone-400)); +} +.docs-home .colophon p b { + color: hsl(var(--stone-200)); + font-weight: 400; +} +.docs-home .colophon code { + font-family: var(--font-mono); + font-size: 0.85em; + color: hsl(var(--sand-300)); +} +.docs-home .colophon .links { + display: flex; + gap: 20px; +} +.docs-home .colophon .links a { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: hsl(var(--stone-500)); + transition: color 200ms ease; +} +.docs-home .colophon .links a:hover { + color: hsl(var(--stone-200)); +} + +@media (prefers-reduced-motion: reduce) { + .docs-home .entry-more { + grid-template-rows: 1fr; + } + .docs-home .entry-sublinks { + opacity: 1; + transform: none; + } + .docs-home * { + transition-duration: 0.01ms !important; + } +} + +/* =========================================================================== + Home takeover — tame Nextra's default chrome on the landing route ONLY. + Scoped via :has(.docs-home); this stylesheet is route-split to the home. + ======================================================================== */ +body:has(.docs-home) { + overflow-x: clip; +} + +/* Full-bleed: neutralize the content article padding + width cap, then break + .docs-home out to the viewport. `article:has(.docs-home)` matches ONLY + Nextra's outer content article — never the .entry articles inside. */ +article:has(.docs-home) { + padding: 0 !important; + max-width: none !important; +} +.docs-home { + margin-left: calc(50% - 50vw); + margin-right: calc(50% - 50vw); +} + +/* Hide the copy-page / page-actions header (a direct
child of the + content article; our content lives under a
, so it's preserved) */ +article:has(.docs-home) > div { + display: none !important; +} + +/* Hide Nextra's footer block (theme toggle + copyright) — our .colophon stays */ +body:has(.docs-home) [class~="x:bg-gray-100"] { + display: none !important; +} + +/* Keep Nextra's navbar background/blur so scrolled content doesn't show + through it. The blur layer is themed (--nextra-bg), so it stays seamless with + the editorial page at the top while staying opaque enough on scroll. Only the + hard border is softened. */ +body:has(.docs-home) .nextra-navbar { + border-color: transparent !important; + box-shadow: none !important; +} +/* NOTE: the navbar is intentionally NOT re-constrained on the landing route. + It keeps Nextra's default width/padding (90rem / 1.5rem) so the top nav is + identical on the home page and doc pages — no width/padding jump between + routes. (Was max-width:1180px + frame padding, which read as extra padding + on the landing nav that vanished on doc pages.) */ diff --git a/components/docs-home.tsx b/components/docs-home.tsx new file mode 100644 index 00000000..6b21dd66 --- /dev/null +++ b/components/docs-home.tsx @@ -0,0 +1,484 @@ +"use client"; + +import "./docs-home.css"; +import Link from "next/link"; +import type { ElementType, ReactNode } from "react"; +import type { Release, WhatsNewItem } from "@/lib/home-data"; + +/* Internal links go through next/link so the deploy's basePath (/docs) is + applied; external links fall back to a plain anchor. */ +function Lnk({ + href, + className, + children, +}: { + href: string; + className?: string; + children?: ReactNode; +}) { + const Tag = (href.startsWith("/") ? Link : "a") as ElementType; + return ( + + {children} + + ); +} + +/* --------------------------------------------------------------------------- + AuthZed Docs — home / welcome experience ("Editorial Pro") + The docs index as an exquisitely typeset technical manual: oversized tabular + numerals, a brand-gradient spine, print-grade type, and entries that expand + on hover/focus to reveal their real sub-navigation. Pure-CSS disclosure — + content is fully visible without JS and under reduced-motion. + Styling + Sandworm tokens are scoped under `.docs-home` in docs-home.css. + --------------------------------------------------------------------------- */ + +type Sublink = { label: string; href: string }; +type Entry = { + num: string; + name: string; + href: string; + desc: string; + tag: string; + accent: string; // a Sandworm accent var, e.g. "var(--magenta-500)" + badge?: string; + sublinks: Sublink[]; +}; + +const ENTRIES: Entry[] = [ + { + num: "01", + name: "SpiceDB", + href: "/spicedb/getting-started/discovering-spicedb", + desc: "The open–source Zanzibar database — schema language, modeling, operations, and the gRPC / HTTP APIs.", + tag: "Open source", + accent: "var(--magenta-500)", + sublinks: [ + { label: "Getting Started", href: "/spicedb/getting-started/discovering-spicedb" }, + { label: "Concepts", href: "/spicedb/concepts/zanzibar" }, + { label: "Modeling", href: "/spicedb/modeling/developing-a-schema" }, + { label: "Operations", href: "/spicedb/ops/operator" }, + { label: "API Reference", href: "/spicedb/api/http-api" }, + { label: "Best Practices", href: "/spicedb/best-practices" }, + ], + }, + { + num: "02", + name: "Managed SpiceDB", + href: "/authzed/guides/picking-a-product", + desc: "Managed, hosted SpiceDB — Cloud and Dedicated, private networking, audit logging, and more.", + tag: "Managed platform", + accent: "var(--teal-500)", + sublinks: [ + { label: "Guides", href: "/authzed/guides/picking-a-product" }, + { label: "Concepts", href: "/authzed/concepts/audit-logging" }, + { label: "API Reference", href: "/authzed/api/http-api" }, + ], + }, + { + num: "03", + name: "Materialize", + href: "/materialize/getting-started/overview", + desc: "Precomputed permissions and change streams — materialized permission sets for search, filtering, and reverse indexes at scale.", + tag: "Managed platform", + accent: "var(--violet-500)", + badge: "New", + sublinks: [ + { label: "Overview", href: "/materialize/getting-started/overview" }, + { label: "Concepts", href: "/materialize/concepts/permission-sets" }, + { label: "API Reference", href: "/materialize/api/watch-permission-sets" }, + ], + }, + { + num: "04", + name: "MCP", + href: "/mcp", + desc: "Model Context Protocol servers — connect AuthZed and SpiceDB to AI agents and assistants.", + tag: "Guides & reference", + accent: "var(--blue-500)", + sublinks: [ + { label: "Overview", href: "/mcp" }, + { label: "AuthZed Server", href: "/mcp" }, + { label: "SpiceDB Dev Server", href: "/mcp" }, + ], + }, +]; + +const ArrowIcon = () => ( + +); + +const SearchIcon = ({ size = 16 }: { size?: number }) => ( + +); + +/* Hero texture — the relationship graph as an ambient field. Straight edges, a + dense jittered lattice of objects with many connections, several relations + lighting up as signals travel them, and a hard radial fade at every border so + it dissolves off-frame — implying the graph keeps going. Deterministic layout + (seeded, no Math.random) keeps server/client markup identical. aria-hidden. */ +type GPoint = [number, number]; + +function gseed(i: number, s: number): number { + const x = Math.sin(i * 127.1 + s * 311.7 + 7.13) * 43758.5453; + return x - Math.floor(x); +} + +// organic jittered field spilling past the 0–380 viewBox so the outer ring +// dissolves under the radial mask — free-floating, no grid (a snapped grid +// read too stiff) +const G_NODES: GPoint[] = (() => { + const cols = 8, + rows = 8, + span = 450, + origin = -35, + out: GPoint[] = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const i = r * cols + c; + const x = origin + (c / (cols - 1)) * span + (gseed(i, 1) - 0.5) * 52; + const y = origin + (r / (rows - 1)) * span + (gseed(i, 2) - 0.5) * 52; + out.push([+x.toFixed(1), +y.toFixed(1)]); + } + } + return out; +})(); + +// connect each object to its 3 nearest neighbours → many straight relations +const G_EDGES: [number, number][] = (() => { + const out: [number, number][] = [], + seen = new Set(); + G_NODES.forEach((a, i) => { + G_NODES.map((b, j) => [j, (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2] as [number, number]) + .filter(([j]) => j !== i) + .sort((p, q) => p[1] - q[1]) + .slice(0, 3) + .forEach(([j]) => { + const key = i < j ? `${i}-${j}` : `${j}-${i}`; + if (!seen.has(key)) { + seen.add(key); + out.push([i, j]); + } + }); + }); + return out; +})(); + +function polyD(ids: number[]): string { + return ids.map((id, k) => `${k ? "L" : "M"}${G_NODES[id][0]} ${G_NODES[id][1]}`).join(" "); +} + +// nodes inside the visible core (off-frame nodes only exist to bleed the fade) +const G_VISIBLE = G_NODES.map((n, i) => [i, n] as [number, GPoint]).filter( + ([, n]) => n[0] > 18 && n[0] < 362 && n[1] > 18 && n[1] < 362, +); + +// a "check" — nearest-neighbour walk from a seed node, winding through the field +function walk(start: number, steps: number): number[] { + let curr = start; + const path = [curr], + used = new Set([curr]); + for (let s = 0; s < steps; s++) { + let best = -1, + bd = Infinity; + G_VISIBLE.forEach(([j]) => { + if (used.has(j)) return; + const d = (G_NODES[curr][0] - G_NODES[j][0]) ** 2 + (G_NODES[curr][1] - G_NODES[j][1]) ** 2; + if (d < bd) { + bd = d; + best = j; + } + }); + if (best < 0) break; + used.add(best); + path.push(best); + curr = best; + } + return path; +} +const nearest = (ax: number, ay: number) => + G_VISIBLE.reduce((a, b) => { + const da = (G_NODES[a][0] - ax) ** 2 + (G_NODES[a][1] - ay) ** 2; + const db = (b[1][0] - ax) ** 2 + (b[1][1] - ay) ** 2; + return db < da ? b[0] : a; + }, G_VISIBLE[0][0]); + +// six different checks of mixed length (long winders + short hops), each seeded +// from a different region and lit in its own colour — they take turns resolving +// (sequenced in CSS via --i, one 3s slot each of an 18s loop) +const CHECK_SPECS: { at: [number, number]; len: number; acc: string }[] = [ + { at: [50, 70], len: 13, acc: "var(--violet-400)" }, + { at: [330, 90], len: 4, acc: "var(--magenta-400)" }, + { at: [300, 310], len: 9, acc: "var(--blue-500)" }, + { at: [80, 300], len: 3, acc: "var(--sand-300)" }, + { at: [200, 180], len: 11, acc: "var(--red-400)" }, + { at: [250, 120], len: 5, acc: "var(--violet-400)" }, +]; +const G_CHECKS = CHECK_SPECS.map((s) => ({ + path: walk(nearest(s.at[0], s.at[1]), s.len), + acc: s.acc, +})); + +function HeroGraph() { + return ( + + ); +} + +/** Focus Nextra's navbar search so the hero search affordance works. */ +function openSearch() { + if (typeof document === "undefined") return; + const input = document.querySelector( + '.nextra-search input, input[type="search"]', + ); + if (input) { + input.focus(); + input.click(); + } +} + +export function DocsHome({ + releases, + whatsNew, +}: { + releases: Release[]; + whatsNew: WhatsNewItem[]; +}) { + return ( +
+
+
+
+
+

+ Everything you need to build with SpiceDB. +

+

+ Relationship-based access control, the Google Zanzibar way: model a{" "} + schema, write relationships, and call{" "} + CheckPermission from your code. Quickstarts, schema modeling, client + SDKs, and full gRPC and HTTP API references — for the open-source SpiceDB engine and + the managed AuthZed platform. +

+
+ + Get started with SpiceDB + + + +
+
+ +
+ +
+
+ Docs Index + Four surfaces +
+ + {ENTRIES.map((e) => ( +
+
+ {e.num} +
+

+ + {e.name} + + {e.badge && {e.badge}} +

+

{e.desc}

+
+
+
+ {e.sublinks.map((s) => ( + + {s.label} + + ))} +
+
+
+
+
+ {e.tag} + + + +
+
+
+ ))} +
+ +
+
+
What’s new
+

Recent additions across the documentation.

+
+ {whatsNew.map((n) => ( + + {n.date} + {n.title} + + ))} +
+
+
+
Latest releases
+

Recent SpiceDB open–source releases.

+
+ {releases.map((r) => ( + + + {r.ver} + {r.latest && Latest} + + {r.date} + + ))} +
+
+
+ +
+

+ Permissions modeled as a graph, not a tangle of if statements. +

+
+ GitHub + Discord + authzed.com +
+
+
+
+
+ ); +} diff --git a/components/feature-icon.css b/components/feature-icon.css new file mode 100644 index 00000000..6cea4f29 --- /dev/null +++ b/components/feature-icon.css @@ -0,0 +1,37 @@ +/* Feature-matrix marks. Theme-aware so the disc pops on either ground: + LIGHT (base) — a deeper, saturated teal→violet disc with a WHITE check. + DARK (html.dark) — the brighter Sandworm gradient with a dark check. + (The bright gradient alone washed out against white table cells.) */ + +.feature-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 9999px; + vertical-align: middle; +} + +/* Yes — light (base) */ +.feature-yes { + background: linear-gradient(135deg, hsl(177 33% 45%), hsl(178 35% 38%) 45%, hsl(253 60% 55%)); +} +.feature-yes svg { + stroke: #fff; +} +/* Yes — dark */ +html.dark .feature-yes { + background: linear-gradient(135deg, hsl(175 28% 73%), hsl(176 29% 57%) 45%, hsl(253 73% 63%)); +} +html.dark .feature-yes svg { + stroke: hsl(280 37% 8%); /* stone-950 */ +} + +/* No — red-outlined disc, red x (reads on both grounds) */ +.feature-no { + border: 2px solid hsl(351 55% 53%); /* red-600 */ +} +.feature-no svg { + stroke: hsl(351 55% 53%); +} diff --git a/components/feature-icon.tsx b/components/feature-icon.tsx new file mode 100644 index 00000000..a5222451 --- /dev/null +++ b/components/feature-icon.tsx @@ -0,0 +1,43 @@ +import "./feature-icon.css"; + +/* Feature-matrix marks for docs tables — Lucide check / x glyphs in Sandworm + colors, matching the marketing pricing comparison. Theme-aware styling lives + in feature-icon.css. Used in MDX as / (mdx-components.ts). */ + +export function Yes() { + return ( + + + + ); +} + +export function No() { + return ( + + + + ); +} diff --git a/lib/changed-pages.json b/lib/changed-pages.json new file mode 100644 index 00000000..142f94f4 --- /dev/null +++ b/lib/changed-pages.json @@ -0,0 +1,65 @@ +{ + "/authzed/guides/picking-a-product": { + "status": "updated" + }, + "/authzed/guides/postgres-fdw": { + "status": "updated" + }, + "/materialize/api/client-sdks": { + "status": "new" + }, + "/materialize/api/lookup-permission-sets": { + "status": "new" + }, + "/materialize/api/watch-permission-sets": { + "status": "new" + }, + "/materialize/concepts/hydration": { + "status": "new" + }, + "/materialize/concepts/managing-client-state": { + "status": "new" + }, + "/materialize/concepts/permission-sets": { + "status": "new" + }, + "/materialize/concepts/snapshots": { + "status": "new" + }, + "/materialize/getting-started/limitations": { + "status": "new" + }, + "/materialize/getting-started/overview": { + "status": "new" + }, + "/materialize/guides/recommended-architecture": { + "status": "new" + }, + "/materialize/guides/relational-database": { + "status": "new" + }, + "/spicedb/best-practices": { + "status": "updated" + }, + "/spicedb/concepts/querying-data": { + "status": "updated" + }, + "/spicedb/concepts/read-after-write": { + "status": "updated" + }, + "/spicedb/getting-started/discovering-spicedb": { + "status": "updated" + }, + "/spicedb/modeling/attributes": { + "status": "updated" + }, + "/spicedb/modeling/validation-testing-debugging": { + "status": "updated" + }, + "/spicedb/ops/performance": { + "status": "updated" + }, + "/spicedb/ops/postgres-fdw": { + "status": "updated" + } +} diff --git a/lib/home-data.ts b/lib/home-data.ts new file mode 100644 index 00000000..e1cab814 --- /dev/null +++ b/lib/home-data.ts @@ -0,0 +1,135 @@ +import "server-only"; + +/* Data for the docs home (components/docs-home.tsx). + Both feeds are fetched at build/ISR time and self-update on revalidate. + Each has a real, accurate fallback so the page never shows fabricated or + empty data if GitHub is unreachable or the dynamic stream is too thin. */ + +export type Release = { ver: string; date: string; latest?: boolean; href: string }; +export type WhatsNewItem = { date: string; title: string; href: string }; + +const REVALIDATE = 60 * 60 * 6; // 6h — releases/PRs don't move faster than this + +const GH_HEADERS: HeadersInit = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), +}; + +function fmtDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +// ---- Releases: SpiceDB GitHub releases (clean, fully dynamic) -------------- + +const FALLBACK_RELEASES: Release[] = [ + { + ver: "v1.53.0", + date: "May 13, 2026", + latest: true, + href: "https://github.com/authzed/spicedb/releases/tag/v1.53.0", + }, + { + ver: "v1.52.0", + date: "Apr 30, 2026", + href: "https://github.com/authzed/spicedb/releases/tag/v1.52.0", + }, + { + ver: "v1.51.1", + date: "Apr 14, 2026", + href: "https://github.com/authzed/spicedb/releases/tag/v1.51.1", + }, +]; + +export async function getReleases(): Promise { + try { + const res = await fetch("https://api.github.com/repos/authzed/spicedb/releases?per_page=10", { + headers: GH_HEADERS, + next: { revalidate: REVALIDATE }, + }); + if (!res.ok) return FALLBACK_RELEASES; + const data = (await res.json()) as Array<{ + tag_name: string; + published_at: string; + html_url: string; + draft: boolean; + prerelease: boolean; + }>; + const rels = data + .filter((r) => !r.draft && !r.prerelease && r.published_at) + .slice(0, 3) + .map((r, i) => ({ + ver: r.tag_name, + date: fmtDate(r.published_at), + latest: i === 0, + href: r.html_url, + })); + return rels.length ? rels : FALLBACK_RELEASES; + } catch { + return FALLBACK_RELEASES; + } +} + +// ---- What's New: recent merged docs PRs ----------------------------------- +// The docs repo's merge stream is mostly automated syncs + chores, so we +// filter hard and fall back to a real curated list when too little survives. + +const FALLBACK_WHATSNEW: WhatsNewItem[] = [ + { + date: "Jun 2026", + title: "Materialize promoted to its own documentation section", + href: "/materialize/getting-started/overview", + }, + { + date: "May 27, 2026", + title: "Native dark-mode support for the API reference", + href: "https://github.com/authzed/docs/pull/501", + }, + { + date: "May 19, 2026", + title: "Guide: using Postgres FDW with SpiceDB", + href: "/spicedb/ops/postgres-fdw", + }, + { date: "Apr 2026", title: "MCP server reference for connecting AI agents", href: "/mcp" }, +]; + +// drop bot/chore/tooling churn that isn't reader-facing "what's new" +const NOISE = + /^(auto-generated|chore|ci|build|test|refactor|style|revert|bump|deps)\b|dependabot|patch-\d|update (zed|spicedb) docs/i; + +function cleanTitle(t: string): string { + const stripped = t.replace(/^(feat|fix|docs|add)(\([^)]*\))?:\s*/i, "").trim(); + return stripped.charAt(0).toUpperCase() + stripped.slice(1); +} + +export async function getWhatsNew(): Promise { + try { + const res = await fetch( + "https://api.github.com/repos/authzed/docs/pulls?state=closed&per_page=40&sort=updated&direction=desc", + { headers: GH_HEADERS, next: { revalidate: REVALIDATE } }, + ); + if (!res.ok) return FALLBACK_WHATSNEW; + const data = (await res.json()) as Array<{ + title: string; + merged_at: string | null; + html_url: string; + }>; + const items = data + .filter((p) => p.merged_at && !NOISE.test(p.title)) + .slice(0, 4) + .map((p) => ({ + date: fmtDate(p.merged_at as string), + title: cleanTitle(p.title), + href: p.html_url, + })); + // only trust the dynamic feed when it surfaces a real list; otherwise the + // curated fallback reads far better than 1–2 stray PRs + return items.length >= 3 ? items : FALLBACK_WHATSNEW; + } catch { + return FALLBACK_WHATSNEW; + } +} diff --git a/mdx-components.ts b/mdx-components.ts index 55d5c0df..9533ba64 100644 --- a/mdx-components.ts +++ b/mdx-components.ts @@ -1,9 +1,12 @@ import { useMDXComponents as getDocsMDXComponents } from "nextra-theme-docs"; import type { Component } from "react"; +import { Yes, No } from "@/components/feature-icon"; const docsComponents = getDocsMDXComponents(); export const useMDXComponents = (components?: Component) => ({ ...docsComponents, + Yes, + No, ...components, }); diff --git a/next.config.mjs b/next.config.mjs index 7856e273..4a01bc15 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -40,6 +40,18 @@ const withNextra = nextra({ export default withNextra({ basePath: process.env.NEXT_PUBLIC_BASE_DIR ?? undefined, + // Best Practices moved under SpiceDB (it's SpiceDB-specific content). Keep + // the old indexed URL working. Fragments are preserved by the browser. + async redirects() { + return [ + { source: "/best-practices", destination: "/spicedb/best-practices", permanent: true }, + { + source: "/best-practices/:path*", + destination: "/spicedb/best-practices/:path*", + permanent: true, + }, + ]; + }, // This is necessary because we're using CDN domains. // It adds `cross-origin="anonymous"` to script tags crossOrigin: "anonymous", diff --git a/package.json b/package.json index d376b060..f98257df 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "url": "https://github.com/authzed/docs/issues" }, "scripts": { - "dev": "next dev --webpack", - "build": "next build --webpack", + "dev": "node scripts/build-changed-manifest.mjs && next dev --webpack", + "build": "node scripts/build-changed-manifest.mjs && next build --webpack", + "gen:changed": "node scripts/build-changed-manifest.mjs", "postbuild": "./scripts/postbuild.sh", "start": "next start", "lint:yaml": "yamllint .", diff --git a/scripts/build-changed-manifest.mjs b/scripts/build-changed-manifest.mjs new file mode 100644 index 00000000..77e6eca2 --- /dev/null +++ b/scripts/build-changed-manifest.mjs @@ -0,0 +1,80 @@ +// Build a manifest of docs CONTENT pages that are new or modified on this +// branch vs the published base (origin/main). Content only (app/ ** /page.mdx) +// so styling/component changes do not flag a page. Output: lib/changed-pages.json +// keyed by route, consumed by ContentStatus + the /changes review index. +// +// Runs at dev/build start (package.json). Best-effort: if git or the base ref +// is unavailable it writes an empty manifest so the site still builds. +import { execSync } from "node:child_process"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const OUT = join(ROOT, "lib", "changed-pages.json"); + +// Review aid ONLY — never ship to production. Show in local dev and Vercel +// preview builds; emit an empty manifest (no banners, empty /changes) anywhere +// that's a production build. +const SHOW = process.env.NODE_ENV !== "production" || process.env.VERCEL_ENV === "preview"; + +function sh(cmd) { + return execSync(cmd, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); +} + +// Prefer origin/main, fall back to main. +function resolveBase() { + for (const ref of ["origin/main", "main"]) { + try { + sh("git rev-parse --verify " + ref); + return ref; + } catch { + /* next */ + } + } + return null; +} + +// app/spicedb/concepts/zanzibar/page.mdx -> /spicedb/concepts/zanzibar +function fileToRoute(file) { + const m = file.match(/^app\/(.*)\/page\.mdx$/); + return m ? "/" + m[1] : null; +} + +let manifest = {}; +try { + const base = SHOW ? resolveBase() : null; + if (base) { + const out = sh( + "git diff --name-status -M " + base + "...HEAD -- 'app/**/page.mdx' 'app/*/page.mdx'", + ); + for (const line of out.split("\n").filter(Boolean)) { + const parts = line.split(/\t+/); + const code = parts[0]; + if (code.startsWith("D")) continue; // deleted page — nothing to flag + const file = parts[parts.length - 1]; // renamed: new path is last + const route = fileToRoute(file); + if (!route) continue; + manifest[route] = { status: code.startsWith("A") ? "new" : "updated" }; + } + // also catch pages modified but still uncommitted in the working tree + const wip = sh("git diff --name-only -- 'app/**/page.mdx' 'app/*/page.mdx'"); + for (const file of wip.split("\n").filter(Boolean)) { + const route = fileToRoute(file); + if (route && !manifest[route]) manifest[route] = { status: "updated" }; + } + } +} catch (e) { + console.warn("[changed-manifest] skipped:", e.message); + manifest = {}; +} + +mkdirSync(dirname(OUT), { recursive: true }); +writeFileSync(OUT, JSON.stringify(manifest, null, 2) + "\n"); +console.log( + "[changed-manifest] " + + Object.keys(manifest).length + + " changed content page(s) -> lib/changed-pages.json", +);