fix: a group member can be told where their item went, and a deploy names the head's author

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 16:50:42 +02:00
co-authored by Claude Opus 5
parent 5baae5c3d1
commit 0a6d49800e
7 changed files with 67 additions and 19 deletions
+6 -2
View File
@@ -1177,8 +1177,8 @@ async fn require_can_write_path(
/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers:
/// 1. admin → always.
/// 2. Path-prefix match against own `u/{username}` or any folder in
/// `authed.folders` (the precomputed read set, with groups + direct
/// 2. Path-prefix match against own `u/{username}`, a group in `authed.groups`, or any
/// folder in `authed.folders` (the precomputed read set, with groups + direct
/// grants already factored in).
/// 3. RLS-aware `SELECT 1` against the backing table — covers item-level
/// extra_perms grants that bypass folder/owner checks.
@@ -1203,6 +1203,10 @@ async fn require_can_read_path(
if parts.len() >= 2 {
match parts[0] {
"u" if parts[1] == authed.username => return Ok(()),
// As `require_can_write_path` and the `see_member` RLS policy read it: a
// draft-only `g/` path has no row for the probe below to fall back on, so
// without this a member cannot see a draft their group owns.
"g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()),
"f" => {
let folder = parts[1];
if authed.folders.iter().any(|(name, _, _)| name == folder) {
@@ -111,8 +111,9 @@
// event up through these runes-mode components silently drops it.
onRestore?: (restoredApp: any) => void
// Fired after a successful deploy, which keeps this editor open: `version` is what
// the deploy wrote, for the next draft's fork base, and `head` what is deployed now.
onDeploy?: (e: { version?: number; head?: number }) => void
// the deploy wrote, for the next draft's fork base, and `head` what is deployed
// now, with its author and time.
onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void
}
let {
@@ -416,7 +417,12 @@
if ($app) $app.parent_version = claimed
// The route owns the pair the out-of-date prompt reads, and this editor stays open
// across the deploy, so hand both over rather than leaving it on the old ones.
onDeploy?.({ version: claimed, head: version })
onDeploy?.({
version: claimed,
head: version,
headBy: appHistory[0]?.created_by,
headAt: appHistory[0]?.created_at
})
closeSaveDrawer()
sendUserToast('App deployed successfully')
@@ -1042,7 +1048,13 @@
itemKind="app"
path={userDraftPath}
draftOnly={newApp}
{onResetToDeployed}
onResetToDeployed={onResetToDeployed &&
(async () => {
// Back on the deployed version, so whatever the last deploy could not claim
// no longer describes this editor.
baseUnknown = false
await onResetToDeployed()
})}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
+4 -3
View File
@@ -184,9 +184,10 @@ export interface AppEditorProps {
// which does not propagate through these runes-mode components.
onRestore?: (restoredApp: any) => void
// Fired after a successful deploy, which keeps this editor open: `version` is what
// the deploy wrote, for the next draft's fork base, and `head` what is deployed now.
// The two differ when another deploy landed beside this one.
onDeploy?: (e: { version?: number; head?: number }) => void
// the deploy wrote, for the next draft's fork base, and `head` what is deployed now,
// with its author and time. `version` and `head` differ when another deploy landed
// beside this one, which is when the out-of-date prompt has something to say.
onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void
}
export type App = {
@@ -105,7 +105,13 @@
/** Fired after a successful deploy; the session preview reloads on it and the route
* re-pins the draft's fork base. `version` is what this deploy wrote and `head`
* what is deployed now: the two differ when another deploy landed beside it. */
onDeploy?: (e: { path: string; version?: number; head?: number }) => void
onDeploy?: (e: {
path: string
version?: number
head?: number
headBy?: string
headAt?: string
}) => void
/** Initial collapsed state for the file/runnable sidebar. The user's
* toggled preference is persisted under `sidebarStorageKey`; this prop
* only seeds the very first open. */
@@ -155,8 +155,15 @@
autosavePath?: string
// Fired after a successful deploy; lets the session preview reload. `version` is
// what this deploy wrote, for the next draft's fork base; `head` is what is
// deployed now, and the two differ when another deploy landed beside this one.
onDeploy?: (e: { path: string; version?: number; head?: number }) => void
// deployed now, with its author and time, and the two differ when another deploy
// landed beside this one.
onDeploy?: (e: {
path: string
version?: number
head?: number
headBy?: string
headAt?: string
}) => void
/** Surfaces the user-typed path (`newEditedPath`) up to the route
* when (and only when) it differs from the deployed/seeded
* `savedApp.path`. The route writes it into the autosaved raw-app
@@ -575,7 +582,13 @@
if (appPath !== npath) {
onSavedNewAppPath?.(npath)
}
onDeploy?.({ path: npath, version: claimed, head: version })
onDeploy?.({
path: npath,
version: claimed,
head: version,
headBy: appHistory[0]?.created_by,
headAt: appHistory[0]?.created_at
})
}
async function setPublishState(message?: string) {
@@ -899,7 +912,13 @@
itemKind="raw_app"
path={indicatorPath}
draftOnly={newApp}
{onResetToDeployed}
onResetToDeployed={onResetToDeployed &&
(async () => {
// Back on the deployed version, so whatever the last deploy could not claim
// no longer describes this editor.
baseUnknown = false
await onResetToDeployed()
})}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
@@ -495,13 +495,16 @@
{loadedFromDraft}
othersDraftsCount={otherDraftsUsers.length}
onOpenOthersDrafts={() => (othersModalOpen = true)}
onDeploy={({ version, head }) => {
// The editor stays open across a deploy, so the pair the out-of-date prompt
// reads has to move with it: the base is what the deploy could claim it
// wrote (unknown when another landed beside it), the head what is deployed.
onDeploy={({ version, head, headBy, headAt }) => {
// The editor stays open across a deploy, so what the out-of-date prompt reads
// has to move with it: the base is what the deploy could claim it wrote
// (unknown when another landed beside it), and the head is what is deployed,
// named by whoever deployed it rather than by the page load's author.
draftBaseVersion = version != null ? String(version) : undefined
if (head != null) {
deployedHeadVersion = String(head)
deployedBy = headBy
deployedAt = headAt
}
}}
/>
@@ -655,7 +655,7 @@
}
}
: undefined}
onDeploy={({ version, head }) => {
onDeploy={({ version, head, headBy, headAt }) => {
// The version this deploy wrote is the base the next autosave carries; the
// head is what is deployed now. They differ when another deploy landed
// beside this one, and the next draft is then behind from the start. A
@@ -665,6 +665,9 @@
draftBaseVersion = version != null ? String(version) : undefined
if (head != null) {
deployedHeadVersion = String(head)
// Named by whoever deployed the head, not by the page load's author.
deployedBy = headBy
deployedAt = headAt
}
}}
onResetToDeployed={reloadDeployed}