@notionhq/apps 0.0.13 → 0.0.15

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.
Files changed (111) hide show
  1. package/README.md +110 -49
  2. package/dist/box.generated.d.ts +164 -0
  3. package/dist/box.generated.d.ts.map +1 -0
  4. package/dist/box.generated.js +24 -0
  5. package/dist/calendar.generated.d.ts +1036 -0
  6. package/dist/calendar.generated.d.ts.map +1 -0
  7. package/dist/calendar.generated.js +42 -0
  8. package/dist/cli/build.js +7 -6
  9. package/dist/cli/emit-manifest.d.ts.map +1 -1
  10. package/dist/cli/emit-manifest.js +1 -4
  11. package/dist/confluence.generated.d.ts +98 -0
  12. package/dist/confluence.generated.d.ts.map +1 -0
  13. package/dist/confluence.generated.js +18 -0
  14. package/dist/connection-actions.d.ts +2 -0
  15. package/dist/connection-actions.d.ts.map +1 -0
  16. package/dist/connection-actions.js +0 -0
  17. package/dist/connection-trigger-definitions.generated.d.ts +34 -0
  18. package/dist/connection-trigger-definitions.generated.d.ts.map +1 -0
  19. package/dist/connection-trigger-definitions.generated.js +45 -0
  20. package/dist/connections.d.ts +15 -0
  21. package/dist/connections.d.ts.map +1 -0
  22. package/dist/connections.js +79 -0
  23. package/dist/connections.test.d.ts +2 -0
  24. package/dist/connections.test.d.ts.map +1 -0
  25. package/dist/context.d.ts.map +1 -1
  26. package/dist/context.js +3 -0
  27. package/dist/context.test.d.ts +2 -0
  28. package/dist/context.test.d.ts.map +1 -0
  29. package/dist/cursor.generated.d.ts +154 -0
  30. package/dist/cursor.generated.d.ts.map +1 -0
  31. package/dist/cursor.generated.js +18 -0
  32. package/dist/custom-block.d.ts +1 -1
  33. package/dist/custom-block.d.ts.map +1 -1
  34. package/dist/custom-block.js +3 -3
  35. package/dist/discord.generated.d.ts +279 -0
  36. package/dist/discord.generated.d.ts.map +1 -0
  37. package/dist/discord.generated.js +33 -0
  38. package/dist/gmail.generated.d.ts +184 -0
  39. package/dist/gmail.generated.d.ts.map +1 -0
  40. package/dist/gmail.generated.js +21 -0
  41. package/dist/googleCalendar.generated.d.ts +83 -0
  42. package/dist/googleCalendar.generated.d.ts.map +1 -0
  43. package/dist/googleCalendar.generated.js +15 -0
  44. package/dist/googleDrive.generated.d.ts +210 -0
  45. package/dist/googleDrive.generated.d.ts.map +1 -0
  46. package/dist/googleDrive.generated.js +27 -0
  47. package/dist/googleDriveOauth.generated.d.ts +851 -0
  48. package/dist/googleDriveOauth.generated.d.ts.map +1 -0
  49. package/dist/googleDriveOauth.generated.js +84 -0
  50. package/dist/index.d.ts +8 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +16 -0
  53. package/dist/outlook.generated.d.ts +224 -0
  54. package/dist/outlook.generated.d.ts.map +1 -0
  55. package/dist/outlook.generated.js +24 -0
  56. package/dist/providers.generated.d.ts +117 -0
  57. package/dist/providers.generated.d.ts.map +1 -0
  58. package/dist/providers.generated.js +108 -0
  59. package/dist/salesforce.generated.d.ts +151 -0
  60. package/dist/salesforce.generated.d.ts.map +1 -0
  61. package/dist/salesforce.generated.js +24 -0
  62. package/dist/slack.generated.d.ts +290 -0
  63. package/dist/slack.generated.d.ts.map +1 -0
  64. package/dist/slack.generated.js +30 -0
  65. package/dist/sync.d.ts +2 -2
  66. package/dist/sync.d.ts.map +1 -1
  67. package/dist/sync.js +3 -3
  68. package/dist/triggers.generated.d.ts +107 -32
  69. package/dist/triggers.generated.d.ts.map +1 -1
  70. package/dist/triggers.generated.js +36 -24
  71. package/dist/workflow-connections-types.test.d.ts +2 -0
  72. package/dist/workflow-connections-types.test.d.ts.map +1 -0
  73. package/dist/workflow-types.test.d.ts +2 -0
  74. package/dist/workflow-types.test.d.ts.map +1 -0
  75. package/dist/workflow.d.ts +24 -20
  76. package/dist/workflow.d.ts.map +1 -1
  77. package/dist/workflow.js +16 -19
  78. package/docs/BUILD.md +83 -14
  79. package/docs/CONNECTIONS.md +130 -0
  80. package/package.json +5 -1
  81. package/src/box.generated.ts +187 -0
  82. package/src/calendar.generated.ts +1117 -0
  83. package/src/cli/build.test.ts +35 -4
  84. package/src/cli/build.ts +6 -5
  85. package/src/cli/emit-manifest.ts +1 -5
  86. package/src/confluence.generated.ts +109 -0
  87. package/src/connection-actions.ts +2 -0
  88. package/src/connection-trigger-definitions.generated.ts +48 -0
  89. package/src/connections.test.ts +157 -0
  90. package/src/connections.ts +126 -0
  91. package/src/context.test.ts +26 -0
  92. package/src/context.ts +8 -0
  93. package/src/cursor.generated.ts +167 -0
  94. package/src/custom-block.test.ts +4 -4
  95. package/src/custom-block.ts +2 -2
  96. package/src/discord.generated.ts +318 -0
  97. package/src/gmail.generated.ts +205 -0
  98. package/src/googleCalendar.generated.ts +95 -0
  99. package/src/googleDrive.generated.ts +235 -0
  100. package/src/googleDriveOauth.generated.ts +980 -0
  101. package/src/index.ts +7 -0
  102. package/src/outlook.generated.ts +249 -0
  103. package/src/providers.generated.ts +207 -0
  104. package/src/salesforce.generated.ts +170 -0
  105. package/src/slack.generated.ts +330 -0
  106. package/src/sync.ts +2 -2
  107. package/src/triggers.generated.ts +112 -32
  108. package/src/workflow-connections-types.test.ts +59 -0
  109. package/src/workflow-types.test.ts +144 -0
  110. package/src/workflow.test.ts +135 -25
  111. package/src/workflow.ts +63 -34
