@tabs-and-spaces/sdk 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +40 -2
- package/dist/client.d.ts +92 -0
- package/dist/client.js +172 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/protocol.d.ts +181 -0
- package/dist/protocol.js +102 -0
- package/dist/server.d.ts +107 -0
- package/dist/server.js +152 -0
- package/package.json +65 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tabs & Spaces
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tabs-and-spaces/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build apps for [Tabs & Spaces](https://tabsandspaces.dev): a Custom tab's page talks to the
|
|
4
|
+
workspace showing it, and your server checks who's asking. No dependencies.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install @tabs-and-spaces/sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
**In your Custom tab's page:**
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { connect } from "@tabs-and-spaces/sdk";
|
|
14
|
+
|
|
15
|
+
const tands = await connect();
|
|
16
|
+
document.querySelector("h1")!.textContent = `Hello, ${tands.context.user.name}`;
|
|
17
|
+
|
|
18
|
+
// Data your manifest declares, kept by the workspace.
|
|
19
|
+
await tands.collection("notes").create({ text: "Ship it" });
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**On your server** (Node 22+, Deno, Bun, Cloudflare Workers):
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { fetchKeys, verifyToken } from "@tabs-and-spaces/sdk/server";
|
|
26
|
+
|
|
27
|
+
const WORKSPACE = "https://chat.acme.com"; // the deployment you serve
|
|
28
|
+
const keys = await fetchKeys(WORKSPACE); // cache these
|
|
29
|
+
|
|
30
|
+
const { context } = await verifyToken(token, {
|
|
31
|
+
keys,
|
|
32
|
+
audience: "https://notes.example.com", // your app's origin
|
|
33
|
+
issuer: WORKSPACE,
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Start an app in a minute: `npx tands new my-app` ([tands](https://www.npmjs.com/package/tands)).
|
|
38
|
+
|
|
39
|
+
Docs: **https://developer.tabsandspaces.dev/sdk/**
|
|
40
|
+
|
|
41
|
+
MIT licensed.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type DataRecord, type DataRequest, type DataScope, type EventKey, type TabContext, type Theme } from "./protocol.js";
|
|
2
|
+
/** A refusal from the workspace, with its reason's code (like `INVALID` or `FORBIDDEN`). */
|
|
3
|
+
export declare class WorkspaceError extends Error {
|
|
4
|
+
readonly code: string | null;
|
|
5
|
+
constructor(message: string, code: string | null);
|
|
6
|
+
}
|
|
7
|
+
/** Free-form values in one scope: settings, state, anything up to 16 KB each. */
|
|
8
|
+
export interface Values {
|
|
9
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
10
|
+
set(key: string, value: unknown): Promise<void>;
|
|
11
|
+
delete(key: string): Promise<boolean>;
|
|
12
|
+
list(): Promise<Record<string, unknown>>;
|
|
13
|
+
}
|
|
14
|
+
/** Records in one of the collections your manifest declares. */
|
|
15
|
+
export interface Collection<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
16
|
+
list(options?: {
|
|
17
|
+
where?: Partial<Record<keyof T & string, string | number | boolean | null>>;
|
|
18
|
+
orderBy?: {
|
|
19
|
+
field: (keyof T & string) | "createdAt" | "updatedAt";
|
|
20
|
+
direction?: "asc" | "desc";
|
|
21
|
+
};
|
|
22
|
+
limit?: number;
|
|
23
|
+
cursor?: string;
|
|
24
|
+
}): Promise<{
|
|
25
|
+
records: DataRecord<T>[];
|
|
26
|
+
cursor: string | null;
|
|
27
|
+
}>;
|
|
28
|
+
get(id: string): Promise<DataRecord<T>>;
|
|
29
|
+
create(data: T): Promise<DataRecord<T>>;
|
|
30
|
+
/** The fields that change; `null` clears one that isn't required. */
|
|
31
|
+
update(id: string, data: Partial<{
|
|
32
|
+
[K in keyof T]: T[K] | null;
|
|
33
|
+
}>): Promise<DataRecord<T>>;
|
|
34
|
+
delete(id: string): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export interface Connection {
|
|
37
|
+
/** Who's looking, and where. */
|
|
38
|
+
readonly context: TabContext;
|
|
39
|
+
/** The workspace's theme now. */
|
|
40
|
+
readonly theme: Theme;
|
|
41
|
+
/**
|
|
42
|
+
* A token for your server: who's looking, signed by the workspace. Fresh
|
|
43
|
+
* (a new one is fetched when this one has less than a minute left).
|
|
44
|
+
*/
|
|
45
|
+
token(): Promise<string>;
|
|
46
|
+
/** Free-form values: the workspace's, this space's, this tab's, or the reader's own. */
|
|
47
|
+
values(scope: DataScope): Values;
|
|
48
|
+
/** Records in a collection your manifest declares (its scope is the manifest's). */
|
|
49
|
+
collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string): Collection<T>;
|
|
50
|
+
/** Any data request, as sent (what `values()` and `collection()` use). */
|
|
51
|
+
data(request: DataRequest): Promise<unknown>;
|
|
52
|
+
/**
|
|
53
|
+
* Reports something that happened, as your manifest's `events` declare it
|
|
54
|
+
* (triggers in this space may start a task). Resolves once it's taken.
|
|
55
|
+
*/
|
|
56
|
+
emit(name: string, data?: Record<string, unknown>): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Asks for a key your server can report events with, in this tab, whenever they
|
|
59
|
+
* happen (a webhook from another service, say). The person sees what's asked and
|
|
60
|
+
* allows it (only those who manage the space's tabs can); a new key replaces this
|
|
61
|
+
* tab's last one. Fails with `DECLINED` if they don't, `FORBIDDEN` if they can't.
|
|
62
|
+
*/
|
|
63
|
+
requestEventKey(): Promise<EventKey>;
|
|
64
|
+
/**
|
|
65
|
+
* Opens a new trigger in this space, starting on one of your manifest's events in this
|
|
66
|
+
* tab (or the first, without `event`): the person sets the task and saves it. Fails
|
|
67
|
+
* with `NO_TASKS_TAB` when the space has no Tasks tab to keep triggers in.
|
|
68
|
+
*/
|
|
69
|
+
openTrigger(event?: string): Promise<void>;
|
|
70
|
+
/** Called when the reader switches between light and dark. Returns an unsubscribe. */
|
|
71
|
+
onTheme(listener: (theme: Theme) => void): () => void;
|
|
72
|
+
/** Stops listening. */
|
|
73
|
+
close(): void;
|
|
74
|
+
}
|
|
75
|
+
export interface ConnectOptions {
|
|
76
|
+
/** Give up after this long without the workspace answering (default 10 s). */
|
|
77
|
+
timeoutMs?: number;
|
|
78
|
+
/** For tests: the window the page runs in. */
|
|
79
|
+
window?: Window;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Connects a Custom tab's page to the workspace showing it. Resolves with who's
|
|
83
|
+
* looking; rejects when the page isn't in a workspace (opened on its own) or it
|
|
84
|
+
* doesn't answer in time.
|
|
85
|
+
*
|
|
86
|
+
* ```ts
|
|
87
|
+
* const tands = await connect();
|
|
88
|
+
* greet(tands.context.user.name);
|
|
89
|
+
* await fetch("/api/contacts", { headers: { authorization: `Bearer ${await tands.token()}` } });
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export declare function connect({ timeoutMs, window: win }?: ConnectOptions): Promise<Connection>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readHostMessage, } from "./protocol.js";
|
|
2
|
+
/** A refusal from the workspace, with its reason's code (like `INVALID` or `FORBIDDEN`). */
|
|
3
|
+
export class WorkspaceError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(message, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Less than this left on a token: get another. */
|
|
11
|
+
const REFRESH_MS = 60_000;
|
|
12
|
+
/**
|
|
13
|
+
* Connects a Custom tab's page to the workspace showing it. Resolves with who's
|
|
14
|
+
* looking; rejects when the page isn't in a workspace (opened on its own) or it
|
|
15
|
+
* doesn't answer in time.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* const tands = await connect();
|
|
19
|
+
* greet(tands.context.user.name);
|
|
20
|
+
* await fetch("/api/contacts", { headers: { authorization: `Bearer ${await tands.token()}` } });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function connect({ timeoutMs = 10_000, window: win = window } = {}) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const parent = win.parent;
|
|
26
|
+
if (!parent || parent === win) {
|
|
27
|
+
reject(new Error("This page isn't open in a Tabs & Spaces workspace."));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Learned from the first answer: later messages go there, and only theirs count.
|
|
31
|
+
let hostOrigin = null;
|
|
32
|
+
let context = null;
|
|
33
|
+
let current = { token: "", expiresAt: 0 };
|
|
34
|
+
let theme = "light";
|
|
35
|
+
let counter = 0;
|
|
36
|
+
const themeListeners = new Set();
|
|
37
|
+
const waiting = new Map();
|
|
38
|
+
let pending = null;
|
|
39
|
+
const send = (message) => parent.postMessage(message, hostOrigin ?? "*");
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
win.removeEventListener("message", listen);
|
|
42
|
+
reject(new Error("The workspace didn't answer."));
|
|
43
|
+
}, timeoutMs);
|
|
44
|
+
function listen(event) {
|
|
45
|
+
if (event.source !== parent)
|
|
46
|
+
return;
|
|
47
|
+
if (hostOrigin && event.origin !== hostOrigin)
|
|
48
|
+
return;
|
|
49
|
+
const message = readHostMessage(event.data);
|
|
50
|
+
if (!message)
|
|
51
|
+
return;
|
|
52
|
+
switch (message.type) {
|
|
53
|
+
case "context":
|
|
54
|
+
if (context)
|
|
55
|
+
return;
|
|
56
|
+
hostOrigin = event.origin;
|
|
57
|
+
context = message.context;
|
|
58
|
+
theme = message.theme;
|
|
59
|
+
current = { token: message.token, expiresAt: Date.parse(message.expiresAt) };
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
resolve(connection);
|
|
62
|
+
return;
|
|
63
|
+
case "token": {
|
|
64
|
+
const waiter = waiting.get(message.id);
|
|
65
|
+
if (!waiter)
|
|
66
|
+
return;
|
|
67
|
+
waiting.delete(message.id);
|
|
68
|
+
current = { token: message.token, expiresAt: Date.parse(message.expiresAt) };
|
|
69
|
+
waiter.resolve(message.token);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
case "data": {
|
|
73
|
+
const waiter = waiting.get(message.id);
|
|
74
|
+
if (!waiter)
|
|
75
|
+
return;
|
|
76
|
+
waiting.delete(message.id);
|
|
77
|
+
waiter.resolve(message.result);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
case "error": {
|
|
81
|
+
const waiter = message.id ? waiting.get(message.id) : undefined;
|
|
82
|
+
if (!waiter || !message.id)
|
|
83
|
+
return;
|
|
84
|
+
waiting.delete(message.id);
|
|
85
|
+
waiter.reject(new WorkspaceError(message.message, message.code ?? null));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
case "theme":
|
|
89
|
+
theme = message.theme;
|
|
90
|
+
for (const listener of themeListeners)
|
|
91
|
+
listener(theme);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Sends a request; its answer (or refusal) settles it. */
|
|
96
|
+
function ask(message) {
|
|
97
|
+
return new Promise((ok, fail) => {
|
|
98
|
+
const id = `r${++counter}`;
|
|
99
|
+
waiting.set(id, { resolve: ok, reject: fail });
|
|
100
|
+
send(message(id));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const data = (request) => ask((id) => ({ tands: 1, type: "data", id, request }));
|
|
104
|
+
const connection = {
|
|
105
|
+
get context() {
|
|
106
|
+
return context;
|
|
107
|
+
},
|
|
108
|
+
get theme() {
|
|
109
|
+
return theme;
|
|
110
|
+
},
|
|
111
|
+
token() {
|
|
112
|
+
if (current.expiresAt - Date.now() > REFRESH_MS)
|
|
113
|
+
return Promise.resolve(current.token);
|
|
114
|
+
pending ??= new Promise((ok, fail) => {
|
|
115
|
+
const id = `t${++counter}`;
|
|
116
|
+
waiting.set(id, { resolve: ok, reject: fail });
|
|
117
|
+
send({ tands: 1, type: "token", id });
|
|
118
|
+
}).finally(() => {
|
|
119
|
+
pending = null;
|
|
120
|
+
});
|
|
121
|
+
return pending;
|
|
122
|
+
},
|
|
123
|
+
data,
|
|
124
|
+
async emit(name, fields = {}) {
|
|
125
|
+
await ask((id) => ({ tands: 1, type: "event", id, name, data: fields }));
|
|
126
|
+
},
|
|
127
|
+
requestEventKey: () => ask((id) => ({ tands: 1, type: "eventKey", id })),
|
|
128
|
+
async openTrigger(event) {
|
|
129
|
+
await ask((id) => ({ tands: 1, type: "openTrigger", id, event: event ?? null }));
|
|
130
|
+
},
|
|
131
|
+
values(scope) {
|
|
132
|
+
return {
|
|
133
|
+
get: async (key) => (await data({ op: "values.get", scope, key })).value,
|
|
134
|
+
set: async (key, value) => {
|
|
135
|
+
await data({ op: "values.set", scope, key, value });
|
|
136
|
+
},
|
|
137
|
+
delete: async (key) => (await data({ op: "values.delete", scope, key })).deleted,
|
|
138
|
+
list: async () => (await data({ op: "values.list", scope }))
|
|
139
|
+
.values,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
collection(name) {
|
|
143
|
+
return {
|
|
144
|
+
list: async (options = {}) => (await data({
|
|
145
|
+
op: "records.list",
|
|
146
|
+
collection: name,
|
|
147
|
+
...options,
|
|
148
|
+
})),
|
|
149
|
+
get: async (id) => (await data({ op: "records.get", collection: name, id })).record,
|
|
150
|
+
create: async (fields) => (await data({ op: "records.create", collection: name, data: fields })).record,
|
|
151
|
+
update: async (id, fields) => (await data({ op: "records.update", collection: name, id, data: fields }))
|
|
152
|
+
.record,
|
|
153
|
+
delete: async (id) => {
|
|
154
|
+
await data({ op: "records.delete", collection: name, id });
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
onTheme(listener) {
|
|
159
|
+
themeListeners.add(listener);
|
|
160
|
+
return () => themeListeners.delete(listener);
|
|
161
|
+
},
|
|
162
|
+
close() {
|
|
163
|
+
win.removeEventListener("message", listen);
|
|
164
|
+
for (const waiter of waiting.values())
|
|
165
|
+
waiter.reject(new Error("Closed."));
|
|
166
|
+
waiting.clear();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
win.addEventListener("message", listen);
|
|
170
|
+
send({ tands: 1, type: "hello" });
|
|
171
|
+
});
|
|
172
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabs & Spaces apps (M9). In a Custom tab's page: `connect()`. On your
|
|
3
|
+
* server: `verifyToken()` from "@tabs-and-spaces/sdk/server".
|
|
4
|
+
*/
|
|
5
|
+
export { connect, WorkspaceError, type Collection, type Connection, type ConnectOptions, type Values, } from "./client.js";
|
|
6
|
+
export { PROTOCOL_VERSION, readAppMessage, readHostMessage, type AppMessage, type DataRecord, type DataRequest, type DataScope, type EventKey, type HostMessage, type TabContext, type Theme, } from "./protocol.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabs & Spaces apps (M9). In a Custom tab's page: `connect()`. On your
|
|
3
|
+
* server: `verifyToken()` from "@tabs-and-spaces/sdk/server".
|
|
4
|
+
*/
|
|
5
|
+
export { connect, WorkspaceError, } from "./client.js";
|
|
6
|
+
export { PROTOCOL_VERSION, readAppMessage, readHostMessage, } from "./protocol.js";
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a Custom tab's page and the workspace around it talk (version 1), with
|
|
3
|
+
* `postMessage`. The page says hello to its parent; the workspace answers with
|
|
4
|
+
* who's looking and a token, sent to the app's origin only, and later with new
|
|
5
|
+
* tokens and theme changes. Every message carries `tands: 1`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PROTOCOL_VERSION = 1;
|
|
8
|
+
/** Who's looking, and where (also inside the token, signed). */
|
|
9
|
+
export interface TabContext {
|
|
10
|
+
user: {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
};
|
|
14
|
+
workspace: {
|
|
15
|
+
id: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
name: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* `name` is the one the reader sees: General and My Space in their language,
|
|
21
|
+
* a DM by its other people.
|
|
22
|
+
*/
|
|
23
|
+
space: {
|
|
24
|
+
id: string;
|
|
25
|
+
kind: "general" | "personal" | "normal" | "dm";
|
|
26
|
+
name: string;
|
|
27
|
+
};
|
|
28
|
+
/** `appTabId`: which of the app's tabs this is. */
|
|
29
|
+
tab: {
|
|
30
|
+
id: string;
|
|
31
|
+
appTabId: string;
|
|
32
|
+
};
|
|
33
|
+
app: {
|
|
34
|
+
id: string;
|
|
35
|
+
};
|
|
36
|
+
/** The reader's language, like "en" or "fr". */
|
|
37
|
+
locale: string;
|
|
38
|
+
}
|
|
39
|
+
export type Theme = "light" | "dark";
|
|
40
|
+
/** Where app data lives, and so who sees it. */
|
|
41
|
+
export type DataScope = "workspace" | "space" | "tab" | "user";
|
|
42
|
+
/** A request for the app's data (the workspace adds where, and who's asking). */
|
|
43
|
+
export type DataRequest = {
|
|
44
|
+
op: "values.get";
|
|
45
|
+
scope: DataScope;
|
|
46
|
+
key: string;
|
|
47
|
+
} | {
|
|
48
|
+
op: "values.set";
|
|
49
|
+
scope: DataScope;
|
|
50
|
+
key: string;
|
|
51
|
+
value: unknown;
|
|
52
|
+
} | {
|
|
53
|
+
op: "values.delete";
|
|
54
|
+
scope: DataScope;
|
|
55
|
+
key: string;
|
|
56
|
+
} | {
|
|
57
|
+
op: "values.list";
|
|
58
|
+
scope: DataScope;
|
|
59
|
+
} | {
|
|
60
|
+
op: "records.list";
|
|
61
|
+
collection: string;
|
|
62
|
+
where?: Record<string, string | number | boolean | null>;
|
|
63
|
+
orderBy?: {
|
|
64
|
+
field: string;
|
|
65
|
+
direction?: "asc" | "desc";
|
|
66
|
+
};
|
|
67
|
+
limit?: number;
|
|
68
|
+
cursor?: string;
|
|
69
|
+
} | {
|
|
70
|
+
op: "records.get";
|
|
71
|
+
collection: string;
|
|
72
|
+
id: string;
|
|
73
|
+
} | {
|
|
74
|
+
op: "records.create";
|
|
75
|
+
collection: string;
|
|
76
|
+
data: Record<string, unknown>;
|
|
77
|
+
} | {
|
|
78
|
+
op: "records.update";
|
|
79
|
+
collection: string;
|
|
80
|
+
id: string;
|
|
81
|
+
data: Record<string, unknown>;
|
|
82
|
+
} | {
|
|
83
|
+
op: "records.delete";
|
|
84
|
+
collection: string;
|
|
85
|
+
id: string;
|
|
86
|
+
};
|
|
87
|
+
/** A record, as the workspace returns it. */
|
|
88
|
+
export interface DataRecord<T = Record<string, unknown>> {
|
|
89
|
+
id: string;
|
|
90
|
+
data: T;
|
|
91
|
+
createdBy: {
|
|
92
|
+
userId: string;
|
|
93
|
+
name: string;
|
|
94
|
+
};
|
|
95
|
+
createdAt: string;
|
|
96
|
+
updatedAt: string;
|
|
97
|
+
}
|
|
98
|
+
/** From the page to the workspace. */
|
|
99
|
+
export type AppMessage = {
|
|
100
|
+
tands: 1;
|
|
101
|
+
type: "hello";
|
|
102
|
+
}
|
|
103
|
+
/** A new token, before the current one expires (`id` matches the answer). */
|
|
104
|
+
| {
|
|
105
|
+
tands: 1;
|
|
106
|
+
type: "token";
|
|
107
|
+
id: string;
|
|
108
|
+
}
|
|
109
|
+
/** The app's data (`id` matches the answer). */
|
|
110
|
+
| {
|
|
111
|
+
tands: 1;
|
|
112
|
+
type: "data";
|
|
113
|
+
id: string;
|
|
114
|
+
request: DataRequest;
|
|
115
|
+
}
|
|
116
|
+
/** Something that happened, as the manifest's `events` declare it (answered like data). */
|
|
117
|
+
| {
|
|
118
|
+
tands: 1;
|
|
119
|
+
type: "event";
|
|
120
|
+
id: string;
|
|
121
|
+
name: string;
|
|
122
|
+
data: Record<string, unknown>;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A key for the app's server to report events in this tab on its own (answered like
|
|
126
|
+
* data, with an `EventKey`), once the person allows it.
|
|
127
|
+
*/
|
|
128
|
+
| {
|
|
129
|
+
tands: 1;
|
|
130
|
+
type: "eventKey";
|
|
131
|
+
id: string;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Opens a new trigger in this space, starting on one of the app's events in this tab
|
|
135
|
+
* (decision 174). Answered like data once it's open.
|
|
136
|
+
*/
|
|
137
|
+
| {
|
|
138
|
+
tands: 1;
|
|
139
|
+
type: "openTrigger";
|
|
140
|
+
id: string;
|
|
141
|
+
event: string | null;
|
|
142
|
+
};
|
|
143
|
+
/** What the app's server reports events with, at `eventsUrl` (decision 170). */
|
|
144
|
+
export interface EventKey {
|
|
145
|
+
key: string;
|
|
146
|
+
eventsUrl: string;
|
|
147
|
+
}
|
|
148
|
+
/** From the workspace to the page. */
|
|
149
|
+
export type HostMessage = {
|
|
150
|
+
tands: 1;
|
|
151
|
+
type: "context";
|
|
152
|
+
context: TabContext;
|
|
153
|
+
token: string;
|
|
154
|
+
expiresAt: string;
|
|
155
|
+
theme: Theme;
|
|
156
|
+
} | {
|
|
157
|
+
tands: 1;
|
|
158
|
+
type: "token";
|
|
159
|
+
id: string;
|
|
160
|
+
token: string;
|
|
161
|
+
expiresAt: string;
|
|
162
|
+
} | {
|
|
163
|
+
tands: 1;
|
|
164
|
+
type: "data";
|
|
165
|
+
id: string;
|
|
166
|
+
result: unknown;
|
|
167
|
+
} | {
|
|
168
|
+
tands: 1;
|
|
169
|
+
type: "error";
|
|
170
|
+
id?: string;
|
|
171
|
+
message: string;
|
|
172
|
+
code?: string;
|
|
173
|
+
} | {
|
|
174
|
+
tands: 1;
|
|
175
|
+
type: "theme";
|
|
176
|
+
theme: Theme;
|
|
177
|
+
};
|
|
178
|
+
/** A message from an app's page, or null. */
|
|
179
|
+
export declare function readAppMessage(data: unknown): AppMessage | null;
|
|
180
|
+
/** A message from the workspace, or null. */
|
|
181
|
+
export declare function readHostMessage(data: unknown): HostMessage | null;
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a Custom tab's page and the workspace around it talk (version 1), with
|
|
3
|
+
* `postMessage`. The page says hello to its parent; the workspace answers with
|
|
4
|
+
* who's looking and a token, sent to the app's origin only, and later with new
|
|
5
|
+
* tokens and theme changes. Every message carries `tands: 1`.
|
|
6
|
+
*/
|
|
7
|
+
export const PROTOCOL_VERSION = 1;
|
|
8
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
9
|
+
/** A message from an app's page, or null. */
|
|
10
|
+
export function readAppMessage(data) {
|
|
11
|
+
if (!isRecord(data) || data.tands !== PROTOCOL_VERSION)
|
|
12
|
+
return null;
|
|
13
|
+
if (data.type === "hello")
|
|
14
|
+
return { tands: 1, type: "hello" };
|
|
15
|
+
if (data.type === "token" && typeof data.id === "string" && data.id.length <= 64) {
|
|
16
|
+
return { tands: 1, type: "token", id: data.id };
|
|
17
|
+
}
|
|
18
|
+
if (data.type === "data" &&
|
|
19
|
+
typeof data.id === "string" &&
|
|
20
|
+
data.id.length <= 64 &&
|
|
21
|
+
isRecord(data.request) &&
|
|
22
|
+
typeof data.request.op === "string") {
|
|
23
|
+
// Checked in full by the workspace's server.
|
|
24
|
+
return { tands: 1, type: "data", id: data.id, request: data.request };
|
|
25
|
+
}
|
|
26
|
+
if (data.type === "eventKey" && typeof data.id === "string" && data.id.length <= 64) {
|
|
27
|
+
return { tands: 1, type: "eventKey", id: data.id };
|
|
28
|
+
}
|
|
29
|
+
if (data.type === "openTrigger" &&
|
|
30
|
+
typeof data.id === "string" &&
|
|
31
|
+
data.id.length <= 64 &&
|
|
32
|
+
(data.event === undefined ||
|
|
33
|
+
data.event === null ||
|
|
34
|
+
(typeof data.event === "string" && data.event.length <= 40))) {
|
|
35
|
+
return {
|
|
36
|
+
tands: 1,
|
|
37
|
+
type: "openTrigger",
|
|
38
|
+
id: data.id,
|
|
39
|
+
event: typeof data.event === "string" ? data.event : null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (data.type === "event" &&
|
|
43
|
+
typeof data.id === "string" &&
|
|
44
|
+
data.id.length <= 64 &&
|
|
45
|
+
typeof data.name === "string" &&
|
|
46
|
+
data.name.length <= 40 &&
|
|
47
|
+
(data.data === undefined || isRecord(data.data))) {
|
|
48
|
+
return {
|
|
49
|
+
tands: 1,
|
|
50
|
+
type: "event",
|
|
51
|
+
id: data.id,
|
|
52
|
+
name: data.name,
|
|
53
|
+
data: data.data ?? {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/** A message from the workspace, or null. */
|
|
59
|
+
export function readHostMessage(data) {
|
|
60
|
+
if (!isRecord(data) || data.tands !== PROTOCOL_VERSION)
|
|
61
|
+
return null;
|
|
62
|
+
const theme = data.theme === "dark" ? "dark" : "light";
|
|
63
|
+
switch (data.type) {
|
|
64
|
+
case "context":
|
|
65
|
+
return isRecord(data.context) &&
|
|
66
|
+
typeof data.token === "string" &&
|
|
67
|
+
typeof data.expiresAt === "string"
|
|
68
|
+
? {
|
|
69
|
+
tands: 1,
|
|
70
|
+
type: "context",
|
|
71
|
+
context: data.context,
|
|
72
|
+
token: data.token,
|
|
73
|
+
expiresAt: data.expiresAt,
|
|
74
|
+
theme,
|
|
75
|
+
}
|
|
76
|
+
: null;
|
|
77
|
+
case "token":
|
|
78
|
+
return typeof data.id === "string" &&
|
|
79
|
+
typeof data.token === "string" &&
|
|
80
|
+
typeof data.expiresAt === "string"
|
|
81
|
+
? { tands: 1, type: "token", id: data.id, token: data.token, expiresAt: data.expiresAt }
|
|
82
|
+
: null;
|
|
83
|
+
case "data":
|
|
84
|
+
return typeof data.id === "string"
|
|
85
|
+
? { tands: 1, type: "data", id: data.id, result: data.result }
|
|
86
|
+
: null;
|
|
87
|
+
case "error":
|
|
88
|
+
return typeof data.message === "string"
|
|
89
|
+
? {
|
|
90
|
+
tands: 1,
|
|
91
|
+
type: "error",
|
|
92
|
+
message: data.message,
|
|
93
|
+
...(typeof data.id === "string" ? { id: data.id } : {}),
|
|
94
|
+
...(typeof data.code === "string" ? { code: data.code } : {}),
|
|
95
|
+
}
|
|
96
|
+
: null;
|
|
97
|
+
case "theme":
|
|
98
|
+
return { tands: 1, type: "theme", theme };
|
|
99
|
+
default:
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { DataRecord, DataRequest, DataScope, EventKey, TabContext } from "./protocol.js";
|
|
2
|
+
/**
|
|
3
|
+
* Checking a Custom tab's token on your server (Node 22+, Deno, Bun, Workers:
|
|
4
|
+
* anywhere with Web Crypto's Ed25519). The workspace signs it (EdDSA); its
|
|
5
|
+
* public keys are at `<workspace address>/api/apps/keys`.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const keys = await fetchKeys("https://chat.acme.com");
|
|
9
|
+
* const { context } = await verifyToken(token, { keys, audience: "https://crm.example.com" });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export interface PublicKey {
|
|
13
|
+
kty: string;
|
|
14
|
+
crv: string;
|
|
15
|
+
x: string;
|
|
16
|
+
kid: string;
|
|
17
|
+
alg?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface VerifiedToken {
|
|
20
|
+
context: TabContext;
|
|
21
|
+
/** The deployment that signed it (its public address). */
|
|
22
|
+
issuer: string;
|
|
23
|
+
expiresAt: Date;
|
|
24
|
+
/** An action's token (sent to your action's endpoint): which action. */
|
|
25
|
+
action: string | null;
|
|
26
|
+
}
|
|
27
|
+
export declare class TokenError extends Error {
|
|
28
|
+
}
|
|
29
|
+
/** The deployment's public keys (cache them; they change only if its secret does). */
|
|
30
|
+
export declare function fetchKeys(deploymentUrl: string, fetcher?: typeof fetch): Promise<PublicKey[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Checks a token: signed by one of `keys`, meant for `audience` (your app's
|
|
33
|
+
* origin), not expired, and (optionally) from `issuer`. Returns who's looking.
|
|
34
|
+
*/
|
|
35
|
+
export declare function verifyToken(token: string, { keys, audience, issuer, now, }: {
|
|
36
|
+
keys: PublicKey[];
|
|
37
|
+
audience: string;
|
|
38
|
+
issuer?: string;
|
|
39
|
+
now?: number;
|
|
40
|
+
}): Promise<VerifiedToken>;
|
|
41
|
+
/** What an action's endpoint is sent, as JSON (verify its bearer token first). */
|
|
42
|
+
export interface ActionRequest<Input = Record<string, unknown>> {
|
|
43
|
+
action: string;
|
|
44
|
+
input: Input;
|
|
45
|
+
context: TabContext;
|
|
46
|
+
/** The agent asking, for the person in `context` (null: they asked themselves). */
|
|
47
|
+
agent: {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
} | null;
|
|
51
|
+
/** A short-lived key to your app's data for this request; see `dataClient()`. */
|
|
52
|
+
dataKey: string;
|
|
53
|
+
dataUrl: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Your app's data in the workspace, from your server, with the data key an
|
|
57
|
+
* action was given: the same place (its space, tab and person) as the action,
|
|
58
|
+
* for the next few minutes.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* const data = dataClient(request);
|
|
62
|
+
* await data.collection("guestbook").create({ message: request.input.message });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare function dataClient({ dataKey, dataUrl }: Pick<ActionRequest, "dataKey" | "dataUrl">, fetcher?: typeof fetch): {
|
|
66
|
+
data: (request: DataRequest) => Promise<unknown>;
|
|
67
|
+
/**
|
|
68
|
+
* Reports something that happened in your app (M9 slice 5), as your
|
|
69
|
+
* manifest's `events` declare it, for the person and place of the action.
|
|
70
|
+
* One from an agent's action starts no trigger (no loops).
|
|
71
|
+
*/
|
|
72
|
+
emit(name: string, fields?: Record<string, unknown>): Promise<void>;
|
|
73
|
+
values(scope: DataScope): {
|
|
74
|
+
get: <T = unknown>(key: string) => Promise<T | null>;
|
|
75
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
76
|
+
delete: (key: string) => Promise<boolean>;
|
|
77
|
+
list: () => Promise<Record<string, unknown>>;
|
|
78
|
+
};
|
|
79
|
+
collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string): {
|
|
80
|
+
list: (options?: Omit<Extract<DataRequest, {
|
|
81
|
+
op: "records.list";
|
|
82
|
+
}>, "op" | "collection">) => Promise<{
|
|
83
|
+
records: DataRecord<T>[];
|
|
84
|
+
cursor: string | null;
|
|
85
|
+
}>;
|
|
86
|
+
get: (id: string) => Promise<DataRecord<T>>;
|
|
87
|
+
create: (fields: T) => Promise<DataRecord<T>>;
|
|
88
|
+
update: (id: string, fields: Partial<{ [K in keyof T]: T[K] | null; }>) => Promise<DataRecord<T>>;
|
|
89
|
+
delete: (id: string) => Promise<void>;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Reports an event from your server at any time (decision 170), with the event key
|
|
94
|
+
* your tab got from `requestEventKey()`: in that tab's space, for the person who
|
|
95
|
+
* allowed it. Keep the key secret, like a password.
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* await reportEvent(stored, "pull_request_opened", { title: "Fix login", number: 42 });
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export declare function reportEvent({ key, eventsUrl }: EventKey, name: string, fields?: Record<string, unknown>, fetcher?: typeof fetch): Promise<void>;
|
|
102
|
+
/** A refusal from the workspace's data endpoint, with its code. */
|
|
103
|
+
export declare class DataError extends Error {
|
|
104
|
+
readonly code: string | null;
|
|
105
|
+
constructor(message: string, code: string | null);
|
|
106
|
+
}
|
|
107
|
+
export type { EventKey } from "./protocol.js";
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export class TokenError extends Error {
|
|
2
|
+
}
|
|
3
|
+
/** The deployment's public keys (cache them; they change only if its secret does). */
|
|
4
|
+
export async function fetchKeys(deploymentUrl, fetcher = fetch) {
|
|
5
|
+
const response = await fetcher(new URL("/api/apps/keys", deploymentUrl));
|
|
6
|
+
if (!response.ok)
|
|
7
|
+
throw new TokenError(`Couldn't fetch the keys (${response.status}).`);
|
|
8
|
+
const body = (await response.json());
|
|
9
|
+
return body.keys ?? [];
|
|
10
|
+
}
|
|
11
|
+
const decode = (part) => {
|
|
12
|
+
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
13
|
+
const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
|
|
14
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
15
|
+
};
|
|
16
|
+
const json = (part) => {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(new TextDecoder().decode(decode(part)));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new TokenError("That isn't a token.");
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Checks a token: signed by one of `keys`, meant for `audience` (your app's
|
|
26
|
+
* origin), not expired, and (optionally) from `issuer`. Returns who's looking.
|
|
27
|
+
*/
|
|
28
|
+
export async function verifyToken(token, { keys, audience, issuer, now = Date.now(), }) {
|
|
29
|
+
const parts = token.split(".");
|
|
30
|
+
if (parts.length !== 3)
|
|
31
|
+
throw new TokenError("That isn't a token.");
|
|
32
|
+
const [headerPart, payloadPart, signaturePart] = parts;
|
|
33
|
+
const header = json(headerPart);
|
|
34
|
+
if (header.alg !== "EdDSA")
|
|
35
|
+
throw new TokenError("Unexpected signing algorithm.");
|
|
36
|
+
const jwk = keys.find((key) => key.kid === header.kid && key.crv === "Ed25519");
|
|
37
|
+
if (!jwk)
|
|
38
|
+
throw new TokenError("Signed with a key we don't know.");
|
|
39
|
+
const key = await crypto.subtle.importKey("jwk", { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, { name: "Ed25519" }, false, ["verify"]);
|
|
40
|
+
const valid = await crypto.subtle.verify({ name: "Ed25519" }, key, decode(signaturePart), new TextEncoder().encode(`${headerPart}.${payloadPart}`));
|
|
41
|
+
if (!valid)
|
|
42
|
+
throw new TokenError("The signature doesn't match.");
|
|
43
|
+
const payload = json(payloadPart);
|
|
44
|
+
if (payload.aud !== audience)
|
|
45
|
+
throw new TokenError("It's meant for another app.");
|
|
46
|
+
if (issuer !== undefined && payload.iss !== issuer) {
|
|
47
|
+
throw new TokenError("It's from another deployment.");
|
|
48
|
+
}
|
|
49
|
+
if (typeof payload.exp !== "number" || payload.exp * 1000 <= now) {
|
|
50
|
+
throw new TokenError("It has expired.");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
context: payload.ctx,
|
|
54
|
+
issuer: String(payload.iss),
|
|
55
|
+
expiresAt: new Date(payload.exp * 1000),
|
|
56
|
+
action: typeof payload.act === "string" ? payload.act : null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Your app's data in the workspace, from your server, with the data key an
|
|
61
|
+
* action was given: the same place (its space, tab and person) as the action,
|
|
62
|
+
* for the next few minutes.
|
|
63
|
+
*
|
|
64
|
+
* ```ts
|
|
65
|
+
* const data = dataClient(request);
|
|
66
|
+
* await data.collection("guestbook").create({ message: request.input.message });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function dataClient({ dataKey, dataUrl }, fetcher = fetch) {
|
|
70
|
+
async function data(request) {
|
|
71
|
+
const response = await fetcher(dataUrl, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${dataKey}` },
|
|
74
|
+
body: JSON.stringify(request),
|
|
75
|
+
});
|
|
76
|
+
const body = (await response.json().catch(() => null));
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new DataError(body?.error?.message ?? `It answered ${response.status}.`, body?.error?.code ?? null);
|
|
79
|
+
}
|
|
80
|
+
return body?.result;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
data,
|
|
84
|
+
/**
|
|
85
|
+
* Reports something that happened in your app (M9 slice 5), as your
|
|
86
|
+
* manifest's `events` declare it, for the person and place of the action.
|
|
87
|
+
* One from an agent's action starts no trigger (no loops).
|
|
88
|
+
*/
|
|
89
|
+
async emit(name, fields = {}) {
|
|
90
|
+
const response = await fetcher(dataUrl.replace(/\/data$/, "/events"), {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${dataKey}` },
|
|
93
|
+
body: JSON.stringify({ name, data: fields }),
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const body = (await response.json().catch(() => null));
|
|
97
|
+
throw new DataError(body?.error?.message ?? `It answered ${response.status}.`, body?.error?.code ?? null);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
values(scope) {
|
|
101
|
+
return {
|
|
102
|
+
get: async (key) => (await data({ op: "values.get", scope, key })).value,
|
|
103
|
+
set: async (key, value) => {
|
|
104
|
+
await data({ op: "values.set", scope, key, value });
|
|
105
|
+
},
|
|
106
|
+
delete: async (key) => (await data({ op: "values.delete", scope, key })).deleted,
|
|
107
|
+
list: async () => (await data({ op: "values.list", scope }))
|
|
108
|
+
.values,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
collection(name) {
|
|
112
|
+
return {
|
|
113
|
+
list: async (options = {}) => (await data({ op: "records.list", collection: name, ...options })),
|
|
114
|
+
get: async (id) => (await data({ op: "records.get", collection: name, id })).record,
|
|
115
|
+
create: async (fields) => (await data({ op: "records.create", collection: name, data: fields })).record,
|
|
116
|
+
update: async (id, fields) => (await data({ op: "records.update", collection: name, id, data: fields }))
|
|
117
|
+
.record,
|
|
118
|
+
delete: async (id) => {
|
|
119
|
+
await data({ op: "records.delete", collection: name, id });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Reports an event from your server at any time (decision 170), with the event key
|
|
127
|
+
* your tab got from `requestEventKey()`: in that tab's space, for the person who
|
|
128
|
+
* allowed it. Keep the key secret, like a password.
|
|
129
|
+
*
|
|
130
|
+
* ```ts
|
|
131
|
+
* await reportEvent(stored, "pull_request_opened", { title: "Fix login", number: 42 });
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export async function reportEvent({ key, eventsUrl }, name, fields = {}, fetcher = fetch) {
|
|
135
|
+
const response = await fetcher(eventsUrl, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
|
|
138
|
+
body: JSON.stringify({ name, data: fields }),
|
|
139
|
+
});
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const body = (await response.json().catch(() => null));
|
|
142
|
+
throw new DataError(body?.error?.message ?? `It answered ${response.status}.`, body?.error?.code ?? null);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** A refusal from the workspace's data endpoint, with its code. */
|
|
146
|
+
export class DataError extends Error {
|
|
147
|
+
code;
|
|
148
|
+
constructor(message, code) {
|
|
149
|
+
super(message);
|
|
150
|
+
this.code = code;
|
|
151
|
+
}
|
|
152
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tabs-and-spaces/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Building apps for Tabs & Spaces: a Custom tab's page talks to the workspace, its server checks who's looking. No dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Tabs & Spaces",
|
|
7
|
+
"homepage": "https://developer.tabsandspaces.dev/sdk/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/proteusai/workspace.git",
|
|
11
|
+
"directory": "packages/sdk"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"tabs-and-spaces",
|
|
15
|
+
"sdk",
|
|
16
|
+
"apps",
|
|
17
|
+
"custom-tabs"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./server": {
|
|
27
|
+
"types": "./dist/server.d.ts",
|
|
28
|
+
"default": "./dist/server.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"default": "./dist/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./server": {
|
|
44
|
+
"types": "./dist/server.d.ts",
|
|
45
|
+
"default": "./dist/server.js"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"types": "./dist/index.d.ts"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
55
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
56
|
+
"lint": "eslint src",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:coverage": "vitest run --coverage"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@vitest/coverage-v8": "5.0.2",
|
|
62
|
+
"eslint": "^10.11.0",
|
|
63
|
+
"typescript": "~6.0.3",
|
|
64
|
+
"vite": "^8.3.1",
|
|
65
|
+
"vitest": "5.0.2"
|
|
66
|
+
},
|
|
67
|
+
"types": "./dist/index.d.ts"
|
|
6
68
|
}
|