@neta-art/cohub 8.18.0 → 8.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  # Cohub App Runtime Guide
2
2
 
3
- This guide explains the Cohub SDK in a published App iframe or an explicitly
4
- configured standalone broker. New Apps should use `auth.authorize()`; see the
3
+ This guide explains the Cohub SDK in a published App iframe or a registered
4
+ standalone origin. New Apps should use `auth.authorize()`; see the
5
5
  [authorization contract](../../../docs/app-authorization.md). Legacy APIs below
6
6
  retain their original return types. Read this before building any
7
7
  App that calls Cohub capabilities from browser-side JavaScript.
@@ -57,7 +57,7 @@ requiring the viewer to paste an API key.
57
57
  │ │ │ │
58
58
  │ │ createCohubClient() ──► token ──►│──┼──► Cohub API
59
59
  │ │ client.context() ◄── identity │ │
60
- │ │ client.auth.request() ──► consent│ │
60
+ │ │ client.auth.authorize() ► consent│ │
61
61
  │ └───────────────────────────────────┘ │
62
62
  └─────────────────────────────────────────┘
63
63
  ```
@@ -66,15 +66,14 @@ These runtime-only APIs form the foundation; everything else is standard SDK:
66
66
 
67
67
  | API | What it does | Returns |
68
68
  |---|---|---|
69
- | `client.context()` | Asks the host for the App's identity | `{ app, space, viewer?, invocation?, shell?, permissions }` or `null` |
70
- | `client.auth.request({ scopes, reason, spaceId?, alwaysAsk? })` | Ensures the app holds these scopes; silent when a grant already covers them, consent dialog otherwise | `true` / `false` |
71
- | `client.auth.requestSpace({ scopes, reason, alwaysAsk? })` | One consent: the viewer picks a Space and grants the scopes on it | `{ granted, space }` |
72
- | `client.auth.requestCreateSpace({ scopes, space, reason })` | One consent: create a viewer-owned Space (`CreateSpaceInput`) and grant the scopes on it | `{ granted, space }` |
69
+ | `client.context()` | Asks the host for the App's identity | `{ app, space, viewer?, invocation?, shell?, permissions, capabilities?, mode? }` or `null` |
70
+ | `client.auth.authorize({ target, scopes, reason?, alwaysAsk?, fallback? })` | Requests consent for an account, a Space, or a viewer-picked Space; also silently grants approved read-only scopes on the trusted Shell Space | `{ status: "granted", requestedTarget, target, resolution, grant }` \| `{ status: "cancelled" }` \| `{ status: "denied", code }` |
71
+ | `client.auth.request()` / `requestSpace()` / `requestCreateSpace()` | Legacy entry points, kept for older Apps | original shapes (`boolean`, `{ granted, space }`) |
73
72
  | `client.context().permissions.viewerGrants` | Render the viewer's current per-space grants | `{ spaceId, scopes }[]` |
74
73
  | `client.app.commerce.*` / `client.app.realtime.*` | Commerce and realtime, bound to the app's runtime identity | (see below) |
75
74
 
76
- > **Runtime requirement.** Use a published App iframe or explicitly configure
77
- > a broker. A standalone page without broker configuration has no runtime.
75
+ > **Runtime requirement.** Use a published App iframe or a standalone origin
76
+ > registered with Cohub. The SDK discovers the transport automatically.
78
77
  > Initialize `context()` before using APIs to discover Host capabilities.
79
78
 
80
79
  `client.app.onContextChanged(listener)` pushes a fresh context whenever the
@@ -100,7 +99,7 @@ and context. This is the normal case when a viewer opens an App through Cohub.
100
99
  snapshot with the originating `spaceId`, `sessionId`, `turnId`, and
101
100
  `toolCallId` when available. The invocation Space may differ from the App's
102
101
  own `space.id`.
103
- - `client.auth.request()` triggers an in-shell consent flow (no popup window).
102
+ - `client.auth.authorize()` triggers an in-shell consent flow (no popup window).
104
103
 
105
104
  ```js
106
105
  const ctx = await client.context();
@@ -118,17 +117,18 @@ The App is accessed as a standalone page (`window.parent === window`), e.g.
118
117
  a direct static-asset URL not wrapped in the Cohub iframe. The SDK opens a
119
118
  **popup window** to a Cohub auth-broker page to obtain tokens.
120
119
 