@@ -54,9 +54,7 @@ describe("buildApp", () => {
54
54
  },
55
55
  ]);
56
56
  const worker = await fs.promises.readFile(bundlePath, "utf8");
57
- expect(worker).not.toMatch(
58
- /custom_block|createCustomBlock|react|createRoot|NotionCustomBlock/,
59
- );
57
+ expect(worker).not.toMatch(/custom_block|customBlock|react|createRoot|NotionCustomBlock/);
60
58
  expect(fs.existsSync(path.join(root, ".notion/blocks.js"))).toBe(false);
61
59
  });
62
60
  it("names the block file when path is missing", async () => {
@@ -66,7 +64,7 @@ describe("buildApp", () => {
66
64
  });
67
65
  it("rejects a block file that exports a different capability", async () => {
68
66
  await expect(buildApp(fixture("custom-block-wrong-tag"))).rejects.toThrow(
69
- /hello.ts.*createCustomBlock/,
67
+ /hello.ts.*customBlock/,
70
68
  );
71
69
  });
72
70
 
@@ -216,6 +214,39 @@ describe("buildApp", () => {
216
214
  });
217
215
  });
218
216
 
217
+ it("removes provisioning when a rebuild no longer declares Notion resources", async () => {
218
+ const root = await fs.promises.mkdtemp(path.join(FIXTURES, "provisioning-rebuild-"));
219
+ try {
220
+ await fs.promises.cp(
221
+ path.join(FIXTURES, "notion-as-code", "src"),
222
+ path.join(root, "src"),
223
+ {
224
+ recursive: true,
225
+ },
226
+ );
227
+ await buildApp(root);
228
+ const provisioningPath = path.join(root, "dist", "provisioning.json");
229
+ const provisioning = JSON.parse(await fs.promises.readFile(provisioningPath, "utf8"));
230
+ expect(provisioning.intents).toEqual(
231
+ expect.arrayContaining([expect.objectContaining({ resourceId: "issues-db" })]),
232
+ );
233
+
234
+ await fs.promises.rm(path.join(root, "src"), { recursive: true });
235
+ await fs.promises.cp(path.join(EXAMPLES, "workflow", "src"), path.join(root, "src"), {
236
+ recursive: true,
237
+ });
238
+ const { manifest } = await buildApp(root);
239
+ expect(manifest.capabilities).toEqual([
240
+ expect.objectContaining({ type: "workflow", key: "onPageCreated" }),
241
+ ]);
242
+ await expect(fs.promises.readFile(provisioningPath)).rejects.toMatchObject({
243
+ code: "ENOENT",
244
+ });
245
+ } finally {
246
+ await fs.promises.rm(root, { recursive: true, force: true });
247
+ }
248
+ });
249
+
219
250
  it("builds a workflow bundle and manifest", async () => {
220
251
  const { manifest, bundlePath, manifestPath } = await buildApp(example("workflow"));
221
252
 
package/src/cli/build.ts CHANGED
@@ -72,13 +72,14 @@ async function emitProvisioningArtifact(
72
72
  capabilities: readonly DiscoveredCapability[],
73
73
  ): Promise<void> {
74
74
  const entityProvisioning = await buildMetadata(projectRoot, capabilities);
75
- if (entityProvisioning.intents.length === 0) return;
75
+ const provisioningPath = path.join(projectRoot, "dist", "provisioning.json");
76
+ if (entityProvisioning.intents.length === 0) {
77
+ await fs.promises.rm(provisioningPath, { force: true });
78
+ return;
79
+ }
76
80
 
77
81
  const artifact = createProvisioningArtifact(entityProvisioning.intents);
78
- await fs.promises.writeFile(
79
- path.join(projectRoot, "dist", "provisioning.json"),
80
- `${JSON.stringify(artifact, null, "\t")}\n`,
81
- );
82
+ await fs.promises.writeFile(provisioningPath, `${JSON.stringify(artifact, null, "\t")}\n`);
82
83
  }
83
84
 
84
85
  /** Bundle capabilities separately to record their Notion-as-Code declarations without shipping them. */
@@ -233,7 +233,7 @@ export async function extractManifest(
233
233
  if (!tagged || tagged._tag !== capability.tag) {
234
234
  throw new Error(
235
235
  `${capability.sourcePath}: default export is not a ${capability.type} — ` +
236
- `files under this directory must default-export create${capability.type === "custom_block" ? "CustomBlock" : capitalize(capability.type)}(...)`,
236
+ `files under this directory must default-export ${capability.type === "custom_block" ? "customBlock" : capability.type}(...)`,
237
237
  );
238
238
  }
239
239
 
@@ -342,10 +342,6 @@ function getCapabilityRegistry(
342
342
  return bundle.capabilities as Record<string, Record<string, unknown> | undefined>;
343
343
  }
344
344
 
345
- function capitalize(value: string): string {
346
- return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
347
- }
348
-
349
345
  /**
350
346
  * Throw if `value` contains anything `JSON.stringify` would silently drop or
351
347
  * rewrite, so invalid manifest configuration fails during the build.
@@ -0,0 +1,109 @@
1
+ // Generated by notion tool-core codegen-script-types --connections. Do not edit.
2
+ import type { ConnectionActionInvoker } from "./connection-actions.js"
3
+ /**
4
+ * Script input for the "Query Confluence with CQL" effect (`confluence.cqlQuery`),
5
+ * derived from its Tool Core definition's call_function input projection.
6
+ *
7
+ * Run a CQL query and return results.
8
+ */
9
+ export type CqlQueryScriptInput = {
10
+ /**
11
+ * CQL query string. Only read-only queries are supported. Example: type=page AND space=DEV AND text~"deployment"
12
+ */
13
+ query: string
14
+ /**
15
+ * Maximum number of results to return. Defaults to 20.
16
+ */
17
+ maxResults?: number
18
+ }
19
+
20
+ /**
21
+ * Canonical Tool Core output for the "Query Confluence with CQL" effect (`confluence.cqlQuery`).
22
+ * This is the definition's output schema; the effect's script surface may
23
+ * re-encode it before returning.
24
+ */
25
+ export type CqlQueryScriptOutput = {
26
+ results: Array<{
27
+ id: string
28
+ title: string
29
+ blocks: Array<string>
30
+ }>
31
+ query: string
32
+ baseUrl: string
33
+ }
34
+
35
+ /**
36
+ * Script input for the "Load Confluence page" effect (`confluence.loadPage`),
37
+ * derived from its Tool Core definition's call_function input projection.
38
+ *
39
+ * Load a Confluence page by ID.
40
+ */
41
+ export type LoadPageScriptInput = {
42
+ /**
43
+ * The Confluence page ID to load.
44
+ */
45
+ pageId: string
46
+ }
47
+
48
+ /**
49
+ * Canonical Tool Core output for the "Load Confluence page" effect (`confluence.loadPage`).
50
+ * This is the definition's output schema; the effect's script surface may
51
+ * re-encode it before returning.
52
+ */
53
+ export type LoadPageScriptOutput = {
54
+ type: "confluence-page"
55
+ title: string
56
+ blocks: Array<string>
57
+ pageId: string
58
+ }
59
+
60
+ /**
61
+ * Script input for the "Search Confluence" effect (`confluence.search`),
62
+ * derived from its Tool Core definition's call_function input projection.
63
+ *
64
+ * Search Confluence via the configured connector.
65
+ */
66
+ export type SearchScriptInput = {
67
+ /**
68
+ * A single, focused question to search for.
69
+ */
70
+ question: string
71
+ /**
72
+ * Brief keywords capturing the core entities.
73
+ */
74
+ keywords?: string
75
+ /**
76
+ * Optional time window. Use only "default", "all_time", a duration matching /^\d+[dwmy]$/ (for example, "7d", "2w", "3m", or "1y"), or a date in "YYYY-MM-DD" format. Do not use natural-language durations or combine units.
77
+ */
78
+ lookback?: string
79
+ }
80
+
81
+ /**
82
+ * Canonical Tool Core output for the "Search Confluence" effect (`confluence.search`).
83
+ * This is the definition's output schema; the effect's script surface may
84
+ * re-encode it before returning.
85
+ */
86
+ export type SearchScriptOutput = {
87
+ results: Array<{
88
+ id: string
89
+ title: string
90
+ path: string
91
+ text: string
92
+ lastEdited: string
93
+ isPrivate: boolean
94
+ pageId: string
95
+ }>
96
+ }
97
+
98
+ export class ConfluenceConnectionClient {
99
+ constructor(private readonly invoke: ConnectionActionInvoker) {}
100
+ cqlQuery(args: CqlQueryScriptInput): Promise<CqlQueryScriptOutput> {
101
+ return this.invoke<CqlQueryScriptOutput>("cqlQuery", args)
102
+ }
103
+ loadPage(args: LoadPageScriptInput): Promise<LoadPageScriptOutput> {
104
+ return this.invoke<LoadPageScriptOutput>("loadPage", args)
105
+ }
106
+ search(args: SearchScriptInput): Promise<SearchScriptOutput> {
107
+ return this.invoke<SearchScriptOutput>("search", args)
108
+ }
109
+ }
@@ -0,0 +1,2 @@
1
+ /** @internal Transport used by generated Tool Core provider clients. */
2
+ export type ConnectionActionInvoker = <Result>(action: string, args: object) => Promise<Result>;
@@ -0,0 +1,48 @@
1
+ // Generated by notion tool-core codegen-script-types --connections. Do not edit.
2
+ export const connectionTriggerDefinitions = [
3
+ {
4
+ type: "calendar.event.created",
5
+ provider: "calendar",
6
+ description: "Triggers immediately when a calendar event is created",
7
+ },
8
+ {
9
+ type: "calendar.event.updated",
10
+ provider: "calendar",
11
+ description: "Triggers immediately when a calendar event is updated",
12
+ },
13
+ {
14
+ type: "calendar.event.canceled",
15
+ provider: "calendar",
16
+ description: "Triggers immediately when a calendar event is canceled",
17
+ },
18
+ {
19
+ type: "slack.message",
20
+ provider: "slack",
21
+ description:
22
+ "Triggers immediately after a new message is posted in Slack (includes new threads and replies)",
23
+ },
24
+ {
25
+ type: "slack.reaction.added",
26
+ provider: "slack",
27
+ description:
28
+ "Triggers immediately after a reaction is added to a message in Slack",
29
+ },
30
+ {
31
+ type: "slack.app.mention",
32
+ provider: "slack",
33
+ description:
34
+ "Triggers when the agent is mentioned in a Slack channel or direct message",
35
+ },
36
+ {
37
+ type: "googleDriveOauth.filesChanged",
38
+ provider: "googleDriveOauth",
39
+ description:
40
+ "Watches for file changes in a specific Google Drive folder (direct children only) or shared drive. The My Drive root is not supported. Fires on a schedule and provides change tokens for fetching the list of changes.",
41
+ },
42
+ {
43
+ type: "discord.interaction",
44
+ provider: "discord",
45
+ description:
46
+ "Triggers when a user invokes the /ai slash command in a configured Discord channel",
47
+ },
48
+ ] as const
@@ -0,0 +1,157 @@
1
+ import { Client } from "@notionhq/client";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ connections,
5
+ createWorkflowConnections,
6
+ validateConnectionRequirements,
7
+ } from "./connections.js";
8
+
9
+ afterEach(() => {
10
+ vi.unstubAllEnvs();
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ describe("connection requirements", () => {
15
+ it("declares generated providers", () => {
16
+ expect(connections.calendar()).toEqual({ type: "calendar" });
17
+ expect(connections.box({ key: "documents" })).toEqual({ type: "box", key: "documents" });
18
+ expect(connections.googleDriveOauth()).toEqual({ type: "googleDriveOauth" });
19
+ expect(connections.discord({ key: "community" })).toEqual({
20
+ type: "discord",
21
+ key: "community",
22
+ });
23
+ });
24
+
25
+ it("rejects duplicate keys across providers", () => {
26
+ expect(() =>
27
+ validateConnectionRequirements([
28
+ connections.calendar({ key: "work" }),
29
+ connections.slack({ key: "work" }),
30
+ ]),
31
+ ).toThrow("Duplicate");
32
+ });
33
+ it("rejects invalid keys and excessive requirements", () => {
34
+ expect(() =>
35
+ validateConnectionRequirements([connections.slack({ key: "bad/key" })]),
36
+ ).toThrow("Invalid");
37
+ expect(() =>
38
+ validateConnectionRequirements(
39
+ Array.from({ length: 101 }, (_, i) => connections.slack({ key: `team${i}` })),
40
+ ),
41
+ ).toThrow("100");
42
+ });
43
+ });
44
+
45
+ describe("workflow connection runtime", () => {
46
+ it("preserves pagination through a generated OAuth provider method", async () => {
47
+ vi.stubEnv(
48
+ "NOTION_CONNECTION_BINDINGS",
49
+ '[{"key":"documents","type":"googleDriveOauth","id":"drive-id"}]',
50
+ );
51
+ const notion = new Client({ auth: "test" });
52
+ const request = vi
53
+ .spyOn(notion, "request")
54
+ .mockResolvedValue({ result: { files: [], nextPageToken: "next" } });
55
+ const result = await createWorkflowConnections(notion)
56
+ .googleDriveOauth("documents")
57
+ .listFiles({ folderId: "folder", pageToken: "previous" });
58
+ expect(result.nextPageToken).toBe("next");
59
+ expect(request).toHaveBeenCalledWith({
60
+ path: "tools/run/internal",
61
+ method: "post",
62
+ body: {
63
+ connection: "drive-id",
64
+ action: "listFiles",
65
+ args: { folderId: "folder", pageToken: "previous" },
66
+ },
67
+ });
68
+ });
69
+ it("uses the configured connector binding without choosing a user in the SDK", async () => {
70
+ vi.stubEnv(
71
+ "NOTION_CONNECTION_BINDINGS",
72
+ '[{"key":"documents","type":"box","id":"box-id"}]',
73
+ );
74
+ const notion = new Client({ auth: "test" });
75
+ const request = vi.spyOn(notion, "request").mockResolvedValue({ result: [] });
76
+ await createWorkflowConnections(notion)
77
+ .box("documents")
78
+ .search({ question: "Quarterly report" });
79
+ expect(request).toHaveBeenCalledWith({
80
+ path: "tools/run/internal",
81
+ method: "post",
82
+ body: {
83
+ connection: "box-id",
84
+ action: "search",
85
+ args: { question: "Quarterly report" },
86
+ },
87
+ });
88
+ });
89
+
90
+ it("rejects a mismatched provider before sending requests", () => {
91
+ vi.stubEnv("NOTION_CONNECTION_BINDINGS", '[{"key":"support","type":"calendar","id":"a"}]');
92
+ const notion = new Client({ auth: "test" });
93
+ const request = vi.spyOn(notion, "request");
94
+ expect(() => createWorkflowConnections(notion).slack("support")).toThrow("not slack");
95
+ expect(request).not.toHaveBeenCalled();
96
+ });
97
+ it("invokes a typed Calendar method with the default binding", async () => {
98
+ vi.stubEnv(
99
+ "NOTION_CONNECTION_BINDINGS",
100
+ '[{"key":"calendar","type":"calendar","id":"calendar-id"}]',
101
+ );
102
+ const notion = new Client({ auth: "test" });
103
+ const request = vi.spyOn(notion, "request").mockResolvedValue({ result: { accounts: [] } });
104
+ const result = await createWorkflowConnections(notion).calendar().listCalendars({});
105
+ expect(result.accounts).toEqual([]);
106
+ expect(request).toHaveBeenCalledWith({
107
+ path: "tools/run/internal",
108
+ method: "post",
109
+ body: { connection: "calendar-id", action: "listCalendars", args: {} },
110
+ });
111
+ });
112
+
113
+ it("invokes the exact named binding and unwraps its result", async () => {
114
+ vi.stubEnv(
115
+ "NOTION_CONNECTION_BINDINGS",
116
+ JSON.stringify([{ key: "support", type: "slack", id: "configured-id" }]),
117
+ );
118
+ const notion = new Client({ auth: "test" });
119
+ const request = vi.spyOn(notion, "request").mockResolvedValue({ result: { ok: true } });
120
+ const runtime = createWorkflowConnections(notion);
121
+ await expect(
122
+ runtime.slack("support").findUserByEmail({ email: "person@example.com" }),
123
+ ).resolves.toEqual({
124
+ ok: true,
125
+ });
126
+ expect(request).toHaveBeenCalledWith({
127
+ path: "tools/run/internal",
128
+ method: "post",
129
+ body: {
130
+ connection: "configured-id",
131
+ action: "findUserByEmail",
132
+ args: { email: "person@example.com" },
133
+ },
134
+ });
135
+ expect(() => runtime.slack("slack")).toThrow("not bound");
136
+ expect(request).toHaveBeenCalledTimes(1);
137
+ });
138
+ it.each([
139
+ "{}",
140
+ '[{"key":"support","type":"slack","id":1}]',
141
+ '[{"key":"support","type":"slack","id":"a"},{"key":"support","type":"slack","id":"b"}]',
142
+ ])("rejects malformed or ambiguous bindings: %s", (metadata) => {
143
+ vi.stubEnv("NOTION_CONNECTION_BINDINGS", metadata);
144
+ expect(() => createWorkflowConnections(new Client()).slack("support")).toThrow();
145
+ });
146
+ it("propagates permission failures without retrying an alternate connection", async () => {
147
+ vi.stubEnv("NOTION_CONNECTION_BINDINGS", '[{"key":"support","type":"slack","id":"a"}]');
148
+ const notion = new Client({ auth: "test" });
149
+ const request = vi.spyOn(notion, "request").mockRejectedValue(new Error("forbidden"));
150
+ await expect(
151
+ createWorkflowConnections(notion)
152
+ .slack("support")
153
+ .findUserByEmail({ email: "person@example.com" }),
154
+ ).rejects.toThrow("forbidden");
155
+ expect(request).toHaveBeenCalledTimes(1);
156
+ });
157
+ });
@@ -0,0 +1,126 @@
1
+ import { connectionTriggerDefinitions } from "./connection-trigger-definitions.generated.js";
2
+ import type { WorkflowTrigger } from "./triggers.generated.js";
3
+ import type { Client } from "@notionhq/client";
4
+ import { createConnectionProviders, type ConnectionProviders } from "./providers.generated.js";
5
+ import type { ConnectionActionInvoker } from "./connection-actions.js";
6
+
7
+ /** A setup requirement. The server decides which providers are available. */
8
+ export type WorkflowConnection<TProvider extends string = string, TKey extends string = string> = {
9
+ type: TProvider;
10
+ key?: TKey;
11
+ };
12
+
13
+ /** Keys declared for one provider in a workflow. */
14
+ export type WorkflowConnectionKeys<
15
+ TConnections extends readonly WorkflowConnection[],
16
+ TProvider extends string,
17
+ > = WorkflowConnectionKey<Extract<TConnections[number], { type: TProvider }>>;
18
+
19
+ type WorkflowConnectionKey<TConnection extends WorkflowConnection> =
20
+ TConnection extends WorkflowConnection
21
+ ? "key" extends keyof TConnection
22
+ ? Exclude<TConnection["key"], undefined>
23
+ : TConnection["type"]
24
+ : never;
25
+ type ConnectionBinding = { key: string; type: string; id: string };
26
+
27
+ /** Requirements are declarations, not credentials or permission grants. */
28
+ export { connections } from "./providers.generated.js";
29
+
30
+ export type WorkflowConnections<
31
+ TConnections extends readonly WorkflowConnection[] = readonly WorkflowConnection[],
32
+ > = Pick<ConnectionProviders, Extract<keyof ConnectionProviders, TConnections[number]["type"]>>;
33
+
34
+ /** @internal */
35
+ export function validateConnectionRequirements(requirements: readonly WorkflowConnection[]): void {
36
+ if (requirements.length > 100) throw new Error("Workflows support at most 100 connections.");
37
+ const keys = new Set<string>();
38
+ for (const requirement of requirements) {
39
+ if (!requirement.type) throw new Error("A workflow connection needs a provider type.");
40
+ const key = requirement.key ?? requirement.type;
41
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/.test(key)) {
42
+ throw new Error("Invalid workflow connection key: " + key);
43
+ }
44
+ if (keys.has(key)) throw new Error("Duplicate workflow connection: " + key);
45
+ keys.add(key);
46
+ }
47
+ }
48
+
49
+ /** @internal */
50
+ export function validateTriggerConnections(
51
+ triggers: readonly WorkflowTrigger[],
52
+ requirements: readonly WorkflowConnection[],
53
+ ): void {
54
+ const seen = new Set<string>();
55
+ for (const trigger of triggers) {
56
+ if (!("connectionKey" in trigger) || trigger.connectionKey === undefined) continue;
57
+ const requirement = requirements.find(
58
+ (connection) => (connection.key ?? connection.type) === trigger.connectionKey,
59
+ );
60
+ const definition = connectionTriggerDefinitions.find(
61
+ (definition) => definition.type === trigger.type,
62
+ );
63
+ if (!requirement || requirement.type !== definition?.provider) {
64
+ throw new Error(
65
+ `Trigger "${trigger.type}" requires a declared ${definition?.provider ?? "supported"} connection: ${trigger.connectionKey}`,
66
+ );
67
+ }
68
+ const identity = JSON.stringify([trigger.type, trigger.connectionKey]);
69
+ if (seen.has(identity))
70
+ throw new Error(
71
+ `Duplicate connection trigger: ${trigger.type} (${trigger.connectionKey})`,
72
+ );
73
+ seen.add(identity);
74
+ }
75
+ }
76
+
77
+ /** @internal */
78
+ export function createWorkflowConnections(notion: Client): WorkflowConnections {
79
+ // Capture the execution's bindings; defer parsing so non-connection workflows
80
+ // remain usable without runtime connection metadata.
81
+ const rawBindings = process.env.NOTION_CONNECTION_BINDINGS;
82
+ function resolveBinding(key: string): ConnectionBinding {
83
+ const bindings: unknown = JSON.parse(rawBindings ?? "[]");
84
+ if (!Array.isArray(bindings) || !bindings.every(isConnectionBinding)) {
85
+ throw new Error("Invalid NOTION_CONNECTION_BINDINGS metadata.");
86
+ }
87
+ const matches = bindings.filter((binding: ConnectionBinding) => binding.key === key);
88
+ if (matches.length !== 1 || !matches[0]) {
89
+ throw new Error(
90
+ `Workflow connection "${key}" is not bound. Deploy and configure this requirement before running it.`,
91
+ );
92
+ }
93
+ return { ...matches[0] };
94
+ }
95
+ function invoker(provider: string, key: string): ConnectionActionInvoker {
96
+ const binding = resolveBinding(key);
97
+ if (binding.type !== provider) {
98
+ throw new Error(`Workflow connection "${key}" is ${binding.type}, not ${provider}.`);
99
+ }
100
+ return async <Result>(action: string, args: object): Promise<Result> => {
101
+ const response = await notion.request<{ result: Result }>({
102
+ path: "tools/run/internal",
103
+ method: "post",
104
+ body: { connection: binding.id, action, args },
105
+ });
106
+ return response.result;
107
+ };
108
+ }
109
+ return createConnectionProviders(invoker);
110
+ }
111
+
112
+ function isConnectionBinding(value: unknown): value is ConnectionBinding {
113
+ return (
114
+ typeof value === "object" &&
115
+ value !== null &&
116
+ "key" in value &&
117
+ typeof value.key === "string" &&
118
+ value.key.length > 0 &&
119
+ "type" in value &&
120
+ typeof value.type === "string" &&
121
+ value.type.length > 0 &&
122
+ "id" in value &&
123
+ typeof value.id === "string" &&
124
+ value.id.length > 0
125
+ );
126
+ }
@@ -0,0 +1,26 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createCapabilityContext } from "./context.js";
3
+
4
+ afterEach(() => {
5
+ delete process.env.NOTION_API_TOKEN;
6
+ delete process.env.NOTION_WORKFLOW_STEP_DIRECTORY;
7
+ });
8
+
9
+ describe("createCapabilityContext", () => {
10
+ it("explains how to configure local Notion API access", () => {
11
+ const context = createCapabilityContext();
12
+
13
+ expect(() => context.notion.pages).toThrow(
14
+ "NOTION_API_TOKEN is not set. context.notion requires an API token when an app capability runs locally.",
15
+ );
16
+ });
17
+
18
+ it("explains how to grant deployed workflow access", () => {
19
+ process.env.NOTION_WORKFLOW_STEP_DIRECTORY = "/tmp/notion-run/steps";
20
+ const context = createCapabilityContext();
21
+
22
+ expect(() => context.notion.pages).toThrow(
23
+ "Notion API access is not configured for this deployed workflow. In Notion, open Developer Tools → Workers",
24
+ );
25
+ });
26
+ });
package/src/context.ts CHANGED
@@ -13,6 +13,14 @@ const LOCAL_FIX_STEPS =
13
13
  "Deployed apps receive Notion API credentials automatically. Do not push NOTION_API_TOKEN.";
14
14
 
15
15
  function missingTokenMessage(): string {
16
+ if (process.env.NOTION_WORKFLOW_STEP_DIRECTORY) {
17
+ return (
18
+ "Notion API access is not configured for this deployed workflow. " +
19
+ "In Notion, open Developer Tools → Workers, select this workflow, and grant access to the pages or databases it uses. " +
20
+ "The runtime provides NOTION_API_TOKEN automatically after access is configured; do not set or push it yourself."
21
+ );
22
+ }
23
+
16
24
  return (
17
25
  "NOTION_API_TOKEN is not set. " +
18
26
  "context.notion requires an API token when an app capability runs locally." +