viasocket-apps 0.1.1 → 0.2.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 CHANGED
@@ -15,6 +15,28 @@ The same calls are documented as plain HTTP in your viaSocket dashboard under
15
15
  **Integrations → your Apps API integration → API reference**. Use whichever suits your stack;
16
16
  this package is a thin, typed layer over those endpoints and adds nothing they don't have.
17
17
 
18
+ ## For coding agents
19
+
20
+ [![skills.sh](https://skills.sh/b/Walkover-Web-Solution/viasocket-apps)](https://skills.sh/Walkover-Web-Solution/viasocket-apps)
21
+
22
+ This repo also carries the **viaSocket integrations skill** — what a coding agent reads to
23
+ integrate any app into your product, with or without this package. Install it into Claude Code,
24
+ Cursor, Codex, Copilot, Gemini CLI, Windsurf, Cline and 20+ other agents:
25
+
26
+ ```sh
27
+ npx skills add Walkover-Web-Solution/viasocket-apps
28
+ ```
29
+
30
+ Add `-g` to install it for every project. No install? Paste this into your agent:
31
+ `Read https://flow.viasocket.com/documentation/skill.md and follow it.`
32
+
33
+ The agent needs three values from the viaSocket dashboard (**Integrations → Create embed →
34
+ Install Code**): `org_id` and `project_id`, which it asks for, and `VIASOCKET_EMBED_SECRET`, which
35
+ goes in your server's `.env`. The skill is
36
+ [skills/viasocket-integrations/SKILL.md](skills/viasocket-integrations/SKILL.md), generated by
37
+ viaSocket and mirrored here; the same text filled with your ids is served at
38
+ `https://flow.viasocket.com/documentation/skill.md?org=<org_id>&project=<project_id>`.
39
+
18
40
  ## Server
19
41
 
20
42
  ```js
@@ -49,12 +71,14 @@ const result = await viasocket.runAction(scriptId, actionVersionId, {
49
71
  column_key: true
50
72
  })
51
73
 
52
- // 5. Or subscribe to an event. Needs only the connection, not enable().
74
+ // 5. Or subscribe to an event. Needs only the connection, not enable(). `code` is a handler we
75
+ // run on our servers when the event fires — see "Events" below. Save the scriptId it returns
76
+ // together with the inputData and handler you sent: the flows list never returns those.
53
77
  const subscription = await user.subscribe(triggerVersionId, {
54
78
  authId,
55
79
  inputData: { channel_id: ['C08SXCV3J85'] },
56
- webhook: 'https://your-app.com/webhooks/viasocket',
57
- meta: { userId: 'user_123' }
80
+ code: handler,
81
+ meta: { user_id: 'user_123' }
58
82
  })
59
83
  ```
60
84
 
@@ -64,7 +88,9 @@ const subscription = await user.subscribe(triggerVersionId, {
64
88
  import { connect } from 'viasocket-apps/browser'
65
89
 
66
90
  // embedToken comes from your backend (user.token()), signed for the signed-in user.
67
- const { authId } = await connect({ embedToken, serviceId: 'rowbu58rc' })
91
+ // actions (optional): the rowids of the actions and triggers you use — the popup then asks only
92
+ // for their scopes; skipActionSelection goes straight to the app's consent screen.
93
+ const { authId } = await connect({ embedToken, serviceId: 'rowbu58rc', actions: ['<action rowid>'], skipActionSelection: true })
68
94
  // Send authId to your backend, which enables the app and stores the script_id.
69
95
  ```
70
96
 
@@ -79,27 +105,48 @@ const { authId } = await connect({ embedToken, serviceId: 'rowbu58rc' })
79
105
  | `user.token()` | Signed embed token for that user; hand it to the frontend. |
80
106
  | `user.enable(serviceId, authId)` | Turn a connection into a `scriptId` for running actions. Once per connection. |
81
107
  | `user.findEnabled(serviceId)` | The `scriptId` if this app is already enabled, else `null`. Call before `enable`. |
82
- | `user.listOptions(actionVersionId, { fieldKey, authId, existingFields })` | The values a field accepts, as `{ options, offset }`. Both response shapes normalised. |
108
+ | `user.listOptions(actionVersionId, { fieldKey, authId, existingFields })` | The values a field accepts, as `{ options, offset }`. Both response shapes normalised; an unreadable connection (revoked, another user's `auth_id`) throws a `ViaSocketError` with status 400 even though the API answers 200. |
83
109
  | `viasocket.runAction(scriptId, actionVersionId, inputData)` | Run one action. Returns the app's own response. |
84
- | `user.subscribe(triggerVersionId, { authId, inputData, webhook \| code, meta })` | Subscribe to an app event. Returns `{ scriptId, hookUrl, … }`. |
110
+ | `viasocket.catalog.search(key)` | The best 30 apps for what the user typed, as `{ serviceId, name, iconUrl, description }`. No token. |
111
+ | `viasocket.catalog.list({ limit, offset, category })` | Every app, most used first, 200 a page; an empty page ends it. Drop apps whose `versions()` is empty. No token. |
112
+ | `viasocket.catalog.versions(serviceId)` | Every published action and trigger of one app, as `{ actions, triggers }`, each with its `inputjson` schema and version id. No token. |
113
+ | `user.subscribe(triggerVersionId, { authId, inputData, code \| webhook, meta })` | Subscribe to an app event with a handler we run per event. Returns `{ scriptId, hookUrl, … }`. |
85
114
  | `user.updateSubscription(scriptId, { code, meta })` | Change a live subscription in place. |
86
115
 
87
- `webhook` is a URL of yours that we POST each event to. `code` is the alternative: a script we run
88
- on viaSocket's servers each time the event fires. It is a string, executed away from your process,
89
- so it must stand alone — no imports, nothing from your codebase. In scope there are `axios`, `fetch`
90
- and `context`, and the event the app sent is `context.req.body`:
116
+ ## Events
117
+
118
+ Subscribing to an event means telling us **what to do when it fires**. That is `code`: a handler we
119
+ run on viaSocket's servers per event, with the event in `context.req.body`. Your server is not in
120
+ the path. Most often the handler runs an action in another app the user connected — the run URL
121
+ takes no token, the `script_id` is the credential — or calls your own API with your own auth header
122
+ baked in. It is a string executed away from your process, so it must stand alone: no imports,
123
+ nothing from your codebase; `axios`, `fetch` and `context` are in scope.
91
124
 
92
125
  ```js
93
- await user.subscribe(triggerVersionId, {
94
- authId,
95
- inputData,
96
- code: `
97
- const event = context.req.body
98
- await axios.post("https://your-app.com/webhooks/viasocket", { event, user_id: "${endUserId}" })
99
- return { forwarded: true }
100
- `
101
- })
126
+ // A string, not a function: it runs on viaSocket's servers each time the event fires.
127
+ // In scope there: axios, fetch, context. The event the app sent is context.req.body.
128
+ // ${…} are YOUR variables, baked in per user when you subscribe.
129
+ const handler = `
130
+ const event = context.req.body
131
+ const response = await fetch("https://flow.sokt.io/func/${slackScriptId}", {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/json" },
134
+ body: JSON.stringify({
135
+ action_version_id: "${slackSendMessageVersionId}",
136
+ inputData: { channel_id: "${channelPickedAtSetup}", text: "New mail from " + event.from + ": " + event.subject }
137
+ })
138
+ })
139
+ return await response.json()
140
+ `
141
+
142
+ await user.subscribe(gmailNewMailVersionId, { authId, inputData: {}, code: handler, meta: { user_id: uniqueIdentifier } })
102
143
  ```
144
+
145
+ `webhook` is the edge case: a public, unauthenticated URL of yours that we POST every raw event to.
146
+ Use it only when that is what you want, and never both.
147
+
148
+ Some triggers are polled rather than pushed. For those, `inputData` may carry `scheduledTime`, the
149
+ minutes between checks, as a string: `"5"` or `"15"`.
103
150
  | `user.listFlows()` | Every enabled app and subscription this user has. |
104
151
  | `user.disableFlow(scriptId)` / `user.enableFlow(scriptId)` | Turn a flow off or back on. Disabling a subscription ends it. |
105
152
  | `user.listConnections()` | Every app this user has connected. |
@@ -101,6 +101,14 @@ async function connect(options) {
101
101
  }
102
102
  };
103
103
  window.addEventListener('message', onMessage);
104
- open(options.embedToken, serviceId);
104
+ const scriptOptions = {};
105
+ if (options.actions?.length)
106
+ scriptOptions.filteredActions = options.actions;
107
+ if (options.skipActionSelection)
108
+ scriptOptions.skipActionSelection = true;
109
+ if (Object.keys(scriptOptions).length)
110
+ open(options.embedToken, serviceId, scriptOptions);
111
+ else
112
+ open(options.embedToken, serviceId);
105
113
  });
106
114
  }
@@ -15,6 +15,13 @@ const http_js_1 = require("./http.js");
15
15
  const token_js_1 = require("./token.js");
16
16
  const DEFAULT_API_BASE_URL = 'https://flow-api.viasocket.com';
17
17
  const DEFAULT_RUN_BASE_URL = 'https://flow.sokt.io';
18
+ /** The public catalog: no token, the same data in every environment. */
19
+ const CATALOG = {
20
+ search: 'https://flow.sokt.io/func/scri12BSufQM',
21
+ list: 'https://plug-service.viasocket.com/api/v1/plugins/all',
22
+ versions: 'https://flow.sokt.io/func/scriolZue69X'
23
+ };
24
+ const LIST_LIMIT_MAX = 200;
18
25
  function required(name, value) {
19
26
  if (typeof value !== 'string' || !value.trim())
20
27
  throw new errors_js_1.ViaSocketError(`${name} is required`, { status: null });
@@ -42,6 +49,67 @@ class ViaSocket {
42
49
  // Bound so a bare `fetch` reference still has the right `this` on runtimes that care.
43
50
  this.transport = options.fetch ? transport : transport.bind(globalThis);
44
51
  }
52
+ /**
53
+ * The catalog: which apps exist and what each can do. Public, no token, nothing per user — so
54
+ * it lives on the root client and is safe to call from a browser too.
55
+ */
56
+ catalog = {
57
+ /** The best 30 apps for what the user typed. A type-ahead: an empty key returns nothing. */
58
+ search: async (key) => {
59
+ const url = `${CATALOG.search}?key=${encodeURIComponent(required('key', key))}`;
60
+ const { data } = await (0, http_js_1.request)({ method: 'GET', url, fetchImpl: this.transport });
61
+ return (Array.isArray(data) ? data : []).map((row) => ({
62
+ serviceId: row.service_id,
63
+ name: row.name,
64
+ description: row.description ?? '',
65
+ iconUrl: row.iconurl ?? ''
66
+ }));
67
+ },
68
+ /**
69
+ * Every app, most used first, a page at a time (200 at most). An empty page ends it. The list
70
+ * is the whole table — far down it holds apps with nothing published — so a grid drops every
71
+ * app whose `versions()` comes back empty.
72
+ */
73
+ list: async (params = {}) => {
74
+ const query = new URLSearchParams({
75
+ limit: String(Math.min(Math.max(1, params.limit ?? LIST_LIMIT_MAX), LIST_LIMIT_MAX)),
76
+ offset: String(Math.max(0, params.offset ?? 0))
77
+ });
78
+ if (params.category)
79
+ query.set('category', params.category);
80
+ // The body is `{ message, data: [...] }` with no `success` key, so it is not the envelope and
81
+ // arrives whole as `data`.
82
+ const { data } = await (0, http_js_1.request)({
83
+ method: 'GET',
84
+ url: `${CATALOG.list}?${query}`,
85
+ fetchImpl: this.transport
86
+ });
87
+ const rows = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
88
+ return rows.map((row) => ({
89
+ serviceId: String(row.rowid ?? ''),
90
+ name: String(row.name ?? ''),
91
+ description: String(row.description ?? ''),
92
+ iconUrl: String(row.iconurl ?? ''),
93
+ category: Array.isArray(row.category) ? row.category : [],
94
+ domain: typeof row.domain === 'string' ? row.domain : undefined
95
+ }));
96
+ },
97
+ /**
98
+ * Every published action and trigger of one app, with the schema (`inputjson`) a form is
99
+ * rendered from and the version id the run and subscribe calls take. Empty for an app with
100
+ * nothing published. Cache it per app; ids are stable and schemas change rarely.
101
+ */
102
+ versions: async (serviceId) => {
103
+ const { data } = await (0, http_js_1.request)({
104
+ method: 'POST',
105
+ url: CATALOG.versions,
106
+ body: JSON.stringify({ service_id: required('serviceId', serviceId) }),
107
+ fetchImpl: this.transport
108
+ });
109
+ const rows = Array.isArray(data) ? data : [];
110
+ return { actions: rows.filter((row) => row.type === 'action'), triggers: rows.filter((row) => row.type === 'trigger') };
111
+ }
112
+ };
45
113
  /** Everything that is done for one of your end users. */
46
114
  user(uniqueIdentifier) {
47
115
  return new UserScope(this, required('uniqueIdentifier', uniqueIdentifier));
@@ -124,16 +192,22 @@ class UserScope {
124
192
  });
125
193
  if (Array.isArray(data))
126
194
  return { options: data, offset: null };
195
+ // A connection that cannot be read (revoked, or another user's auth_id) is answered with 200 and
196
+ // success: true, the failure nested in data. It is an error, and the message is the explanation.
197
+ if (data?.response && Number(data.response.status) >= 400) {
198
+ throw new errors_js_1.ViaSocketError(data.response.data?.message || 'list-options failed', { status: Number(data.response.status), body: data });
199
+ }
127
200
  return { options: Array.isArray(data?.data) ? data.data : [], offset: data?.offset ?? null };
128
201
  }
129
202
  /**
130
- * Subscribes to an app event. Give `webhook` (a URL we POST each event to) or `code` (a
131
- * self-contained script we run on our servers per event, with the event at
132
- * `context.req.body`) — one or the other.
203
+ * Subscribes to an app event with a handler: `code`, a self-contained script we run on our
204
+ * servers each time the event fires, with the event at `context.req.body`. It runs an action in
205
+ * another app the user connected, or calls your own API. `webhook` (a public URL we POST raw
206
+ * events to) is the edge case. Exactly one of the two.
133
207
  */
134
208
  async subscribe(triggerVersionId, params) {
135
209
  if (!params?.webhook === !params?.code) {
136
- throw new errors_js_1.ViaSocketError('subscribe: pass exactly one of `webhook` or `code`', { status: null });
210
+ throw new errors_js_1.ViaSocketError('subscribe: pass exactly one of `code` (a handler we run per event) or `webhook`', { status: null });
137
211
  }
138
212
  const body = {
139
213
  auth_id: required('authId', params.authId),
package/dist/cjs/http.js CHANGED
@@ -8,10 +8,11 @@
8
8
  * that message is almost always the explanation.
9
9
  *
10
10
  * The action runner is the exception: some actions answer in that envelope, many hand back their
11
- * own result at the top level with no `success` or `data` key at all (Gmail's list-mails returns
12
- * `{ emails, nextPageToken }`). So a body only counts as an envelope when it actually carries a
13
- * boolean `success`; anything else is the result itself. Getting this wrong returned `undefined`
14
- * for every such action.
11
+ * own result at the top level — with no `success` or `data` key at all (Gmail's list-mails returns
12
+ * `{ emails, nextPageToken }`), or with a `success` key of their own and no `data` (Slack's "Find
13
+ * Public channel" returns `{ success, channels }`). So a body is the envelope only when it carries a
14
+ * boolean `success` AND a `data` key, or is a refusal (`success: false`); anything else is the
15
+ * result itself. Getting this wrong returned `undefined` for every such action.
15
16
  */
16
17
  Object.defineProperty(exports, "__esModule", { value: true });
17
18
  exports.request = request;
@@ -35,7 +36,8 @@ async function request(options) {
35
36
  catch {
36
37
  payload = text;
37
38
  }
38
- const isEnvelope = payload !== null && typeof payload === 'object' && typeof payload.success === 'boolean';
39
+ const shaped = payload !== null && typeof payload === 'object' ? payload : null;
40
+ const isEnvelope = shaped !== null && typeof shaped.success === 'boolean' && ('data' in shaped || shaped.success === false);
39
41
  const envelope = isEnvelope ? payload : null;
40
42
  if (!response.ok || envelope?.success === false) {
41
43
  const raw = payload && typeof payload === 'object' ? payload : null;
package/dist/cjs/index.js CHANGED
@@ -5,13 +5,13 @@
5
5
  * import { ViaSocket } from 'viasocket-apps'
6
6
  *
7
7
  * const viasocket = new ViaSocket({ orgId, projectId, secret: process.env.VIASOCKET_EMBED_SECRET })
8
- * const user = viasocket.user(endUserId)
8
+ * const user = viasocket.user(uniqueIdentifier)
9
9
  *
10
10
  * const token = await user.token() // for the connect popup
11
11
  * const { scriptId } = await user.enable(serviceId, authId) // once, for actions
12
12
  * const { options } = await user.listOptions(actionVersionId, {...}) // fill a dropdown
13
13
  * const result = await viasocket.runAction(scriptId, actionVersionId, inputData)
14
- * const sub = await user.subscribe(triggerVersionId, { authId, inputData, webhook })
14
+ * const sub = await user.subscribe(triggerVersionId, { authId, inputData, code: handler })
15
15
  *
16
16
  * The browser half — opening the connect popup — lives at 'viasocket-apps/browser'.
17
17
  */
@@ -27,9 +27,21 @@ export interface ConnectOptions {
27
27
  embedToken: string;
28
28
  /** The app to connect. Its service_id is shown on every app in the Apps API reference. */
29
29
  serviceId: string;
30
+ /**
31
+ * The rowids of the actions and triggers this product uses (each is listed with its action in
32
+ * the app's document). The popup then asks only for their scopes. Omit to offer every action.
33
+ */
34
+ actions?: string[];
35
+ /** With `actions`: skip the action list and open the app's consent screen directly. */
36
+ skipActionSelection?: boolean;
30
37
  /** Where the connect script is served from. Only override for a non-production environment. */
31
38
  scriptUrl?: string;
32
39
  }
40
+ /** The third argument of the connect script's `openViasocketConnection`. */
41
+ interface ConnectScriptOptions {
42
+ filteredActions?: string[];
43
+ skipActionSelection?: boolean;
44
+ }
33
45
  export interface ConnectResult {
34
46
  /** The connection. Every later call for this app takes it as auth_id. */
35
47
  authId: string;
@@ -39,7 +51,7 @@ export interface ConnectResult {
39
51
  }
40
52
  declare global {
41
53
  interface Window {
42
- openViasocketConnection?: (embedToken: string, serviceId: string) => void;
54
+ openViasocketConnection?: (embedToken: string, serviceId: string, options?: ConnectScriptOptions) => void;
43
55
  }
44
56
  }
45
57
  /**
@@ -54,3 +66,4 @@ export declare function loadConnectScript(scriptUrl?: string): Promise<void>;
54
66
  * 'rejected' if the app said no, 'script' if the connect script could not load.
55
67
  */
56
68
  export declare function connect(options: ConnectOptions): Promise<ConnectResult>;
69
+ export {};
@@ -95,6 +95,14 @@ export async function connect(options) {
95
95
  }
96
96
  };
97
97
  window.addEventListener('message', onMessage);
98
- open(options.embedToken, serviceId);
98
+ const scriptOptions = {};
99
+ if (options.actions?.length)
100
+ scriptOptions.filteredActions = options.actions;
101
+ if (options.skipActionSelection)
102
+ scriptOptions.skipActionSelection = true;
103
+ if (Object.keys(scriptOptions).length)
104
+ open(options.embedToken, serviceId, scriptOptions);
105
+ else
106
+ open(options.embedToken, serviceId);
99
107
  });
100
108
  }
@@ -7,7 +7,7 @@
7
7
  * everything else behind `user(id)` makes that distinction structural rather than something a
8
8
  * reader has to remember.
9
9
  */
10
- import type { EnableResult, Flow, FlowStatus, ListOptionsParams, ListOptionsResult, SubscribeParams, SubscribeResult, UpdateSubscriptionParams, ViaSocketOptions } from './types.js';
10
+ import type { CatalogApp, CatalogVersions, EnableResult, Flow, FlowStatus, ListOptionsParams, ListAppsParams, ListOptionsResult, SubscribeParams, SubscribeResult, UpdateSubscriptionParams, ViaSocketOptions } from './types.js';
11
11
  export declare class ViaSocket {
12
12
  readonly orgId: string;
13
13
  readonly projectId: string;
@@ -15,6 +15,26 @@ export declare class ViaSocket {
15
15
  readonly runBaseUrl: string;
16
16
  private readonly secret;
17
17
  constructor(options: ViaSocketOptions);
18
+ /**
19
+ * The catalog: which apps exist and what each can do. Public, no token, nothing per user — so
20
+ * it lives on the root client and is safe to call from a browser too.
21
+ */
22
+ readonly catalog: {
23
+ /** The best 30 apps for what the user typed. A type-ahead: an empty key returns nothing. */
24
+ search: (key: string) => Promise<CatalogApp[]>;
25
+ /**
26
+ * Every app, most used first, a page at a time (200 at most). An empty page ends it. The list
27
+ * is the whole table — far down it holds apps with nothing published — so a grid drops every
28
+ * app whose `versions()` comes back empty.
29
+ */
30
+ list: (params?: ListAppsParams) => Promise<CatalogApp[]>;
31
+ /**
32
+ * Every published action and trigger of one app, with the schema (`inputjson`) a form is
33
+ * rendered from and the version id the run and subscribe calls take. Empty for an app with
34
+ * nothing published. Cache it per app; ids are stable and schemas change rarely.
35
+ */
36
+ versions: (serviceId: string) => Promise<CatalogVersions>;
37
+ };
18
38
  /** Everything that is done for one of your end users. */
19
39
  user(uniqueIdentifier: string): UserScope;
20
40
  /**
@@ -56,9 +76,10 @@ export declare class UserScope {
56
76
  */
57
77
  listOptions(actionVersionId: string, params: ListOptionsParams): Promise<ListOptionsResult>;
58
78
  /**
59
- * Subscribes to an app event. Give `webhook` (a URL we POST each event to) or `code` (a
60
- * self-contained script we run on our servers per event, with the event at
61
- * `context.req.body`) — one or the other.
79
+ * Subscribes to an app event with a handler: `code`, a self-contained script we run on our
80
+ * servers each time the event fires, with the event at `context.req.body`. It runs an action in
81
+ * another app the user connected, or calls your own API. `webhook` (a public URL we POST raw
82
+ * events to) is the edge case. Exactly one of the two.
62
83
  */
63
84
  subscribe(triggerVersionId: string, params: SubscribeParams): Promise<SubscribeResult>;
64
85
  /** Changes a live subscription in place. The script_id does not change. */
@@ -12,6 +12,13 @@ import { request } from './http.js';
12
12
  import { signEmbedToken } from './token.js';
13
13
  const DEFAULT_API_BASE_URL = 'https://flow-api.viasocket.com';
14
14
  const DEFAULT_RUN_BASE_URL = 'https://flow.sokt.io';
15
+ /** The public catalog: no token, the same data in every environment. */
16
+ const CATALOG = {
17
+ search: 'https://flow.sokt.io/func/scri12BSufQM',
18
+ list: 'https://plug-service.viasocket.com/api/v1/plugins/all',
19
+ versions: 'https://flow.sokt.io/func/scriolZue69X'
20
+ };
21
+ const LIST_LIMIT_MAX = 200;
15
22
  function required(name, value) {
16
23
  if (typeof value !== 'string' || !value.trim())
17
24
  throw new ViaSocketError(`${name} is required`, { status: null });
@@ -39,6 +46,67 @@ export class ViaSocket {
39
46
  // Bound so a bare `fetch` reference still has the right `this` on runtimes that care.
40
47
  this.transport = options.fetch ? transport : transport.bind(globalThis);
41
48
  }
49
+ /**
50
+ * The catalog: which apps exist and what each can do. Public, no token, nothing per user — so
51
+ * it lives on the root client and is safe to call from a browser too.
52
+ */
53
+ catalog = {
54
+ /** The best 30 apps for what the user typed. A type-ahead: an empty key returns nothing. */
55
+ search: async (key) => {
56
+ const url = `${CATALOG.search}?key=${encodeURIComponent(required('key', key))}`;
57
+ const { data } = await request({ method: 'GET', url, fetchImpl: this.transport });
58
+ return (Array.isArray(data) ? data : []).map((row) => ({
59
+ serviceId: row.service_id,
60
+ name: row.name,
61
+ description: row.description ?? '',
62
+ iconUrl: row.iconurl ?? ''
63
+ }));
64
+ },
65
+ /**
66
+ * Every app, most used first, a page at a time (200 at most). An empty page ends it. The list
67
+ * is the whole table — far down it holds apps with nothing published — so a grid drops every
68
+ * app whose `versions()` comes back empty.
69
+ */
70
+ list: async (params = {}) => {
71
+ const query = new URLSearchParams({
72
+ limit: String(Math.min(Math.max(1, params.limit ?? LIST_LIMIT_MAX), LIST_LIMIT_MAX)),
73
+ offset: String(Math.max(0, params.offset ?? 0))
74
+ });
75
+ if (params.category)
76
+ query.set('category', params.category);
77
+ // The body is `{ message, data: [...] }` with no `success` key, so it is not the envelope and
78
+ // arrives whole as `data`.
79
+ const { data } = await request({
80
+ method: 'GET',
81
+ url: `${CATALOG.list}?${query}`,
82
+ fetchImpl: this.transport
83
+ });
84
+ const rows = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
85
+ return rows.map((row) => ({
86
+ serviceId: String(row.rowid ?? ''),
87
+ name: String(row.name ?? ''),
88
+ description: String(row.description ?? ''),
89
+ iconUrl: String(row.iconurl ?? ''),
90
+ category: Array.isArray(row.category) ? row.category : [],
91
+ domain: typeof row.domain === 'string' ? row.domain : undefined
92
+ }));
93
+ },
94
+ /**
95
+ * Every published action and trigger of one app, with the schema (`inputjson`) a form is
96
+ * rendered from and the version id the run and subscribe calls take. Empty for an app with
97
+ * nothing published. Cache it per app; ids are stable and schemas change rarely.
98
+ */
99
+ versions: async (serviceId) => {
100
+ const { data } = await request({
101
+ method: 'POST',
102
+ url: CATALOG.versions,
103
+ body: JSON.stringify({ service_id: required('serviceId', serviceId) }),
104
+ fetchImpl: this.transport
105
+ });
106
+ const rows = Array.isArray(data) ? data : [];
107
+ return { actions: rows.filter((row) => row.type === 'action'), triggers: rows.filter((row) => row.type === 'trigger') };
108
+ }
109
+ };
42
110
  /** Everything that is done for one of your end users. */
43
111
  user(uniqueIdentifier) {
44
112
  return new UserScope(this, required('uniqueIdentifier', uniqueIdentifier));
@@ -120,16 +188,22 @@ export class UserScope {
120
188
  });
121
189
  if (Array.isArray(data))
122
190
  return { options: data, offset: null };
191
+ // A connection that cannot be read (revoked, or another user's auth_id) is answered with 200 and
192
+ // success: true, the failure nested in data. It is an error, and the message is the explanation.
193
+ if (data?.response && Number(data.response.status) >= 400) {
194
+ throw new ViaSocketError(data.response.data?.message || 'list-options failed', { status: Number(data.response.status), body: data });
195
+ }
123
196
  return { options: Array.isArray(data?.data) ? data.data : [], offset: data?.offset ?? null };
124
197
  }
125
198
  /**
126
- * Subscribes to an app event. Give `webhook` (a URL we POST each event to) or `code` (a
127
- * self-contained script we run on our servers per event, with the event at
128
- * `context.req.body`) — one or the other.
199
+ * Subscribes to an app event with a handler: `code`, a self-contained script we run on our
200
+ * servers each time the event fires, with the event at `context.req.body`. It runs an action in
201
+ * another app the user connected, or calls your own API. `webhook` (a public URL we POST raw
202
+ * events to) is the edge case. Exactly one of the two.
129
203
  */
130
204
  async subscribe(triggerVersionId, params) {
131
205
  if (!params?.webhook === !params?.code) {
132
- throw new ViaSocketError('subscribe: pass exactly one of `webhook` or `code`', { status: null });
206
+ throw new ViaSocketError('subscribe: pass exactly one of `code` (a handler we run per event) or `webhook`', { status: null });
133
207
  }
134
208
  const body = {
135
209
  auth_id: required('authId', params.authId),
@@ -7,10 +7,11 @@
7
7
  * that message is almost always the explanation.
8
8
  *
9
9
  * The action runner is the exception: some actions answer in that envelope, many hand back their
10
- * own result at the top level with no `success` or `data` key at all (Gmail's list-mails returns
11
- * `{ emails, nextPageToken }`). So a body only counts as an envelope when it actually carries a
12
- * boolean `success`; anything else is the result itself. Getting this wrong returned `undefined`
13
- * for every such action.
10
+ * own result at the top level — with no `success` or `data` key at all (Gmail's list-mails returns
11
+ * `{ emails, nextPageToken }`), or with a `success` key of their own and no `data` (Slack's "Find
12
+ * Public channel" returns `{ success, channels }`). So a body is the envelope only when it carries a
13
+ * boolean `success` AND a `data` key, or is a refusal (`success: false`); anything else is the
14
+ * result itself. Getting this wrong returned `undefined` for every such action.
14
15
  */
15
16
  import type { Envelope } from './types.js';
16
17
  export interface RequestOptions {
package/dist/esm/http.js CHANGED
@@ -7,10 +7,11 @@
7
7
  * that message is almost always the explanation.
8
8
  *
9
9
  * The action runner is the exception: some actions answer in that envelope, many hand back their
10
- * own result at the top level with no `success` or `data` key at all (Gmail's list-mails returns
11
- * `{ emails, nextPageToken }`). So a body only counts as an envelope when it actually carries a
12
- * boolean `success`; anything else is the result itself. Getting this wrong returned `undefined`
13
- * for every such action.
10
+ * own result at the top level — with no `success` or `data` key at all (Gmail's list-mails returns
11
+ * `{ emails, nextPageToken }`), or with a `success` key of their own and no `data` (Slack's "Find
12
+ * Public channel" returns `{ success, channels }`). So a body is the envelope only when it carries a
13
+ * boolean `success` AND a `data` key, or is a refusal (`success: false`); anything else is the
14
+ * result itself. Getting this wrong returned `undefined` for every such action.
14
15
  */
15
16
  import { ViaSocketError } from './errors.js';
16
17
  export async function request(options) {
@@ -32,7 +33,8 @@ export async function request(options) {
32
33
  catch {
33
34
  payload = text;
34
35
  }
35
- const isEnvelope = payload !== null && typeof payload === 'object' && typeof payload.success === 'boolean';
36
+ const shaped = payload !== null && typeof payload === 'object' ? payload : null;
37
+ const isEnvelope = shaped !== null && typeof shaped.success === 'boolean' && ('data' in shaped || shaped.success === false);
36
38
  const envelope = isEnvelope ? payload : null;
37
39
  if (!response.ok || envelope?.success === false) {
38
40
  const raw = payload && typeof payload === 'object' ? payload : null;
@@ -4,13 +4,13 @@
4
4
  * import { ViaSocket } from 'viasocket-apps'
5
5
  *
6
6
  * const viasocket = new ViaSocket({ orgId, projectId, secret: process.env.VIASOCKET_EMBED_SECRET })
7
- * const user = viasocket.user(endUserId)
7
+ * const user = viasocket.user(uniqueIdentifier)
8
8
  *
9
9
  * const token = await user.token() // for the connect popup
10
10
  * const { scriptId } = await user.enable(serviceId, authId) // once, for actions
11
11
  * const { options } = await user.listOptions(actionVersionId, {...}) // fill a dropdown
12
12
  * const result = await viasocket.runAction(scriptId, actionVersionId, inputData)
13
- * const sub = await user.subscribe(triggerVersionId, { authId, inputData, webhook })
13
+ * const sub = await user.subscribe(triggerVersionId, { authId, inputData, code: handler })
14
14
  *
15
15
  * The browser half — opening the connect popup — lives at 'viasocket-apps/browser'.
16
16
  */
@@ -18,4 +18,4 @@ export { ViaSocket, UserScope } from './client.js';
18
18
  export { ViaSocketError } from './errors.js';
19
19
  export { signEmbedToken } from './token.js';
20
20
  export type { SignEmbedTokenInput } from './token.js';
21
- export type { EnableResult, Envelope, Flow, FlowStatus, ListOptionsParams, ListOptionsResult, Option, SubscribeParams, SubscribeResult, UpdateSubscriptionParams, ViaSocketOptions } from './types.js';
21
+ export type { CatalogApp, CatalogVersion, CatalogVersions, EnableResult, Envelope, Flow, FlowStatus, ListAppsParams, ListOptionsParams, ListOptionsResult, Option, SubscribeParams, SubscribeResult, UpdateSubscriptionParams, ViaSocketOptions } from './types.js';
package/dist/esm/index.js CHANGED
@@ -4,13 +4,13 @@
4
4
  * import { ViaSocket } from 'viasocket-apps'
5
5
  *
6
6
  * const viasocket = new ViaSocket({ orgId, projectId, secret: process.env.VIASOCKET_EMBED_SECRET })
7
- * const user = viasocket.user(endUserId)
7
+ * const user = viasocket.user(uniqueIdentifier)
8
8
  *
9
9
  * const token = await user.token() // for the connect popup
10
10
  * const { scriptId } = await user.enable(serviceId, authId) // once, for actions
11
11
  * const { options } = await user.listOptions(actionVersionId, {...}) // fill a dropdown
12
12
  * const result = await viasocket.runAction(scriptId, actionVersionId, inputData)
13
- * const sub = await user.subscribe(triggerVersionId, { authId, inputData, webhook })
13
+ * const sub = await user.subscribe(triggerVersionId, { authId, inputData, code: handler })
14
14
  *
15
15
  * The browser half — opening the connect popup — lives at 'viasocket-apps/browser'.
16
16
  */
@@ -50,19 +50,23 @@ export interface EnableResult {
50
50
  }
51
51
  export interface SubscribeParams {
52
52
  authId: string;
53
- /** The event's own configuration — which channel, which sheet. */
53
+ /**
54
+ * The event's own configuration — which channel, which sheet. For polled triggers it may also
55
+ * carry `scheduledTime`, the minutes between checks as a string: "5" or "15".
56
+ */
54
57
  inputData: Record<string, unknown>;
55
- /** Your endpoint. We POST every event to it. Use this or `code`, not both. */
56
- webhook?: string;
57
58
  /**
58
- * Instead of a webhook: a script we run on viaSocket's servers each time the event fires.
59
+ * What to do when the event fires: a script we run on viaSocket's servers per event. The
60
+ * default. It runs an action in another app the user connected, or calls your own API.
59
61
  *
60
62
  * It is a string of JavaScript executed away from your process, so it must stand alone — no
61
63
  * imports and no reference to anything in your codebase. In scope there: `axios`, `fetch` and
62
- * `context`; the event the app sent is `context.req.body`. Bake in any value of yours (a user
63
- * id, a URL) when you build the string.
64
+ * `context`; the event the app sent is `context.req.body`. Bake in any value of yours (a
65
+ * script_id, a picked id, an API key) when you build the string. Exactly one of `code` or `webhook`.
64
66
  */
65
67
  code?: string;
68
+ /** Edge case: a public, unauthenticated URL of yours. We POST every raw event to it. */
69
+ webhook?: string;
66
70
  /** Anything of yours; handed back with every delivery. */
67
71
  meta?: Record<string, unknown>;
68
72
  }
@@ -94,6 +98,43 @@ export interface Flow {
94
98
  }
95
99
  export type FlowStatus = 0 | 1;
96
100
  /** The envelope every viaSocket endpoint answers with. */
101
+ /** One app of the catalog, from a search or the full list. */
102
+ export interface CatalogApp {
103
+ /** The `service_id` every other call takes. */
104
+ serviceId: string;
105
+ name: string;
106
+ description: string;
107
+ iconUrl: string;
108
+ /** Only the full list carries these. */
109
+ category?: string[];
110
+ domain?: string;
111
+ }
112
+ export interface ListAppsParams {
113
+ /** Up to 200, the API's cap. */
114
+ limit?: number;
115
+ offset?: number;
116
+ /** One of the catalog's category names, e.g. "CRM". */
117
+ category?: string;
118
+ }
119
+ /** One published action or trigger version of an app, as the catalog returns it. */
120
+ export interface CatalogVersion {
121
+ /** The `action_version_id` (or trigger version id) the run and subscribe calls take. */
122
+ actionversionrecordid: string;
123
+ type: 'action' | 'trigger' | string;
124
+ name: string;
125
+ description: string;
126
+ /** The field schema a form is rendered from; `inputData` follows it. */
127
+ inputjson: Record<string, unknown>;
128
+ sampledata?: unknown;
129
+ pluginrecordid?: string;
130
+ iconurl?: string;
131
+ authtype?: string;
132
+ [key: string]: unknown;
133
+ }
134
+ export interface CatalogVersions {
135
+ actions: CatalogVersion[];
136
+ triggers: CatalogVersion[];
137
+ }
97
138
  export interface Envelope<T = unknown> {
98
139
  success: boolean;
99
140
  message?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viasocket-apps",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Server-side client for the viaSocket Apps API: let your users connect Slack, GitHub, Google Sheets or any of 2,300+ apps from your own product, then run actions and receive events through one API.",
5
5
  "keywords": [
6
6
  "viasocket",