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 {