@recursiv/sdk 0.7.109 → 0.7.116

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 CHANGED
@@ -68,6 +68,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
68
68
  | `r.databases` | `ensure`, `create`, `list`, `getCredentials`, `query` |
69
69
  | `r.envVars` | `set`, `list`, `delete` (values write-only; secrets masked on read) |
70
70
  | `r.projects` | `create`, `deploy`, `executeCode`, `createSandbox`, `stopSandbox`, `deploymentLogs` |
71
+ | `r.deployments` | Operator release coordination: `reserveRelease`, `getRelease`, `advanceRelease`, `startRelease`, `finishRelease`, `recoverRelease`; follow the [shared release guide](../../docs/deploy/shared-releases.md) |
71
72
  | `r.storage` | `ensureBucket`, `getUploadUrl`, `getDownloadUrl`, `listItems`, `deleteObject` |
72
73
  | `r.telemetry` | `productCounts` — app-bound first-party aggregate counts; reviewed event names and counts only, retained at most 30 days |
73
74
  | `r.sandbox` | `execute` (anonymous, no API key needed) |
@@ -207,6 +208,8 @@ await r.projects.createSandbox('proj_1')
207
208
  await r.projects.stopSandbox('proj_1')
208
209
  ```
209
210
 
211
+ Shared API/frontend releases use `r.deployments` with a project-bound operator key carrying `projects:read` and `projects:write`. Read an existing reservation with `await r.deployments.getRelease(releaseId, projectId)`. Follow the [shared release guide](../../docs/deploy/shared-releases.md) for phase transitions, health proof and recovery; uncertain operations keep ownership held.
212
+
210
213
  ## AI agents
211
214
 
212
215
  ```typescript
@@ -318,6 +321,7 @@ const { apiKey, user } = await anonymous.auth.signInAndCreateKey(
318
321
  name: 'my-app',
319
322
  scopes: ['posts:read'],
320
323
  projectId: 'prj_xxx',
324
+ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
321
325
  consumeSession: true,
322
326
  },
323
327
  )
