@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.
- package/README.md +17 -9
- package/dist/chunks/environment.d.ts +72 -10
- package/dist/chunks/environment.js +5 -1
- package/dist/chunks/http.d.ts +78 -16
- package/dist/chunks/http.js +27 -11
- package/dist/chunks/websocket.d.ts +1 -1
- package/dist/chunks/websocket.js +1 -1
- package/dist/http.d.ts +2 -2
- package/dist/http.js +1 -1
- package/dist/index.d.ts +80 -3
- package/dist/index.js +826 -71
- package/docs/app-runtime-guide.md +174 -146
- package/package.json +1 -1
|
@@ -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
|
|
4
|
-
|
|
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.
|
|
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.
|
|
71
|
-
| `client.auth.
|
|
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
|
|
77
|
-
>
|
|
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.
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
> **
|
|
127
|
-
>
|
|
128
|
-
>
|
|
129
|
-
>
|
|
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
|
-
|
|
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,
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
162
|
-
`getAccessToken()`.
|
|
163
|
-
|
|
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,
|
|
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.
|
|
170
|
+
await client.auth.authorize({ target: { kind: "pick-space" }, scopes });
|
|
170
171
|
|
|
171
|
-
// RIGHT:
|
|
172
|
-
await client.auth.
|
|
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.
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
249
|
-
|
|
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
|
-
//
|
|
257
|
-
const
|
|
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
|
|
275
|
+
if (picked.status === "granted" && picked.target.kind === "space") {
|
|
276
|
+
const space = client.space(picked.target.spaceId);
|
|
263
277
|
}
|
|
264
278
|
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
scopes: ["
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
`
|
|
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.
|
|
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.
|
|
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
|
-
###
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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?.
|
|
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.
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
//
|
|
479
|
-
|
|
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
|
-
>
|
|
483
|
-
>
|
|
484
|
-
>
|
|
485
|
-
>
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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?.
|
|
1027
|
+
if (!ctx?.app?.id) {
|
|
1011
1028
|
throw new Error("Not running inside a published app runtime.");
|
|
1012
1029
|
}
|
|
1013
|
-
|
|
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.
|
|
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?.
|
|
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
|
|
1036
|
-
|
|
1037
|
-
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
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 `
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
`
|
|
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**:
|
|
1237
|
-
|
|
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()`
|