121
- - `client.context()` is **answered locally** by the SDK: `space.id` is an
122
- **empty string `""`**, and viewer grants are unavailable (empty).
123
- - `client.auth.request()` opens a popup to
124
- `${brokerOrigin}/app-auth?app=${appId}`.
120
+ - `client.context()` is **answered locally** by the SDK after anonymously
121
+ resolving the page Origin. It has the same shape as bridge context, including
122
+ App identity, Home Space, capabilities and App scopes. `shell.surface` is
123
+ `"broker"`; there is no active shell Space, session or turn.
124
+ - `client.auth.authorize()` opens the environment's trusted Cohub broker.
125
+ - Broker tokens stay in memory and are never restored from or written to
126
+ `localStorage`.
125
127
 
126
- > **Broker mode requires configuration.** You must pass `app: { brokerOrigin,
127
- > appId }` — or `app: { brokerOrigin, ownerUsername, spaceSlug, appSlug }`
128
- > when the appId isn't known yet — to `createCohubClient` for broker mode to
129
- > activate. Without it, a standalone page gets `ParentBridgeTransport` which
130
- > has no parent to talk to, so `context()` returns `null`. See
131
- > [Initialization recipe](#4-initialization-recipe).
128
+ > **No mode configuration is required.** `createCohubClient({ env })` uses
129
+ > bridge inside the Cohub shell and broker on a registered standalone Origin.
130
+ > Unregistered pages receive `null` from `context()`. Explicit `app` transport
131
+ > options remain available for legacy integrations and local debugging.
132
132
 
133
133
  ### Detecting the mode at runtime
134
134
 
@@ -147,29 +147,30 @@ if (isBroker) {
147
147
  const detail = await client.apps.getBySlug(ownerUsername, spaceSlug, appSlug);
148
148
  spaceId = detail.app.spaceId;
149
149
  } else {
150
- spaceId = ctx.space.id;
150
+ // Prefer where the App is running; fall back to the Space that owns it.
151
+ spaceId = ctx.shell?.space?.id ?? ctx.invocation?.spaceId ?? ctx.app.homeSpace?.id;
151
152
  }
152
153
  ```
153
154
 
154
155
  ### Broker mode: user-activation ordering gotcha
155
156
 
156
157
  `HttpTransport` calls `getAccessToken()` on **every** request — including
157
- public ones like `apps.getBySlug()`. In broker mode, an uncached token request
158
- opens a popup, which **consumes the browser's user-activation budget**. If a
159
- second popup (`auth.request`) follows in the same click, the browser blocks it.
158
+ public ones like `apps.getBySlug()`. In broker mode, a token exchange opens a
159
+ popup, which **consumes the browser's user-activation budget**. If a second
160
+ popup (the consent dialog) follows in the same click, the browser blocks it.
160
161
 
161
- **Fix:** call `auth.request()` **before** any other API call that triggers
162
- `getAccessToken()`. After `auth.request` succeeds, the token is cached in
163
- `localStorage` and subsequent `getAccessToken()` calls hit the cache — no
164
- popup.
162
+ **Fix:** call `auth.authorize()` **before** any other API call that triggers
163
+ `getAccessToken()`. The token returned by that exchange is cached in memory for
164
+ the lifetime of the client, so later `getAccessToken()` calls reuse it without
165
+ a popup.
165
166
 
166
167
  ```js
167
- // WRONG: getBySlug() opens a popup, consumes activation, auth.request popup blocked
168
+ // WRONG: getBySlug() opens a popup, consumes activation, consent popup blocked
168
169
  const detail = await client.apps.getBySlug(owner, spaceSlug, appSlug);
169
- await client.auth.request({ scopes, reason });
170
+ await client.auth.authorize({ target: { kind: "pick-space" }, scopes });
170
171
 
171
- // RIGHT: auth.request opens the only popup, then getBySlug() hits token cache
172
- await client.auth.request({ scopes, reason });
172
+ // RIGHT: authorize() opens the only popup, then getBySlug() hits the token cache
173
+ await client.auth.authorize({ target: { kind: "pick-space" }, scopes });
173
174
  const detail = await client.apps.getBySlug(owner, spaceSlug, appSlug);
174
175
  ```
175
176
 
@@ -207,7 +208,7 @@ command.execute — run sandbox shell commands
207
208
  ### Viewer grants (consent-required, any permission, per Space)
208
209
 
209
210
  A viewer grants these through a consent dialog triggered by
210
- `client.auth.request()` / `client.auth.requestSpace()` / `client.auth.requestCreateSpace()`. A viewer may grant
211
+ `client.auth.authorize()`. A viewer may grant
211
212
  **any** permission they currently hold on the target Space — including scopes
212
213
  outside the eight app scopes, such as `generation.create` or the account-level
213
214
  `user.*` scopes. Two hard rules are enforced by the server:
@@ -240,51 +241,85 @@ requested Space.
240
241
 
241
242
  ### Requesting viewer grants
242
243
 
243
- Both helpers must be called **from a user gesture** (button click). They are
244
- silent when an existing grant already covers the scopes — the dialog only
245
- opens when something new is needed:
244
+ Normal consent must be requested **from a user gesture** (a button click).
245
+ A request whose explicit target is the trusted `ctx.shell.space.id` is also
246
+ silent for the read-only Shell scopes `space.view`, `file.view`,
247
+ `file.view.filtered`, `session.view`, `taskrun.view`, and `checkpoint.view`.
248
+ The Host decides this by comparing the request against the Space it is actually
249
+ showing, and the server still checks the viewer's current permission on that
250
+ Space. The API keeps its single `authorize` endpoint and is not told whether
251
+ consent was interactive, so there is no flag to trust or forge. Broker surfaces
252
+ keep the interactive flow. A Shell hosting an embedded App applies the same rule
253
+ on its behalf. Existing grants are read without extending their expiry. Any other target, mixed scope set, `pick-space`,
254
+ account data, writes, or actions keeps the interactive flow:
246
255
 
247
256
  ```js
248
- // Target a known Space (omit spaceId for the App's own Space).
249
- const ok = await client.auth.request({
257
+ // 1. Target a known Space. The result names the Space that was actually
258
+ // granted, which may differ from the request when the viewer cannot use it.
259
+ const result = await client.auth.authorize({
260
+ target: { kind: "space", spaceId: invocationSpaceId },
250
261
  scopes: ["taskrun.view"],
251
- spaceId: invocationSpaceId,
252
262
  reason: "This app reads generation tasks in the Space you opened it from.",
253
263
  });
264
+ if (result.status === "granted" && result.target.kind === "space") {
265
+ const space = client.space(result.target.spaceId);
266
+ }
254
267
 
255
- // One consent: the viewer picks the Space. The host loads the space list —
256
- // the app only learns the pick. Returning viewers silently reuse their last pick.
257
- const { granted, space } = await client.auth.requestSpace({
268
+ // 2. One consent: the viewer picks the Space. The host loads the space list —
269
+ // the app only learns the Space the viewer chose.
270
+ const picked = await client.auth.authorize({
271
+ target: { kind: "pick-space" },
258
272
  scopes: ["file.view", "session.view"],
259
273
  reason: "This app reads the Space you pick.",
260
274
  });
261
- if (granted && space) {
262
- const picked = client.space(space.id);
275
+ if (picked.status === "granted" && picked.target.kind === "space") {
276
+ const space = client.space(picked.target.spaceId);
263
277
  }
264
278
 
265
- // Create a viewer-owned Space and grant on it — never silent. `space` is the
266
- // same `CreateSpaceInput` as `client.spaces.create()` (blank, git, or checkpoint).
267
- const created = await client.auth.requestCreateSpace({
268
- scopes: ["file.view", "session.view", "session.prompt.fullaccess"],
269
- space: {
270
- name: "Whale Shrine",
271
- bootstrapSource: { type: "checkpoint", checkpointId },
272
- },
273
- reason: "Create a workspace from this template.",
279
+ // 3. Account-level data needs an account target, not a Space.
280
+ const account = await client.auth.authorize({
281
+ target: { kind: "account" },
282
+ scopes: ["user.space.list"],
283
+ });
284
+
285
+ // 4. Strict target: never resolve to another Space.
286
+ const strict = await client.auth.authorize({
287
+ target: { kind: "space", spaceId: projectSpaceId },
288
+ scopes: ["file.edit"],
289
+ fallback: "none",
274
290
  });
275
- if (created.granted && created.space) {
276
- const next = client.space(created.space.id);
277
- }
278
- // `granted: false` with a `space` means the Space was created but bootstrap
279
- // did not finish — no grant was issued. A deny is `{ granted: false, space: null }`.
280
291
  ```
281
292
 
282
- Pass `alwaysAsk: true` to skip silent reuse and force a fresh dialog — for
283
- re-confirming a grant or letting the viewer switch to another Space.
284
- `requestCreateSpace` always opens the dialog; each confirm mints a new Space.
285
- The host creates it with the viewer's account token. An App can also create a
286
- viewer-owned Space directly with `client.spaces.create()` once it holds a
287
- `space.create` viewer grant.
293
+ The result tells the App exactly what happened:
294
+
295
+ | Field | Meaning |
296
+ |---|---|
297
+ | `status` | `granted`, `cancelled`, or `denied` |
298
+ | `requestedTarget` | What the App asked for |
299
+ | `target` | What was actually granted (`account`, or a Space with `spaceId`/`name`) |
300
+ | `resolution` | `requested`, `selected`, or `fallback` |
301
+ | `grant` | `{ id, spaceId, scopes, expiresAt }` from the server |
302
+
303
+ Rules worth internalizing:
304
+
305
+ - **Act on `target`, never on the request.** API calls are never redirected to
306
+ another Space on your behalf; if you asked for Space A and got B, use B.
307
+ - **Unavailable Spaces fall back by default.** The dialog shows the
308
+ viewer-controlled Space that will be used. Pass `fallback: "none"` when the
309
+ operation only makes sense in the Space you named.
310
+ - **Cancellation is not an error.** `{ status: "cancelled" }` means the viewer
311
+ closed the dialog; keep their input and offer the action again.
312
+ - **Incremental growth.** New consent keeps scopes from a still-valid grant and
313
+ adds the new ones; it never revives an expired or revoked scope.
314
+ - Pass `alwaysAsk: true` to skip silent reuse — for re-confirming a grant or
315
+ letting the viewer switch to another Space.
316
+
317
+ Creating a viewer-owned Space stays on `client.auth.requestCreateSpace({ space, scopes })`
318
+ (never silent, each confirm mints a new Space). `{ granted: false }` **may still
319
+ carry a `space`**: the Space was created but bootstrap or authorization did not
320
+ finish. Keep the id, resume against it, and never create a second Space or
321
+ delete it automatically. An App can also create a viewer-owned Space directly
322
+ with `client.spaces.create()` once it holds a `space.create` viewer grant.
288
323
 
289
324
  ### Checking grant state at runtime
290
325
 
@@ -300,7 +335,7 @@ ctx.permissions.viewerScopes // flat viewer scopes (legacy compatibility)
300
335
  ```
301
336
 
302
337
  Apps never cache or manage grants themselves — the host does. Checking state
303
- is for **rendering**; acting is `auth.request`'s job.
338
+ is for **rendering**; acting is `auth.authorize()`'s job.
304
339
 
305
340
  ### Managing grants
306
341
 
@@ -340,6 +375,7 @@ dialog can.
340
375
  | List tasks in a Space | `client.tasks.list({ spaceId })` | `taskrun.view` on that Space | app or viewer |
341
376
  | List all owned task runs | `client.tasks.list()` | `user.taskrun.list` | **viewer only** |
342
377
  | Create a Space for the viewer | `client.auth.requestCreateSpace({ space, scopes })` | requested scopes on the new Space | **viewer consent** |
378
+ | Request any consent | `client.auth.authorize({ target, scopes })` | the requested scopes on the resolved target | **viewer consent** |
343
379
  | Create a viewer-owned Space directly | `client.spaces.create(input)` | `space.create` | **viewer only** |
344
380
  | List viewer's spaces | `client.spaces.list()` | `user.space.list` | **viewer only** |
345
381
  | List viewer's sessions | `client.user.listSessions()` | `user.session.list` | **viewer only** |
@@ -367,7 +403,7 @@ App's own Space; any other Space needs a viewer grant on that Space.
367
403
 
368
404
  **Cross-space app** (acts on Spaces the viewer picks):
369
405
  - `appScopes: []` (or its own-Space needs)
370
- - viewer grants via `auth.requestSpace` at runtime
406
+ - viewer grants via `auth.authorize({ target: { kind: "pick-space" } })` at runtime
371
407
 
372
408
  ---
373
409
 
@@ -403,54 +439,22 @@ const client = createCohubClient({
403
439
  });
404
440
  ```
405
441
 
406
- ### Broker mode configuration (standalone pages only)
407
-
408
- If the App may be accessed as a standalone page (not inside the Cohub
409
- iframe), pass the `app` option so the SDK can fall back to broker mode:
410
-
411
- ```js
412
- const client = createCohubClient({
413
- env: isDevApp ? "dev" : "prod",
414
- app: {
415
- brokerOrigin: isDevApp ? "https://dev.cohub.live" : "https://cohub.live",
416
- appId: "<your-published-app-id>",
417
- },
418
- });
419
- ```
442
+ ### Automatic standalone mode
420
443
 
421
- When inside the Cohub iframe, the SDK auto-detects bridge mode and ignores
422
- broker config. When standalone, it uses broker mode. **One codebase, both
423
- deployments.**
444
+ Published public `file` and `directory` Apps may also be opened at a standalone
445
+ URL: the deployment's standalone host template with the App id substituted, for
446
+ example `https://<app-id>.apps.example.com`. Standalone origins are opt-in
447
+ against deployment configuration; without a template, Apps have no standalone
448
+ URL.
424
449
 
425
- #### Broker mode without a pre-known appId
450
+ The same client initialization works in the Cohub iframe and at this direct
451
+ URL. The SDK first uses the parent bridge when embedded; at a top-level page it
452
+ anonymously resolves `window.location.origin` and selects the trusted broker.
453
+ The resolver returns identity only and never returns a token.
426
454
 
427
- The `appId` is only generated at publish time, so you often cannot hardcode
428
- it while writing the App. In standalone deployments you can omit `appId` and
429
- instead pass the App's public **slug triple**. The SDK resolves the appId at
430
- runtime via the public `apps.getBySlug` API (anonymous, no auth required),
431
- caches it, and starts broker mode with it.
432
-
433
- All three values are known before publishing:
434
-
435
- - `appSlug` — the slug you chose when creating the App.
436
- - `ownerUsername` — the space owner's username (`cohub auth whoami`).
437
- - `spaceSlug` — the space's slug (`cohub spaces get <spaceId>`).
438
-
439
- ```js
440
- const client = createCohubClient({
441
- env: isDevApp ? "dev" : "prod",
442
- app: {
443
- brokerOrigin: isDevApp ? "https://dev.cohub.live" : "https://cohub.live",
444
- ownerUsername,
445
- spaceSlug,
446
- appSlug,
447
- },
448
- });
449
- ```
450
-
451
- Either `appId` or the full slug triple is enough to activate broker mode. If
452
- you pass both, the explicit `appId` wins and no lookup is performed. Inside
453
- the Cohub iframe both are ignored (bridge mode).
455
+ Explicit `app: { brokerOrigin, appId }` and slug-triple configuration remains
456
+ supported for older externally hosted Apps, but new Apps should not declare a
457
+ mode or hardcode their App id.
454
458
 
455
459
  ### Standard initialization sequence
456
460
 
@@ -458,31 +462,40 @@ the Cohub iframe both are ignored (bridge mode).
458
462
  // 1. Create client (env is mandatory in the browser)
459
463
  const client = createCohubClient({ env: isDevApp ? "dev" : "prod" });
460
464
 
461
- // 2. Get runtime context (and keep it fresh)
465
+ // 2. Get runtime context (and keep it fresh). `app` and its Home Space are
466
+ // present in bridge and broker mode; shell location exists only when hosted.
462
467
  const ctx = await client.context();
463
- if (!ctx?.space?.id) {
464
- // Not in an app runtime (or broker mode — see §2)
468
+ if (!ctx?.app?.id) {
465
469
  throw new Error("Not running inside a published app.");
466
470
  }
467
471
  const stopContextWatch = client.app.onContextChanged((next) => renderGrants(next));
468
472
 
469
- // 3. Obtain the space client for API calls
470
- const space = client.space(ctx.space.id);
471
-
472
- // 4. Request viewer grants (from a user gesture, e.g. button click)
473
- const ok = await client.auth.request({
473
+ // 3. Request viewer grants from a user gesture (e.g. button click). Target
474
+ // where the App is running, or `{ kind: "pick-space" }` when it has no
475
+ // location of its own.
476
+ const consent = await client.auth.authorize({
477
+ target: {
478
+ kind: "space",
479
+ spaceId: ctx.shell?.space?.id ?? ctx.invocation?.spaceId ?? ctx.app.homeSpace?.id,
480
+ },
474
481
  scopes: ["session.prompt.fullaccess", "generation.create"],
475
482
  reason: "This app sends prompts and generates images.",
476
483
  });
484
+ if (consent.status !== "granted" || consent.target.kind !== "space") {
485
+ throw new Error("Consent was not granted.");
486
+ }
477
487
 
478
- // 5. Call capabilities
479
- const result = await space.prompt({ content: [{ type: "text", text: "Hello" }] });
488
+ // 4. Call capabilities on the Space that was actually granted — never on the
489
+ // one you asked for.
490
+ const activeSpace = client.space(consent.target.spaceId);
491
+ const result = await activeSpace.prompt({ content: [{ type: "text", text: "Hello" }] });
480
492
  ```
481
493
 
482
- > **`auth.request` must be called from a user gesture** (click handler).
483
- > Browsers block popups (broker mode) and some consent flows (bridge mode)
484
- > when triggered programmatically without user activation. Do not call it on
485
- > page load. It is safe to call repeatedly: covered scopes renew silently.
494
+ > **Normal interactive `auth.authorize()` calls must use a user gesture** (click
495
+ > handler). The approved Shell read-only path may run during initialization in
496
+ > bridge mode. Broker mode has no trusted Shell Space, so it keeps the normal
497
+ > popup and gesture requirements. `alwaysAsk: true` always returns to the
498
+ > interactive flow.
486
499
 
487
500
  ---
488
501
 
@@ -581,7 +594,8 @@ send must match — the backend picks the permission check based on
581
594
 
582
595
  ```js
583
596
  // 1. Ensure the read-only scope is granted (silent when already covered)
584
- await client.auth.request({
597
+ await client.auth.authorize({
598
+ target: { kind: "space", spaceId: space.id },
585
599
  scopes: ["session.prompt.readonly"],
586
600
  reason: "Generate a one-off character reply (read-only).",
587
601
  });
@@ -708,21 +722,24 @@ not the App's own space. Each requires a separate viewer grant.
708
722
 
709
723
  ```js
710
724
  // List the viewer's spaces — needs user.space.list
711
- await client.auth.request({
725
+ await client.auth.authorize({
726
+ target: { kind: "account" },
712
727
  scopes: ["user.space.list"],
713
728
  reason: "Show your space list.",
714
729
  });
715
730
  const spaces = await client.spaces.list();
716
731
 
717
732
  // List sessions across all the viewer's spaces — needs user.session.list
718
- await client.auth.request({
733
+ await client.auth.authorize({
734
+ target: { kind: "account" },
719
735
  scopes: ["user.session.list"],
720
736
  reason: "List your recent sessions.",
721
737
  });
722
738
  const { sessions } = await client.user.listSessions({ limit: 20 });
723
739
 
724
740
  // Read activity — needs user.usage.read
725
- await client.auth.request({
741
+ await client.auth.authorize({
742
+ target: { kind: "account" },
726
743
  scopes: ["user.usage.read"],
727
744
  reason: "Show your activity.",
728
745
  });
@@ -1007,19 +1024,21 @@ function log(el, msg) {
1007
1024
  // --- Runtime initialization ---
1008
1025
  async function ensureRuntime(outEl) {
1009
1026
  const ctx = await client.context();
1010
- if (!ctx?.space?.id) {
1027
+ if (!ctx?.app?.id) {
1011
1028
  throw new Error("Not running inside a published app runtime.");
1012
1029
  }
1013
- spaceId = ctx.space.id;
1030
+ // Prefer the current Cohub location, then the Space that owns the App.
1031
+ spaceId = ctx.shell?.space?.id ?? ctx.invocation?.spaceId ?? ctx.app.homeSpace?.id;
1032
+ if (!spaceId) throw new Error("No Space to act on — request one with auth.authorize().");
1014
1033
  space = client.space(spaceId);
1015
1034
  return ctx;
1016
1035
  }
1017
1036
 
1018
1037
  // --- Viewer grant helpers ---
1019
- // Render state from context; act through auth.request (silent when covered).
1038
+ // Render state from context; act through auth.authorize (silent when covered).
1020
1039
  function hasViewerGrant(ctx, scope, spaceId) {
1021
1040
  return (ctx?.permissions?.viewerGrants ?? []).some(
1022
- (g) => g.spaceId === (spaceId ?? ctx?.space?.id) && g.scopes.includes(scope),
1041
+ (g) => g.spaceId === (spaceId ?? ctx?.app?.homeSpace?.id) && g.scopes.includes(scope),
1023
1042
  );
1024
1043
  }
1025
1044
 
@@ -1032,9 +1051,17 @@ async function ensureViewerScopes(scopes, reason, outEl) {
1032
1051
  return true;
1033
1052
  }
1034
1053
  log(outEl, `Requesting: [${missing.join(", ")}]...`);
1035
- const ok = await client.auth.request({ scopes, reason });
1036
- log(outEl, ok ? "Authorized." : "Authorization denied.");
1037
- return ok;
1054
+ const result = await client.auth.authorize({
1055
+ target: { kind: "space", spaceId: ctx?.shell?.space?.id ?? ctx?.invocation?.spaceId },
1056
+ scopes,
1057
+ reason,
1058
+ });
1059
+ if (result.status === "granted") {
1060
+ log(outEl, `Authorized on ${result.target.kind === "space" ? result.target.spaceId : "account"}.`);
1061
+ } else {
1062
+ log(outEl, result.status === "cancelled" ? "Consent cancelled." : `Consent denied (${result.code}).`);
1063
+ }
1064
+ return result.status === "granted";
1038
1065
  }
1039
1066
 
1040
1067
  // --- LLM chat (space.prompt + subscribeGeneration) ---
@@ -1121,7 +1148,7 @@ async function generateImage(prompt, outEl) {
1121
1148
  return image?.source?.url ?? null;
1122
1149
  }
1123
1150
 
1124
- // --- Event handlers (auth.request must be in a user gesture) ---
1151
+ // --- Event handlers (auth.authorize must be in a user gesture) ---
1125
1152
  $("btn-context").addEventListener("click", async () => {
1126
1153
  const out = $("output-context");
1127
1154
  out.textContent = "";
@@ -1192,7 +1219,8 @@ $("btn-img").addEventListener("click", async () => {
1192
1219
  if (ctx) {
1193
1220
  setOutput($("output-context"), {
1194
1221
  "app.id": ctx.app?.id,
1195
- "space.id": ctx.space?.id,
1222
+ "app.homeSpace.id": ctx.app?.homeSpace?.id,
1223
+ mode: ctx.mode,
1196
1224
  permissions: ctx.permissions,
1197
1225
  });
1198
1226
  }
@@ -1223,19 +1251,19 @@ Before publishing your App, verify each item:
1223
1251
  `session.view`.
1224
1252
  - [ ] **`generation.create` does NOT include `taskrun.view`** — creating a
1225
1253
  generation task succeeds but polling the result 403s without `taskrun.view`.
1226
- - [ ] **`auth.request()` is called from a user gesture** (button click), not
1254
+ - [ ] **`auth.authorize()` is called from a user gesture** (button click), not
1227
1255
  on page load. It is safe to call repeatedly — covered scopes renew silently.
1228
1256
  - [ ] **Cross-Space access targets the right Space** — viewer grants are per
1229
- Space. Pass `spaceId` when requesting, or use `auth.requestSpace` to let the
1230
- viewer pick. Use `auth.requestCreateSpace` to mint a new viewer-owned Space
1231
- in one consent, or `spaces.create()` directly once the app holds a
1232
- `space.create` viewer grant.
1257
+ Space. Pass `target: { kind: "space", spaceId }` to name one, or
1258
+ `target: { kind: "pick-space" }` to let the viewer choose. Act on the
1259
+ returned `target`, which may differ from the request. Use
1260
+ `auth.requestCreateSpace` to mint a new viewer-owned Space in one consent, or
1261
+ `spaces.create()` directly once the app holds a `space.create` viewer grant.
1233
1262
  - [ ] **`subscribeGeneration` errors are not silently swallowed** — if the
1234
1263
  stream fails, surface it; a silent fallback to polling will also 403 if
1235
1264
  `session.view` is missing.
1236
- - [ ] **Broker mode**: if the App may be accessed standalone, pass
1237
- `app: { brokerOrigin, appId }` and call `auth.request()` before any other
1238
- API call (to avoid user-activation exhaustion).
1265
+ - [ ] **Broker mode**: initialize `context()` early so Origin discovery is warm,
1266
+ then call `auth.authorize()` from a user action before protected API calls.
1239
1267
  - [ ] **Space has a slug and owner has a username** before publishing — the
1240
1268
  API rejects Apps when either is missing.
1241
1269
  - [ ] **Model ids are not hardcoded** — use `client.models.listMultimodal()`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub",
3
- "version": "8.18.0",
3
+ "version": "8.20.0",
4
4
  "description": "Cohub SDK for spaces, sessions, boards, and realtime agent collaboration.",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,