From 5103d72995d5a2bc4e19d7552b00570cb892fb9e Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 23 Sep 2026 08:05:43 -0700 Subject: [PATCH] access: a PDS sign-in is enough to go live on a node that holds the account's credentials. place.stream.access.getStatus gains nodeSession: true when the node has --account-credentials for the caller, or for the DID an unattributable client names as subject. The live dashboard and the phone go-live page asked a bearer session (a sign-in against the PDS, or one inherited from the network's app) for a node OAuth login first, because the node needed the user's OAuth session to write their records; with credentials of its own it does not, so those pages open for such an account on the bearer session alone. The stream key is written from the client's own PDS session, as it always was Claude-Session: https://claude.ai/code/session_016qEqFKnriZFcKWbQNKWsBu --- .../access/node-session-required.tsx | 12 ++++++++++ js/app/src/screens/live-dashboard.tsx | 4 ++-- js/app/src/screens/mobile-go-live.tsx | 4 ++-- .../src/streamplace-store/access.tsx | 4 ++++ .../access/place-stream-access-getstatus.md | 23 +++++++++++-------- .../content/docs/lex-reference/openapi.json | 4 ++++ lexicons/place/stream/access/getStatus.json | 10 +++++++- pkg/placestream/accessgetstatus.go | 6 +++-- pkg/spxrpc/place_stream_access.go | 13 +++++++++++ 9 files changed, 64 insertions(+), 16 deletions(-) diff --git a/js/app/components/access/node-session-required.tsx b/js/app/components/access/node-session-required.tsx index 20d09809..fb81ce08 100644 --- a/js/app/components/access/node-session-required.tsx +++ b/js/app/components/access/node-session-required.tsx @@ -1,6 +1,7 @@ import { Button, Text, + useAccessStatus, useNetworkName, useTheme, } from "@streamplace/components"; @@ -19,6 +20,17 @@ export function useBearerSession(): boolean { return kind === "brokered" || kind === "credential"; } +/** + * Whether a screen that writes as the streamer must first get a node OAuth + * session: the viewer holds only a bearer session, and the node has no + * credentials of its own for the account (access status nodeSession). + */ +export function useNeedsNodeSession(): boolean { + const bearer = useBearerSession(); + const nodeSession = useAccessStatus()?.nodeSession ?? false; + return bearer && !nodeSession; +} + /** * Shown in place of a screen that needs a node OAuth session when the * viewer only holds a bearer session. The button opens the login modal on diff --git a/js/app/src/screens/live-dashboard.tsx b/js/app/src/screens/live-dashboard.tsx index 97a6199c..8ea8b25d 100644 --- a/js/app/src/screens/live-dashboard.tsx +++ b/js/app/src/screens/live-dashboard.tsx @@ -2,7 +2,7 @@ import { useRoute } from "@react-navigation/native"; import { LivestreamProvider, PlayerProvider } from "@streamplace/components"; import { NodeSessionRequired, - useBearerSession, + useNeedsNodeSession, } from "components/access/node-session-required"; import BentoGrid from "components/live-dashboard/bento-grid"; import Loading from "components/loading/loading"; @@ -17,7 +17,7 @@ export default function LiveDashboard() { const userProfile = useUserProfile(); const isLive = useLiveUser(); const openLoginModal = useStore((state) => state.openLoginModal); - const bearerSession = useBearerSession(); + const bearerSession = useNeedsNodeSession(); const route = useRoute(); const [videoElement, setVideoElement] = useState( null, diff --git a/js/app/src/screens/mobile-go-live.tsx b/js/app/src/screens/mobile-go-live.tsx index 8981e3e1..18bde1e0 100644 --- a/js/app/src/screens/mobile-go-live.tsx +++ b/js/app/src/screens/mobile-go-live.tsx @@ -2,7 +2,7 @@ import { useRoute } from "@react-navigation/native"; import { KeepAwake } from "@streamplace/components"; import { NodeSessionRequired, - useBearerSession, + useNeedsNodeSession, } from "components/access/node-session-required"; import Loading from "components/loading/loading"; import { Player } from "components/mobile/player"; @@ -14,7 +14,7 @@ import { useUserProfile } from "store/hooks"; export default function MobileGoLive() { const userProfile = useUserProfile(); const openLoginModal = useStore((state) => state.openLoginModal); - const bearerSession = useBearerSession(); + const bearerSession = useNeedsNodeSession(); const route = useRoute(); useEffect(() => { diff --git a/js/components/src/streamplace-store/access.tsx b/js/components/src/streamplace-store/access.tsx index 0fd6fe9f..9c5e7a7a 100644 --- a/js/components/src/streamplace-store/access.tsx +++ b/js/components/src/streamplace-store/access.tsx @@ -20,6 +20,9 @@ export interface AccessStatus { /** Whether the account lives on the network's own PDS; undefined when * the node has no network PDS configured. */ networkMember?: boolean; + /** The node holds credentials for this account and writes its records + * itself, so a bearer (PDS) session is enough to go live here. */ + nodeSession?: boolean; } // What we assume when the node predates access control (the method doesn't @@ -111,6 +114,7 @@ export function useFetchAccessStatus() { chatVerifiedOnly: res.chatVerifiedOnly ?? false, chatVerified: res.chatVerified ?? false, networkMember: res.networkMember ?? undefined, + nodeSession: (res as any).nodeSession ?? false, }, accessStatusLoaded: true, accessStatusError: null, diff --git a/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md b/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md index 3c6b9d61..9c1d2532 100644 --- a/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md +++ b/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md @@ -28,15 +28,16 @@ Report the caller's roles on this node and the node's access policy. Works unaut **Schema Type:** `object` -| Name | Type | Req'd | Description | Constraints | -| ------------------ | ------------------------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| `did` | `string` | ❌ | The authenticated caller, when there is one. | Format: `did` | -| `roles` | Array of [`place.stream.access.defs#role`](/lex-reference/place-stream-access-defs#role) | ✅ | Every role the caller effectively holds. | | -| `policy` | [`place.stream.access.defs#policyView`](/lex-reference/place-stream-access-defs#policyview) | ✅ | | | -| `space` | `string` | ✅ | The node's access-control space: at://{authority}/space/place.stream.access.control/self (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.) | | -| `chatVerifiedOnly` | `boolean` | ❌ | Whether chat is restricted to users verified by the node's trusted verifiers (branding key chatVerifiedOnly). | | -| `chatVerified` | `boolean` | ❌ | Whether the authenticated caller is verified by one of the node's trusted verifiers. | | -| `networkMember` | `boolean` | ❌ | Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured. | | +| Name | Type | Req'd | Description | Constraints | +| ------------------ | ------------------------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `did` | `string` | ❌ | The authenticated caller, when there is one. | Format: `did` | +| `roles` | Array of [`place.stream.access.defs#role`](/lex-reference/place-stream-access-defs#role) | ✅ | Every role the caller effectively holds. | | +| `policy` | [`place.stream.access.defs#policyView`](/lex-reference/place-stream-access-defs#policyview) | ✅ | | | +| `space` | `string` | ✅ | The node's access-control space: at://{authority}/space/place.stream.access.control/self (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.) | | +| `chatVerifiedOnly` | `boolean` | ❌ | Whether chat is restricted to users verified by the node's trusted verifiers (branding key chatVerifiedOnly). | | +| `chatVerified` | `boolean` | ❌ | Whether the authenticated caller is verified by one of the node's trusted verifiers. | | +| `networkMember` | `boolean` | ❌ | Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured. | | +| `nodeSession` | `boolean` | ❌ | Whether this node can write to the caller's (or subject's) repo on its own: it holds credentials for the account (--account-credentials), so a session the node can't attribute (a sign-in against the PDS) is enough to go live here. | | --- @@ -98,6 +99,10 @@ Report the caller's roles on this node and the node's access policy. Works unaut "networkMember": { "type": "boolean", "description": "Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured." + }, + "nodeSession": { + "type": "boolean", + "description": "Whether this node can write to the caller's (or subject's) repo on its own: it holds credentials for the account (--account-credentials), so a session the node can't attribute (a sign-in against the PDS) is enough to go live here." } } } diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index b42cfe06..e8ee278e 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -4789,6 +4789,10 @@ "networkMember": { "type": "boolean", "description": "Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured." + }, + "nodeSession": { + "type": "boolean", + "description": "Whether this node can write to the caller's (or subject's) repo on its own: it holds credentials for the account (--account-credentials), so a session the node can't attribute (a sign-in against the PDS) is enough to go live here." } }, "required": ["roles", "policy", "space"] diff --git a/lexicons/place/stream/access/getStatus.json b/lexicons/place/stream/access/getStatus.json index ff6c7d81..266ef7c4 100644 --- a/lexicons/place/stream/access/getStatus.json +++ b/lexicons/place/stream/access/getStatus.json @@ -19,7 +19,11 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["roles", "policy", "space"], + "required": [ + "roles", + "policy", + "space" + ], "properties": { "did": { "type": "string", @@ -53,6 +57,10 @@ "networkMember": { "type": "boolean", "description": "Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured." + }, + "nodeSession": { + "type": "boolean", + "description": "Whether this node can write to the caller's (or subject's) repo on its own: it holds credentials for the account (--account-credentials), so a session the node can't attribute (a sign-in against the PDS) is enough to go live here." } } } diff --git a/pkg/placestream/accessgetstatus.go b/pkg/placestream/accessgetstatus.go index 4c7297fa..da8832b9 100644 --- a/pkg/placestream/accessgetstatus.go +++ b/pkg/placestream/accessgetstatus.go @@ -21,8 +21,10 @@ type AccessGetStatus_Output struct { // did: The authenticated caller, when there is one. Did *string `json:"did,omitempty"` // networkMember: Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured. - NetworkMember *bool `json:"networkMember,omitempty"` - Policy AccessDefs_PolicyView `json:"policy"` + NetworkMember *bool `json:"networkMember,omitempty"` + // nodeSession: Whether this node can write to the caller's (or subject's) repo on its own: it holds credentials for the account (--account-credentials), so a session the node can't attribute (a sign-in against the PDS) is enough to go live here. + NodeSession *bool `json:"nodeSession,omitempty"` + Policy AccessDefs_PolicyView `json:"policy"` // roles: Every role the caller effectively holds. Roles []string `json:"roles"` // space: The node's access-control space: at://{authority}/space/place.stream.access.control/self (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.) diff --git a/pkg/spxrpc/place_stream_access.go b/pkg/spxrpc/place_stream_access.go index 10fc3a5f..0181f2b5 100644 --- a/pkg/spxrpc/place_stream_access.go +++ b/pkg/spxrpc/place_stream_access.go @@ -155,6 +155,19 @@ func (s *Server) handlePlaceStreamAccessGetStatus(ctx context.Context, subject s out.Roles = append(out.Roles, role) } } + // An account the node holds credentials for (--account-credentials) + // needs no OAuth session here: the node writes its records itself, so a + // client signed in against the PDS alone may go live. Answered for the + // caller, or for the DID an unattributable client names. + if who := did; who != "" || strings.HasPrefix(subject, "did:") { + if who == "" { + who = subject + } + if s.cli != nil && s.cli.DevAccountCreds[who] != "" { + yes := true + out.NodeSession = &yes + } + } // The chat lock and the caller's standing under it, so the composer can // explain itself rather than post into the void. if s.ATSync != nil { -- 2.51.2