@notionhq/workers 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,26 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
+ import { CREDENTIAL_VALUE } from "./credential.js";
3
4
  import * as Schema from "./schema.js";
4
5
  import { Worker } from "./worker.js";
5
6
 
6
7
  describe("Worker", () => {
7
- it("includes sdkVersion, databases, and capabilities in manifest", () => {
8
+ it("includes sdkVersion, credentials, databases, and capabilities in manifest", () => {
8
9
  const worker = new Worker();
10
+ worker.credential("LINEAR_API_TOKEN", {
11
+ network: [
12
+ {
13
+ domain: "api.linear.com",
14
+ transform: [
15
+ {
16
+ headers: {
17
+ Authorization: `Bearer ${CREDENTIAL_VALUE}`,
18
+ },
19
+ },
20
+ ],
21
+ },
22
+ ],
23
+ });
9
24
  const tasks = worker.database("tasks", {
10
25
  type: "managed",
11
26
  initialTitle: "Tasks",
@@ -26,6 +41,23 @@ describe("Worker", () => {
26
41
  });
27
42
 
28
43
  expect(worker.manifest.sdkVersion).toMatch(/^\d+\.\d+\.\d+/);
44
+ expect(worker.manifest.credentials).toEqual([
45
+ {
46
+ key: "LINEAR_API_TOKEN",
47
+ network: [
48
+ {
49
+ domain: "api.linear.com",
50
+ transform: [
51
+ {
52
+ headers: {
53
+ Authorization: "Bearer {{credential}}",
54
+ },
55
+ },
56
+ ],
57
+ },
58
+ ],
59
+ },
60
+ ]);
29
61
  expect(worker.manifest.databases).toEqual([
30
62
  {
31
63
  key: "tasks",
@@ -141,7 +173,154 @@ describe("Worker", () => {
141
173
  ]);
142
174
  });
143
175
 
144
- it("rejects duplicate keys across databases, pacers, and capabilities", () => {
176
+ it("registers a project custom block from a folder path (type defaults to project)", () => {
177
+ const worker = new Worker();
178
+
179
+ worker.customBlock("visualBlock", {
180
+ path: "./views/visual",
181
+ });
182
+
183
+ expect(worker.manifest.capabilities).toEqual([
184
+ {
185
+ _tag: "custom_block",
186
+ key: "visualBlock",
187
+ config: {
188
+ source: { type: "project", path: "./views/visual" },
189
+ manifest: {
190
+ version: 1,
191
+ dataSources: {},
192
+ },
193
+ },
194
+ },
195
+ ]);
196
+ });
197
+
198
+ it("carries a project's custom build command and output dir", () => {
199
+ const worker = new Worker();
200
+
201
+ worker.customBlock("built", {
202
+ path: "./app",
203
+ command: "npm run build-prod",
204
+ output: "build",
205
+ });
206
+
207
+ expect(worker.manifest.capabilities).toEqual([
208
+ {
209
+ _tag: "custom_block",
210
+ key: "built",
211
+ config: {
212
+ source: {
213
+ type: "project",
214
+ path: "./app",
215
+ command: "npm run build-prod",
216
+ output: "build",
217
+ },
218
+ manifest: { version: 1, dataSources: {} },
219
+ },
220
+ },
221
+ ]);
222
+ });
223
+
224
+ it("registers a static custom block served as-is", () => {
225
+ const worker = new Worker();
226
+
227
+ worker.customBlock("prebuilt", { type: "static", path: "./dist" });
228
+
229
+ expect(worker.manifest.capabilities).toEqual([
230
+ {
231
+ _tag: "custom_block",
232
+ key: "prebuilt",
233
+ config: {
234
+ source: { type: "static", path: "./dist" },
235
+ manifest: { version: 1, dataSources: {} },
236
+ },
237
+ },
238
+ ]);
239
+ });
240
+
241
+ it("carries the author-declared data-source schema verbatim into the manifest", () => {
242
+ const worker = new Worker();
243
+
244
+ worker.customBlock("issueBoard", {
245
+ path: "./views/issueBoard",
246
+ dataSources: {
247
+ issues: {
248
+ name: "Issues",
249
+ description: "The team's issues",
250
+ icon: { type: "emoji", emoji: "🐛" },
251
+ properties: {
252
+ title: { name: "Title", type: "title" },
253
+ status: {
254
+ name: "Status",
255
+ description: "Workflow state",
256
+ type: "status",
257
+ },
258
+ },
259
+ },
260
+ },
261
+ });
262
+
263
+ expect(worker.manifest.capabilities).toEqual([
264
+ {
265
+ _tag: "custom_block",
266
+ key: "issueBoard",
267
+ config: {
268
+ source: { type: "project", path: "./views/issueBoard" },
269
+ manifest: {
270
+ version: 1,
271
+ dataSources: {
272
+ issues: {
273
+ name: "Issues",
274
+ description: "The team's issues",
275
+ icon: { type: "emoji", emoji: "🐛" },
276
+ properties: {
277
+ title: { name: "Title", type: "title" },
278
+ status: {
279
+ name: "Status",
280
+ description: "Workflow state",
281
+ type: "status",
282
+ },
283
+ },
284
+ },
285
+ },
286
+ },
287
+ },
288
+ },
289
+ ]);
290
+ });
291
+
292
+ it("threads a top-level manifest version into the generated manifest", () => {
293
+ const worker = new Worker();
294
+
295
+ worker.customBlock("versioned", {
296
+ path: "./views/versioned",
297
+ version: 1,
298
+ });
299
+
300
+ expect(worker.manifest.capabilities).toEqual([
301
+ {
302
+ _tag: "custom_block",
303
+ key: "versioned",
304
+ config: {
305
+ source: { type: "project", path: "./views/versioned" },
306
+ manifest: { version: 1, dataSources: {} },
307
+ },
308
+ },
309
+ ]);
310
+ });
311
+
312
+ it("cannot run a custom block capability", async () => {
313
+ const worker = new Worker();
314
+ worker.customBlock("visualBlock", {
315
+ path: "./views/visual",
316
+ });
317
+
318
+ await expect(worker.run("visualBlock", {})).rejects.toThrow(
319
+ 'Cannot run custom block capability "visualBlock"',
320
+ );
321
+ });
322
+
323
+ it("rejects duplicate keys across credentials, databases, pacers, and capabilities", () => {
145
324
  const worker = new Worker();
146
325
  const db = worker.database("shared-key", {
147
326
  type: "managed",
@@ -154,6 +333,23 @@ describe("Worker", () => {
154
333
  },
155
334
  });
156
335
 
336
+ expect(() =>
337
+ worker.credential("shared-key", {
338
+ network: [
339
+ {
340
+ domain: "api.example.com",
341
+ transform: [
342
+ {
343
+ headers: {
344
+ Authorization: CREDENTIAL_VALUE,
345
+ },
346
+ },
347
+ ],
348
+ },
349
+ ],
350
+ }),
351
+ ).toThrow('Worker item with key "shared-key" already registered');
352
+
157
353
  expect(() =>
158
354
  worker.sync("shared-key", {
159
355
  database: db,
@@ -164,4 +360,106 @@ describe("Worker", () => {
164
360
  }),
165
361
  ).toThrow('Worker item with key "shared-key" already registered');
166
362
  });
363
+
364
+ it("does not reserve a key when registration fails", () => {
365
+ const worker = new Worker();
366
+
367
+ expect(() =>
368
+ worker.credential("REUSABLE_KEY", {
369
+ network: [],
370
+ }),
371
+ ).toThrow("must declare at least one network rule");
372
+
373
+ expect(() =>
374
+ worker.pacer("REUSABLE_KEY", {
375
+ allowedRequests: 10,
376
+ intervalMs: 1_000,
377
+ }),
378
+ ).not.toThrow();
379
+ });
380
+
381
+ it("rejects credentials that target the same domain and header", () => {
382
+ const worker = new Worker();
383
+ worker.credential("FIRST_TOKEN", {
384
+ network: [
385
+ {
386
+ domain: "api.example.com",
387
+ transform: [{ headers: { Authorization: `Bearer ${CREDENTIAL_VALUE}` } }],
388
+ },
389
+ ],
390
+ });
391
+
392
+ expect(() =>
393
+ worker.credential("SECOND_TOKEN", {
394
+ network: [
395
+ {
396
+ domain: "API.EXAMPLE.COM",
397
+ transform: [{ headers: { authorization: CREDENTIAL_VALUE } }],
398
+ },
399
+ ],
400
+ }),
401
+ ).toThrow('conflicts with credential "FIRST_TOKEN"');
402
+ });
403
+
404
+ it("limits the number of credential declarations", () => {
405
+ const worker = new Worker();
406
+ for (let index = 0; index < 100; index += 1) {
407
+ worker.credential(`TOKEN_${index}`, {
408
+ network: [
409
+ {
410
+ domain: `api${index}.example.com`,
411
+ transform: [{ headers: { Authorization: CREDENTIAL_VALUE } }],
412
+ },
413
+ ],
414
+ });
415
+ }
416
+
417
+ expect(() =>
418
+ worker.credential("ONE_TOO_MANY", {
419
+ network: [
420
+ {
421
+ domain: "overflow.example.com",
422
+ transform: [{ headers: { Authorization: CREDENTIAL_VALUE } }],
423
+ },
424
+ ],
425
+ }),
426
+ ).toThrow("cannot declare more than 100 credentials");
427
+ });
428
+
429
+ it("matches the service limit for the credential declaration payload", () => {
430
+ const worker = new Worker();
431
+ for (let index = 0; index < 32; index += 1) {
432
+ worker.credential(`TOKEN_${index}`, {
433
+ network: [
434
+ {
435
+ domain: `api${index}.example.com`,
436
+ transform: [
437
+ {
438
+ headers: {
439
+ Authorization: `${"x".repeat(8_000)}${CREDENTIAL_VALUE}`,
440
+ },
441
+ },
442
+ ],
443
+ },
444
+ ],
445
+ });
446
+ }
447
+
448
+ expect(() =>
449
+ worker.credential("TOKEN_32", {
450
+ network: [
451
+ {
452
+ domain: "api32.example.com",
453
+ transform: [
454
+ {
455
+ headers: {
456
+ Authorization: `${"x".repeat(8_000)}${CREDENTIAL_VALUE}`,
457
+ },
458
+ },
459
+ ],
460
+ },
461
+ ],
462
+ }),
463
+ ).toThrow("credential declarations must be at most 262144 bytes");
464
+ });
167
465
  });
package/src/worker.ts CHANGED
@@ -11,6 +11,11 @@ import type {
11
11
  } from "./capabilities/automation.js";
12
12
  import { createAutomationCapability } from "./capabilities/automation.js";
13
13
  import type { CapabilityContext } from "./capabilities/context.js";
14
+ import type {
15
+ CustomBlockCapability,
16
+ CustomBlockConfiguration,
17
+ } from "./capabilities/custom-block.js";
18
+ import { createCustomBlockCapability } from "./capabilities/custom-block.js";
14
19
  import type {
15
20
  NotionManagedOAuthConfiguration,
16
21
  OAuthCapability,
@@ -34,6 +39,17 @@ import type {
34
39
  WorkflowEvent,
35
40
  } from "./capabilities/workflow.js";
36
41
  import { createWorkflowCapability } from "./capabilities/workflow.js";
42
+ import type {
43
+ CredentialConfiguration,
44
+ CredentialDeclaration,
45
+ CredentialKey,
46
+ } from "./credential.js";
47
+ import {
48
+ createCredentialDeclaration,
49
+ MAX_CREDENTIAL_DECLARATIONS_BYTES,
50
+ MAX_CREDENTIALS,
51
+ validateCredentialDeclarationConflicts,
52
+ } from "./credential.js";
37
53
  import type { PacerDeclaration } from "./pacer_internal.js";
38
54
  import { pacerWait } from "./pacer_internal.js";
39
55
  import type { Schema } from "./schema.js";
@@ -69,7 +85,8 @@ type Capability =
69
85
  | AutomationCapability
70
86
  | WorkflowCapability
71
87
  | OAuthCapability
72
- | WebhookCapability;
88
+ | WebhookCapability
89
+ | CustomBlockCapability;
73
90
 
74
91
  export type CapabilityType = Capability["_tag"];
75
92
 
@@ -105,6 +122,7 @@ type StoredCapability =
105
122
  | AutomationCapability
106
123
  | StoredWorkflowCapability
107
124
  | OAuthCapability
125
+ | CustomBlockCapability
108
126
  | WebhookCapability;
109
127
 
110
128
  /**
@@ -205,6 +223,7 @@ type CapabilityManifestEntry = Pick<Capability, "_tag" | "key" | "config">;
205
223
  */
206
224
  export type WorkerManifest = {
207
225
  readonly sdkVersion: string;
226
+ readonly credentials: readonly CredentialDeclaration[];
208
227
  readonly databases: readonly RegisteredDatabase[];
209
228
  readonly pacers: readonly PacerDeclaration[];
210
229
  readonly capabilities: readonly CapabilityManifestEntry[];
@@ -236,9 +255,33 @@ const SDK_VERSION = (() => {
236
255
 
237
256
  export class Worker {
238
257
  #capabilities: Map<string, StoredCapability> = new Map();
258
+ #credentials: Map<string, CredentialDeclaration> = new Map();
239
259
  #databases: Map<string, RegisteredDatabase> = new Map();
240
260
  #pacers: Map<string, PacerDeclaration> = new Map();
241
261
 
262
+ /**
263
+ * Declare a credential that is injected into matching outbound HTTPS
264
+ * requests without exposing its value to Worker code.
265
+ */
266
+ credential(key: CredentialKey, config: CredentialConfiguration): void {
267
+ this.#validateUniqueKey(key);
268
+ if (this.#credentials.size >= MAX_CREDENTIALS) {
269
+ throw new Error(`Worker cannot declare more than ${MAX_CREDENTIALS} credentials`);
270
+ }
271
+ const declaration = createCredentialDeclaration(key, config);
272
+ validateCredentialDeclarationConflicts(this.#credentials.values(), declaration);
273
+ const declarations = [...this.#credentials.values(), declaration];
274
+ if (
275
+ new TextEncoder().encode(JSON.stringify(declarations)).byteLength >
276
+ MAX_CREDENTIAL_DECLARATIONS_BYTES
277
+ ) {
278
+ throw new Error(
279
+ `Worker credential declarations must be at most ${MAX_CREDENTIAL_DECLARATIONS_BYTES} bytes`,
280
+ );
281
+ }
282
+ this.#credentials.set(key, declaration);
283
+ }
284
+
242
285
  database<PK extends string, S extends Schema<PK>>(
243
286
  key: string,
244
287
  config: DatabaseConfig<PK, S>,
@@ -609,6 +652,51 @@ export class Worker {
609
652
  return capability;
610
653
  }
611
654
 
655
+ /**
656
+ * Register a custom block capability.
657
+ *
658
+ * A custom block is a front-end view that the deploy pipeline builds from
659
+ * the source entry into a deployable bundle. It can optionally declare a
660
+ * data-source *schema* (`name`, and optional `description`, `icon`, and
661
+ * `properties`) that the block reads.
662
+ *
663
+ * Example:
664
+ *
665
+ * ```ts
666
+ * const worker = new Worker();
667
+ * export default worker;
668
+ *
669
+ * worker.customBlock("issueBoard", {
670
+ * version: 1,
671
+ * path: "./views/issueBoard",
672
+ * dataSources: {
673
+ * issues: {
674
+ * name: "Issues",
675
+ * properties: {
676
+ * title: { name: "Title", type: "title" },
677
+ * status: { name: "Status", type: "status" },
678
+ * },
679
+ * },
680
+ * },
681
+ * });
682
+ * ```
683
+ *
684
+ * The manifest keys (`version`, `dataSources`) are top-level on the config;
685
+ * the SDK assembles the deploy-wire `CustomBlockManifest` from them. Binding
686
+ * each declared data source to a concrete managed database is instance-level
687
+ * and handled at deploy time — the worker does not emit bindings.
688
+ *
689
+ * @param key - The unique key for this block (the block declaration key).
690
+ * @param config - The custom block configuration (a `project`/`static` source folder + optional `version`/`dataSources` schema).
691
+ * @returns The custom block capability.
692
+ */
693
+ customBlock(key: string, config: CustomBlockConfiguration): CustomBlockCapability {
694
+ this.#validateUniqueKey(key);
695
+ const capability = createCustomBlockCapability(key, config);
696
+ this.#capabilities.set(key, capability);
697
+ return capability;
698
+ }
699
+
612
700
  /**
613
701
  * Get all registered capabilities (for discovery) without their handlers.
614
702
  */
@@ -623,6 +711,7 @@ export class Worker {
623
711
  get manifest(): WorkerManifest {
624
712
  return {
625
713
  sdkVersion: SDK_VERSION,
714
+ credentials: Array.from(this.#credentials.values()),
626
715
  databases: Array.from(this.#databases.values()),
627
716
  pacers: Array.from(this.#pacers.values()),
628
717
  capabilities: this.capabilities,
@@ -650,6 +739,12 @@ export class Worker {
650
739
  );
651
740
  }
652
741
 
742
+ if (capability._tag === "custom_block") {
743
+ throw new Error(
744
+ `Cannot run custom block capability "${key}" - custom blocks only provide configuration`,
745
+ );
746
+ }
747
+
653
748
  if (!capability.handler) {
654
749
  throw new Error(`Capability "${key}" cannot be executed`);
655
750
  }
@@ -665,7 +760,12 @@ export class Worker {
665
760
  if (!key || typeof key !== "string") {
666
761
  throw new Error("Capability key must be a non-empty string");
667
762
  }
668
- if (this.#capabilities.has(key) || this.#databases.has(key) || this.#pacers.has(key)) {
763
+ if (
764
+ this.#capabilities.has(key) ||
765
+ this.#credentials.has(key) ||
766
+ this.#databases.has(key) ||
767
+ this.#pacers.has(key)
768
+ ) {
669
769
  throw new Error(`Worker item with key "${key}" already registered`);
670
770
  }
671
771
  }