@@ -326,6 +330,7 @@ const { apiKey, user } = await anonymous.auth.signInAndCreateKey(
326
330
  `consumeSession: true` atomically retires the temporary login session as the
327
331
  API key is minted. Use it when the customer app stores and authenticates only
328
332
  with the returned API key; omit it for flows that still need the cookie session.
333
+ `createApiKey(input, sessionToken)` requires a signed-in session. Its optional `expiresAt` must be in the future and at most one year away. The creation receipt includes the stored `projectId`, `networkId` and `expiresAt`; validate these bindings before storing an operator key, and keep the returned key out of logs.
329
334
 
330
335
  ## Next.js server auth (`@recursiv/sdk/next`)
331
336
 
@@ -942,7 +947,9 @@ Both take a Politics choice, `compass` (`all`, `mix`, `hide`, or cells in the or
942
947
  `meta.compass_filter` (`status` `applied` or `unavailable`, and counts only), and each session
943
948
  page carries its Minds read's answer as `page.compass` (`unavailable`: the page is unfiltered). Only Minds
944
949
  posts are filtered, so a view that leaves posts out reads best with `source: 'minds'`.
945
- The catalog lists `compass_filter` on every network when the filter is served to the viewer.
950
+ The catalog lists `compass_filter` on every network when the filter is served to the viewer, and `compass_sides`
951
+ with it (0.7.116) when the viewer may choose sides and Mix too: a visitor's catalog lists it where the operator serves
952
+ visitors the whole filter; without it, offer a visitor Hide politics only.
946
953
  Hide politics (`compass: 'hide'`) also applies on its own, with or without compass judgments on the server, to
947
954
  `sdk.discovery.videos`, `protocols.getPublicTrends` and the radar (`getPublicRadar`, keyless `getSharedRadar`; 0.7.62):
948
955
  it leaves out what is categorised politics (a story not themed yet, or `mixed`, too) and says what it did in
@@ -959,7 +966,7 @@ From 0.7.72 Hide politics on those reads that need no compass judgment (the rada
959
966
  visitor's Discover posts) applies for every caller, whether or not the operator serves them the full filter: where it
960
967
  does not, Hide is the floor (the server's own `political` flags and browse categories, no judgment read) and
961
968
  `meta.compass_filter` says `status: 'applied'` with `basis: 'flags'`. Choose sides, Mix sides and the catalog's
962
- `compass_filter` capability still follow the operator's audience.
969
+ `compass_filter` and `compass_sides` capabilities still follow the operator's audience.
963
970
 
964
971
  Every story names one outlet count (0.7.71): radar stories, World Today stories, category strip stories, feed-top and search stories carry `outlets: { total, mainstream, independent, other }`, the distinct outlets of the articles the story lists (buckets sum to `total`). Show `outlets.total`; `sources.outlet` counts articles, not outlets.
965
972
 
package/dist/index.d.ts CHANGED
@@ -204,6 +204,7 @@ export { EnvVarsResource } from './resources/env-vars.js';
204
204
  export type { QueryDatabaseInput, QueryResult } from './resources/databases.js';
205
205
  export { GitHubResource } from './resources/github.js';
206
206
  export { DeploymentsResource } from './resources/deployments.js';
207
+ export type { DeploymentReleaseInput, DeploymentRelease, DeploymentReleaseOperation, DeploymentReleaseRecoveryInput } from './resources/deployments.js';
207
208
  export type { PrepareProtocolServersInput, PreparedProtocolServers, ProtocolServerPreparation, ProtocolServerUpgradePreview, ProtocolServerUpgradeInput, ProtocolServerUpgradeStatus } from './resources/deployments.js';
208
209
  export { IntegrationsResource } from './resources/integrations.js';
209
210
  export type { ExecuteToolInput, ExecuteToolResult } from './resources/integrations.js';
@@ -130,6 +130,8 @@ export interface VerifyOtpInput {
130
130
  export interface CreateApiKeyInput {
131
131
  name: string;
132
132
  scopes: string[];
133
+ /** ISO timestamp in the future, at most one year from creation. */
134
+ expiresAt?: string;
133
135
  /**
134
136
  * Optional human-readable note describing what the key is for. Stored on the
135
137
  * key record and surfaced in the dashboard / key listings.
@@ -197,6 +199,9 @@ export interface ApiKeyResult {
197
199
  scopes: string[];
198
200
  organizationId?: string | null;
199
201
  projectId?: string | null;
202
+ networkId?: string | null;
203
+ /** The server's stored expiry; null denotes a key without an expiry. */
204
+ expiresAt?: string | null;
200
205
  /** ISO timestamp of when the key was created. */
201
206
  createdAt?: string | null;
202
207
  }
@@ -31,6 +31,42 @@ export interface CreateDeploymentInput {
31
31
  commit_hash?: string;
32
32
  commit_message?: string;
33
33
  }
34
+ export interface DeploymentReleaseInput {
35
+ project_id: string;
36
+ /** Caller-generated random 32-byte secret, encoded as 64 lowercase hex characters. Never log or store in source. */
37
+ capability: string;
38
+ api_revision: string;
39
+ frontend_revision: string;
40
+ expected_frontend_revision: string;
41
+ frontend_deploy: boolean;
42
+ coordinator_repository: string;
43
+ run_id: number;
44
+ attempt: number;
45
+ }
46
+ export interface DeploymentRelease {
47
+ deployment_id: string;
48
+ status: string;
49
+ phase: 'reserved' | 'api_started' | 'api_verified' | 'started' | 'complete' | 'aborted';
50
+ api_revision: string;
51
+ frontend_revision: string;
52
+ expected_frontend_revision: string;
53
+ frontend_deploy: boolean;
54
+ coordinator_repository: string;
55
+ run_id: number;
56
+ attempt: number;
57
+ provider_deployment_id: string | null;
58
+ /** Non-secret recovery link; replay still requires both original and replacement capabilities. */
59
+ replacement_deployment_id: string | null;
60
+ }
61
+ export interface DeploymentReleaseOperation {
62
+ project_id: string;
63
+ capability: string;
64
+ }
65
+ export interface DeploymentReleaseRecoveryInput extends DeploymentReleaseOperation {
66
+ replacement: DeploymentReleaseInput;
67
+ /** Short-lived GitHub OIDC token from the main protected Promote workflow. Never log it. */
68
+ coordinator_identity_token: string;
69
+ }
34
70
  export interface GetLogsParams {
35
71
  coolify_app_uuid: string;
36
72
  lines?: number;
@@ -146,6 +182,22 @@ export interface HostedServiceObservation extends HostedServiceReservation {
146
182
  export declare class DeploymentsResource {
147
183
  private client;
148
184
  constructor(client: HttpClient);
185
+ /** Atomically block competing production deployments before promoting the API. Retry explicitly with the SAME capability. */
186
+ reserveRelease(input: DeploymentReleaseInput): Promise<SingleResponse<DeploymentRelease>>;
187
+ getRelease(deploymentId: string, projectId: string): Promise<SingleResponse<DeploymentRelease>>;
188
+ /** Mark immediately before API mutation, then only after the existing API health gates pass. */
189
+ advanceRelease(deploymentId: string, input: DeploymentReleaseOperation & {
190
+ phase: 'api_started' | 'api_verified';
191
+ api_revision: string;
192
+ }): Promise<SingleResponse<DeploymentRelease>>;
193
+ /** Consume the authenticated reservation after API verification; an unknown earlier trigger is never replayed. */
194
+ startRelease(deploymentId: string, input: DeploymentReleaseOperation): Promise<SingleResponse<DeploymentRelease>>;
195
+ /** Abort before API mutation, or finish an API-only release after proving the frontend unchanged. */
196
+ finishRelease(deploymentId: string, input: DeploymentReleaseOperation & {
197
+ operation: 'abort_before_api' | 'complete_api_only' | 'complete_paired';
198
+ }): Promise<SingleResponse<DeploymentRelease>>;
199
+ /** Atomically hand a settled interrupted release to a new protected runner; retry explicitly with identical ownership. */
200
+ recoverRelease(deploymentId: string, input: DeploymentReleaseRecoveryInput): Promise<SingleResponse<DeploymentRelease>>;
149
201
  /** Prepare a stopped staging service from a maintained recipe. Requires an unbound owner key. */
150
202
  prepareHostedService(input: PrepareHostedServiceInput): Promise<SingleResponse<HostedServiceReservation>>;
151
203
  getHostedService(scope: HostedServiceScope): Promise<SingleResponse<HostedServiceObservation | null>>;
@@ -3,6 +3,29 @@ export class DeploymentsResource {
3
3
  constructor(client) {
4
4
  this.client = client;
5
5
  }
6
+ /** Atomically block competing production deployments before promoting the API. Retry explicitly with the SAME capability. */
7
+ reserveRelease(input) {
8
+ return this.client.post('/deployments/releases/reserve', input, { retry: false });
9
+ }
10
+ getRelease(deploymentId, projectId) {
11
+ return this.client.get(`/deployments/releases/${deploymentId}`, { project_id: projectId });
12
+ }
13
+ /** Mark immediately before API mutation, then only after the existing API health gates pass. */
14
+ advanceRelease(deploymentId, input) {
15
+ return this.client.post(`/deployments/releases/${deploymentId}/advance`, input, { retry: false });
16
+ }
17
+ /** Consume the authenticated reservation after API verification; an unknown earlier trigger is never replayed. */
18
+ startRelease(deploymentId, input) {
19
+ return this.client.post(`/deployments/releases/${deploymentId}/start`, input, { retry: false });
20
+ }
21
+ /** Abort before API mutation, or finish an API-only release after proving the frontend unchanged. */
22
+ finishRelease(deploymentId, input) {
23
+ return this.client.post(`/deployments/releases/${deploymentId}/finish`, input, { retry: false });
24
+ }
25
+ /** Atomically hand a settled interrupted release to a new protected runner; retry explicitly with identical ownership. */
26
+ recoverRelease(deploymentId, input) {
27
+ return this.client.post(`/deployments/releases/${deploymentId}/recover`, input, { retry: false });
28
+ }
6
29
  /** Prepare a stopped staging service from a maintained recipe. Requires an unbound owner key. */
7
30
  prepareHostedService(input) {
8
31
  return this.client.post('/deployments/hosted-services/prepare', input);
@@ -1118,8 +1118,12 @@ export interface SocialNetwork {
1118
1118
  url: string;
1119
1119
  };
1120
1120
  coverage: string;
1121
- /** `compass_filter`: the Politics filter is served to this viewer (an app offers it only then); listed on every network. */
1122
- capabilities: Array<'profile_search' | 'profile_read' | 'original_author_posts' | 'minds_subscription' | 'following_feed' | 'conversation_read' | 'public_trends' | 'trend_feed' | 'popular_accounts' | 'post_search' | 'compass_filter'>;
1121
+ /**
1122
+ * `compass_filter`: the Politics filter is served to this viewer (an app offers it only then); listed on every network.
1123
+ * `compass_sides` (0.7.116): this viewer may also choose sides and Mix sides, not Hide politics alone; a visitor's
1124
+ * catalog lists it where the operator serves visitors the whole filter. Without it, offer a visitor Hide politics only.
1125
+ */
1126
+ capabilities: Array<'profile_search' | 'profile_read' | 'original_author_posts' | 'minds_subscription' | 'following_feed' | 'conversation_read' | 'public_trends' | 'trend_feed' | 'popular_accounts' | 'post_search' | 'compass_filter' | 'compass_sides'>;
1123
1127
  profileSearchPagination: boolean;
1124
1128
  postSearchAccountRequired?: boolean;
1125
1129
  /** Categories available from the current app configuration. */
package/llm.md CHANGED
@@ -100,6 +100,8 @@ const { data: logs } = await r.projects.deploymentLogs(project.id, deployment.id
100
100
 
101
101
  Methods: `list`, `get`, `create`, `delete`, `deploy`, `executeCode`, `sandbox`, `createSandbox`, `stopSandbox`, `deployment`, `deploymentLogs`, `deployments`
102
102
 
103
+ Operator API/frontend coordination: `r.deployments.reserveRelease(input)`, `getRelease(releaseId, projectId)`, `advanceRelease(releaseId, input)`, `startRelease(releaseId, input)`, `finishRelease(releaseId, input)`, `recoverRelease(originalReleaseId, input)`. Example read: `const { data: release } = await r.deployments.getRelease(releaseId, projectId)`. Use a project-bound `projects:read`/`projects:write` operator key; follow [shared release phases, live proof and recovery](../../docs/deploy/shared-releases.md). Keep capabilities out of logs; uncertain operations retain ownership.
104
+
103
105
  ### r.storage — Bucket-based file storage
104
106
 
105
107
  ```typescript
@@ -390,10 +392,10 @@ const session = await r.auth.signUp({ name: 'Jane', email: 'jane@example.com', p
390
392
  const session = await r.auth.signIn({ email: 'jane@example.com', password: 'secure12345!' })
391
393
 
392
394
  const key = await r.auth.createApiKey(
393
- { name: 'my-key', scopes: ['posts:read', 'agents:write'], description: 'CI bot' },
395
+ { name: 'my-key', scopes: ['posts:read', 'agents:write'], description: 'CI bot', expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString() },
394
396
  session.token,
395
397
  )
396
- // key: { key, id, name, prefix, lastFourChars, scopes, createdAt }
398
+ // key: { key, id, name, prefix, lastFourChars, scopes, projectId, networkId, expiresAt, createdAt }
397
399
 
398
400
  await r.auth.signOut(session.token)
399
401
  ```
@@ -402,6 +404,7 @@ await r.auth.signOut(session.token)
402
404
  Bearer auth, with a Cookie fallback for native/server runtimes. `getSession`
403
405
  also answers `session: { id, expiresAt }`; an API-key client lists sessions with
404
406
  `is_current: false`, so it matches `session.id` to mark its own device.
407
+ `createApiKey` requires a signed-in session; optional `expiresAt` is a future ISO timestamp at most one year away. Validate the receipt's stored `projectId`, `networkId` and `expiresAt` before storing an operator key, and never log its key value.
405
408
 
406
409
  Methods: `signUp`, `signIn`, `getSession`, `signOut`, `createApiKey`, `signInAndCreateKey`, `signUpAndCreateKey`, `verifyOtpAndCreateKey`
407
410
 
@@ -859,7 +862,7 @@ const showCheckedLine = isFactLive('sealed.attest_before_send', capabilities, {
859
862
  - `r.notifications` — The notification stream: `list({ category: 'social' })` (rows carry `actor`, `reaction_type` and a `post_preview` with the viewer's `user_reaction`/`reposted_by_viewer_id` and counts; an `agent_needs_input` row carries `answered`, true once you have written in the agent's thread since it asked, when its title reads "You answered <agent>"), `category: 'mentions'` (0.7.72) for a Mentions tab (mentions and replies only; `unreadCount` and `markAllAsRead` take it too), `unreadCount({ category })` for badges (`const { data } = await r.notifications.unreadCount({ category: 'social' })`), `markAsRead(id)`, `archive(id)` to dismiss, `markAllAsRead(category)` or `markAllAsRead({ category, organization_id })`, plus push token `registerToken`/`unregisterToken`. A push's `data.notificationId` is the row to mark read on tap. DMs are not in the stream.
860
863
  - `r.uploads` — Legacy media upload URLs (prefer `r.storage` for new projects)
861
864
  - `r.github` — GitHub integration (getRepository, getTree, getFileContent, getCommits, createFromTemplate)
862
- - `r.deployments` — Deployment management (prefer `r.projects.deploy()` for new projects)
865
+ - `r.deployments` — App deployment management plus protected API/frontend reservations (`reserveRelease`, `getRelease`, `advanceRelease`, `startRelease`, `finishRelease`, `recoverRelease`); see the [shared release guide](../../docs/deploy/shared-releases.md). Use `r.projects.deploy()` for ordinary app deploys.
863
866
  - `r.integrations` — External service integrations and agent tool connections. Connections: `listConnections(orgId)`, `connect()`, `confirmConnection()`. Deterministic tool execution (no agent/LLM in the loop): `executeTool({ connection_id, tool, arguments })` — run a tool yourself with explicit args + pagination and get the raw result back (e.g. `{ tool: 'GMAIL_FETCH_EMAILS', arguments: { query, max_results } }`); use for reliable batch coverage, vs `agents.chat` for autonomous behavior. Tool approvals: `listPendingExecutions(conversationId)`, `approveExecution(id)`, `rejectExecution(id)` — handles both integration tools and brain/platform tools (provision_sandbox etc.); use these to unblock a permission-mode agent waiting on tool approval
864
867
  - `r.email` — Email campaigns, batch sending, and transactional email. **Pass `category: 'transactional'`** for receipts, invites, confirmations and alerts: `r.email.send({ to, subject, html, category: 'transactional' })`. Without it the send is treated as marketing and held for anyone who has not given marketing consent. A send where every recipient was held throws `EmailNotSentError` (its message carries the reason). A `status: 'partial'` result lists each recipient's `status` and `reason` in `messages`.
865
868
  - `r.webhooks` — Register inbound webhook endpoints: `register()`, `list()`, `delete()`. Webhook events execute handler code in project sandbox.
@@ -892,11 +895,11 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
892
895
  - Hosted Bluesky account on the app's own PDS: `const { data } = await r.protocols.createHostedIdentity({ protocol: 'atproto', handle: 'alice', email })`. Only the label is sent; `data.handle` carries the domain and `data.password` is returned this once, never stored. Never auto-retried. Either the account is finished or it is removed again. 409 `handle_unavailable` = choose another label; 503 `handle_dns_unavailable`/`handle_unresolvable`/`handle_unverified` = operator setup, retry later unchanged.
893
896
  - Anonymous discovery: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows. Category depth: `r.posts.list({ category: 'science', sort: 'hot', discovery: true })` pages the hot window by offset and then by keyset (`meta.next` is `{ offset }`, `{ cursor }` or `null`; pass a cursor back as `cursor`); when a stage ends, `meta.fallover` lists the next feeds in order (`category_older`, `category_network`, `category` with `via`, `for_you`, `all`; `feed` with `ref` is declared in 0.7.60 but not sent yet, so skip kinds you cannot open). One call for a category's modules: `const { data } = await r.discovery.category({ category: 'science' })` returns `stories`, `network`, `clips`, `people`, `groups`, each `{ items, ms, reason? }`. Watch's Minds videos: `const { data, meta } = await r.discovery.videos({ limit: 12 })` returns public video posts in hot order from a list shared per app (rebuilt about once a minute), each re-checked per call for the viewer; page on `meta.next_cursor`. With no category about one row in four is an open-network clip (0.7.78): `isWatchNetworkClip(row)` tells `{ kind: 'social', id, post: SocialPost }` from a `Post`; open and react to it as the network's post (its `id` is never a Minds id).
894
897
  - Progressive Discover feed (0.7.71): `const feed = r.discovery.createProgressiveFeed({ networks, category, recommend, top: { ref: 'builtin:all' }, seen, seenClusters }); const round = await feed.next(part => draw(part))` opens one `createFeed` session per source (Minds with the radar and suggestions, one per network with `public_trends`) and hands each source's page on as a `DiscoveryPart` (`{ source, round, modules, composed?, mixed? }`) the moment it lands, instead of a page that waits for its slowest network (up to 15 s); a round waits `DISCOVERY_STRAGGLER_MS` past its first answer and rejects only when every source failed. The first load is held until Minds and one other network answered (or `FIRST_SCREEN_MS`, 2.5 s, after the first answer; `firstScreenMs: VISITOR_FIRST_SCREEN_MS` for a visitor) and arrives as one `composed: true` part from `composeFirstScreen`: the top's first post when it is a playable video, else a picture lead from the best 8 by engagement (never a story, rail, video strip, column, `isSensitivePost` post or `linksAdultSite` porn link; 0.7.80: never a post with no picture or clip and no engagement while the screen holds one with either, which then leads: media and engagement, else media, else engagement, as `mergeDiscoveryArrival` leads a feed's top and the feed after a line), then every network's posts by engagement (`DiscoveryEngagementMerge`) with row 2 from another network than the lead's when one is left (`FIRST_SCREEN_NETWORK_ROWS`), a story as row 3 and a rail as row 5, nothing dropped. Merge later `mixed` parts with `mergeDiscoveryArrival(rows, arriving, reachedIds)`. `top`/`seen`/`seenClusters`/`bucket` reach every session; the top is read once per open. Also `retryFailed()`, `onStatus(listener)`, `setNetworks(catalog)`; settings `perSource`, `firstScreen`, `onLanded`. For you: `createProgressiveFeed({ createFeed: o => r.discovery.createPersonalFeed(o) }, options, { perSource: false })`. A session's `seen` now also leaves seen network posts out of trend pages, previews and topic pages; in a category a topic page with nothing new ends its topic and after `DISCOVERY_EMPTY_TOPIC_PAGES` (3) in a row the network's other topics are skipped. 0.7.84: a featured module carries `theme` only when its lead window was themed so and most of its own posts were judged into that category (a category feed's posts, by the read's category filter); a feed with no category titles every module by its kind alone ("Videos", not "Videos in Technology" over an unjudged Nostr clip). A post is shown once by every identity (`discoveryItemIdentities(item)`: its key, an open-network post's network id, and each media file by address and by content hash, so a second note carrying the same clip is the same post) across a session's pages, a progressive feed's sources (`withoutSeenItems` takes identities or keys), `mergeDiscoveryArrival` and gallery pooling; `withoutRepeatedDiscoveryItems(rows, shown)` does the same for an app's own rows.
895
- - Viral read (0.7.59; off until the server enables it for the app): `const { data } = await r.protocols.getPublicViral({ media: 'video', category: 'science', limit: 20 })` (member key, users:read + posts:read, never cached) or keyless `visitor.protocols.getSharedViral(projectId, { media: 'video', compass: 'hide' })` (visitors send compass `all` or `hide` only). Params, all optional: `network` (all, minds, bluesky, activitypub, nostr), `category`, `media: 'video'`, `limit` 5/10/20/40, `cursor` (the last page's; a cursor whose 30-minute build expired ends the list), `compass`. Each item: `key` (stable; drop ones already shown), `protocol`, `post` (checking `protocol` narrows it: `ViralMindsCard` for minds, open at /post/:id; otherwise a `SocialPost`), `label` viral/rising/hot, `score`, `rank`, `category`, `velocity` (show counts with `countsReportedBy` as reported, never verified), `spread`. A visitor's read serves media only where the server cleared it, so even with `media: 'video'` some items arrive with the video withheld (empty `media`): `import { viralItemHasVideo } from '@recursiv/sdk'` and keep `data.items.filter(viralItemHasVideo)` before a vertical video player. `status` ok/building/screening/off: off resolves with no items, and `getSharedViral` also resolves an older API's 401 `missing_api_key` as off; `getPublicViral` throws every 401/403 (never off), so catch it and skip that step.
898
+ - Viral read (0.7.59; off until the server enables it for the app): `const { data } = await r.protocols.getPublicViral({ media: 'video', category: 'science', limit: 20 })` (member key, users:read + posts:read, never cached) or keyless `visitor.protocols.getSharedViral(projectId, { media: 'video', compass: 'hide' })` (visitors send compass `all` or `hide`, and a side or `mix` only where the catalog lists `compass_sides`). Params, all optional: `network` (all, minds, bluesky, activitypub, nostr), `category`, `media: 'video'`, `limit` 5/10/20/40, `cursor` (the last page's; a cursor whose 30-minute build expired ends the list), `compass`. Each item: `key` (stable; drop ones already shown), `protocol`, `post` (checking `protocol` narrows it: `ViralMindsCard` for minds, open at /post/:id; otherwise a `SocialPost`), `label` viral/rising/hot, `score`, `rank`, `category`, `velocity` (show counts with `countsReportedBy` as reported, never verified), `spread`. A visitor's read serves media only where the server cleared it, so even with `media: 'video'` some items arrive with the video withheld (empty `media`): `import { viralItemHasVideo } from '@recursiv/sdk'` and keep `data.items.filter(viralItemHasVideo)` before a vertical video player. `status` ok/building/screening/off: off resolves with no items, and `getSharedViral` also resolves an older API's 401 `missing_api_key` as off; `getPublicViral` throws every 401/403 (never off), so catch it and skip that step.
896
899
  - Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor?, compass?, media? })` (`compass: 'hide'` leaves out politics, 0.7.62; `media: 'video'`, 0.7.73, Bluesky: the network's own window of video posts, a `kind: posts` page paged by its cursor, no category or layout, 404 `unsupported_protocol` for a network without one, 400 on an API that predates it; keyless `getSharedTrends(projectId, { ..., media: 'video' })` holds only clips a visitor may see and counts the rest in `mediaFilter.withheld`); render `kind: topics` as source topics and `kind: posts` as source posts. With `trend_feed`, open only a returned current topic using `getPublicTrendFeed({ protocol, topic: trend.nativeId, limit?, cursor? })`. Show provider, observedAt and provider_ranked_window coverage; null rankingWindow is unknown, not a daily/global ranking. Preserve source order, canonical IDs and readRef, and opaque cursors. Topic catalogs do not paginate; Mastodon trends stop at 200 source positions. Both methods require users:read plus posts:read on the current human app key; Nostr trends are unsupported. Propagate errors; trend_unavailable means refresh the topic list. No ingestion or native writes.
897
900
  - One outlet count per story (0.7.71): a radar story (list, story read, archive), a World Today story (`outlets`, with `outlet_count` its total and `lane_mix` its lanes), a category strip story, a `feeds.top`/`feeds.stories` story and a search story carry `outlets: { total, mainstream, independent, other }`, the distinct outlets of the articles the story lists (an allow-listed outlet once, an unlisted host in `other`; the buckets sum to `total`). Show `outlets.total`; `sources.outlet` counts articles. `DiscoveryCluster.outletCount` reads it. An older server omits it.
898
901
  - Political stories (0.7.67): every radar story (`getPublicRadar`, `getSharedRadar`, a story read) and every World Today story (`r.curator.today()`) carries `political`: true when a quarter of its judged posts are politics, its theme or topic is politics, or its headline or summary names an election, a war or troop deployment, a court or justice, a legislature, or a party or politician. `compass: 'hide'` leaves those out whatever their theme; `r.curator.today({ compass: 'hide' })` does the same to the front page, the next story leads, and `meta.compass_filter` counts what was left out. A story from `r.feeds.stories()` or a `r.feeds.top()` lead carries `political` too, and both reads, and `r.curator.forYouWorld()`, leave political stories out under `compass: 'hide'` whatever their theme. An older server omits `political`: decide by `theme` as before.
899
- - Hide politics as a floor (0.7.72): the radar, `r.curator.today()`, `getPublicTrends`, keyless `visitor.protocols.getSharedTrends(projectId, { protocol, compass: 'hide' })` (visitors send `all` or `hide` only; an older API answers 400 to `compass` there), the viral read, `r.discovery.videos` and a visitor's Discover posts apply `compass: 'hide'` for every caller. Where the operator does not serve the caller the full filter, the answer's `meta.compass_filter` is `{ status: 'applied', basis: 'flags', ... }`: left out by the server's own `political` flags and browse categories, no judgment read. Treat `applied` with or without `basis` as filtered. Choose sides and Mix sides still answer `unavailable` until the operator serves them, and the catalog's `compass_filter` capability still says when to offer Politics in Filters.
902
+ - Hide politics as a floor (0.7.72): the radar, `r.curator.today()`, `getPublicTrends`, keyless `visitor.protocols.getSharedTrends(projectId, { protocol, compass: 'hide' })` (visitors send `all` or `hide`, and a side or `mix` only where the catalog lists `compass_sides`, 0.7.116; an older API answers 400 to `compass` there), the viral read, `r.discovery.videos` and a visitor's Discover posts apply `compass: 'hide'` for every caller. Where the operator does not serve the caller the full filter, the answer's `meta.compass_filter` is `{ status: 'applied', basis: 'flags', ... }`: left out by the server's own `political` flags and browse categories, no judgment read. Treat `applied` with or without `basis` as filtered. Choose sides and Mix sides still answer `unavailable` until the operator serves them, and the catalog's `compass_filter` capability still says when to offer Politics in Filters; `compass_sides` (0.7.116) says the viewer may choose sides and Mix too (a visitor only where the operator serves visitors the whole filter).
900
903
  - Post language: a `SocialPost` may carry `language`, the lower-case primary subtag the post declares (`'de'`, `'en'`; Bluesky `langs[0]`, Mastodon `language`, a Nostr NIP-32 `ISO-639-1` label). It is absent when the post declares none (most Nostr notes) and on posts from the source index; it is never guessed and no post is dropped for it. Order by it (the viewer's language first, undeclared next), never filter on it.
901
904
  - `listCandidates({ protocol?, limit?, cursor? })` and `getCandidate(id)` are read-only quarantine review, not published-post search. Both require `admin` scope, a project-bound key, a live admin role, and existing project-admin access. The key supplies app/network scope; neither method accepts another project or network. They never invoke refresh, change settings, or publish.
902
905
  - Lists return `{ data: ProtocolQuarantinedCandidate[], meta: { limit, has_more, next_cursor } }`; use the opaque `next_cursor`, not offset pagination. The API defaults `limit` to 20 and permits at most 50. Details return `{ data: ProtocolQuarantinedCandidate }`. Every candidate has `state: 'quarantined'` and `content_format: 'plain'`; render its bounded `content` as text, respect `content_truncated`, and distinguish upstream `published_at` from local `observed_at` / `created_at`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.109",
3
+ "version": "0.7.116",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",