@notionhq/apps 0.0.16 → 0.0.17

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 (56) hide show
  1. package/README.md +27 -2
  2. package/dist/connections.d.ts +39 -11
  3. package/dist/connections.d.ts.map +1 -1
  4. package/dist/connections.js +85 -21
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -0
  8. package/dist/notion-as-code/database.d.ts +3 -2
  9. package/dist/notion-as-code/database.d.ts.map +1 -1
  10. package/dist/notion-as-code/handles.d.ts +2 -0
  11. package/dist/notion-as-code/handles.d.ts.map +1 -1
  12. package/dist/notion-as-code/handles.js +2 -0
  13. package/dist/notion-as-code/index.d.ts +3 -0
  14. package/dist/notion-as-code/index.d.ts.map +1 -1
  15. package/dist/notion-as-code/intents.d.ts +2 -1
  16. package/dist/notion-as-code/intents.d.ts.map +1 -1
  17. package/dist/notion-as-code/view.d.ts +12 -0
  18. package/dist/notion-as-code/view.d.ts.map +1 -0
  19. package/dist/notion-as-code/view.js +13 -0
  20. package/dist/notion-as-code/views-types.test.d.ts +2 -0
  21. package/dist/notion-as-code/views-types.test.d.ts.map +1 -0
  22. package/dist/notion-as-code/views.d.ts +469 -0
  23. package/dist/notion-as-code/views.d.ts.map +1 -0
  24. package/dist/notion-as-code/views.js +0 -0
  25. package/dist/oauth.d.ts +11 -0
  26. package/dist/oauth.d.ts.map +1 -0
  27. package/dist/oauth.js +26 -0
  28. package/dist/providers.generated.d.ts +26 -86
  29. package/dist/providers.generated.d.ts.map +1 -1
  30. package/dist/providers.generated.js +54 -66
  31. package/dist/triggers.generated.d.ts +3 -3
  32. package/dist/triggers.generated.d.ts.map +1 -1
  33. package/dist/workflow.d.ts +8 -8
  34. package/dist/workflow.d.ts.map +1 -1
  35. package/dist/workflow.js +12 -6
  36. package/docs/CONNECTIONS.md +70 -22
  37. package/package.json +1 -1
  38. package/skills/connections/SKILL.md +7 -8
  39. package/skills/notion-as-code/SKILL.md +8 -2
  40. package/src/connections.test.ts +205 -41
  41. package/src/connections.ts +144 -38
  42. package/src/index.ts +1 -0
  43. package/src/notion-as-code/database.ts +3 -3
  44. package/src/notion-as-code/handles.ts +2 -0
  45. package/src/notion-as-code/index.ts +55 -0
  46. package/src/notion-as-code/intents.ts +2 -2
  47. package/src/notion-as-code/view.ts +23 -0
  48. package/src/notion-as-code/views-types.test.ts +59 -0
  49. package/src/notion-as-code/views.ts +554 -0
  50. package/src/oauth.ts +40 -0
  51. package/src/providers.generated.ts +68 -163
  52. package/src/triggers.generated.ts +4 -4
  53. package/src/workflow-connections-types.test.ts +16 -10
  54. package/src/workflow-types.test.ts +88 -16
  55. package/src/workflow.test.ts +45 -17
  56. package/src/workflow.ts +31 -17
@@ -1,9 +1,10 @@
1
1
  import { Client } from "@notionhq/client";
2
- import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest";
3
3
  import {
4
4
  connections,
5
5
  createWorkflowConnections,
6
- validateConnectionRequirements,
6
+ normalizeConnectionRequirements,
7
+ type WorkflowConnections,
7
8
  } from "./connections.js";
8
9
 
9
10
  afterEach(() => {
@@ -12,37 +13,95 @@ afterEach(() => {
12
13
  });
13
14
 
14
15
  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
- });
16
+ it("normalizes object keys into manifest requirements", () => {
17
+ expect(
18
+ normalizeConnectionRequirements({
19
+ work: connections.calendar(),
20
+ support: connections.slack(),
21
+ team: connections.slack(),
22
+ }),
23
+ ).toEqual([
24
+ { type: "calendar", key: "work" },
25
+ { type: "slack", key: "support" },
26
+ { type: "slack", key: "team" },
27
+ ]);
23
28
  });
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");
29
+ it.each(["bad/key", "__proto__", "constructor", "prototype"])(
30
+ "rejects invalid or prototype-sensitive keys: %s",
31
+ (key) => {
32
+ expect(() => normalizeConnectionRequirements({ [key]: connections.slack() })).toThrow(
33
+ "Invalid",
34
+ );
35
+ },
36
+ );
37
+ it("rejects excessive requirements", () => {
38
+ const requirements: Record<string, { type: string }> = {};
39
+ for (let i = 0; i < 101; i++) requirements[`team${i}`] = connections.slack();
40
+ expect(() => normalizeConnectionRequirements(requirements)).toThrow("100");
32
41
  });
33
- it("rejects invalid keys and excessive requirements", () => {
34
- expect(() =>
35
- validateConnectionRequirements([connections.slack({ key: "bad/key" })]),
36
- ).toThrow("Invalid");
42
+ it("rejects old arrays and declarations with hidden or inherited keys", () => {
37
43
  expect(() =>
38
- validateConnectionRequirements(
39
- Array.from({ length: 101 }, (_, i) => connections.slack({ key: `team${i}` })),
40
- ),
41
- ).toThrow("100");
44
+ Reflect.apply(normalizeConnectionRequirements, undefined, [[connections.slack()]]),
45
+ ).toThrow("must be an object");
46
+ const inherited = { support: connections.slack() };
47
+ Object.setPrototypeOf(inherited, { hidden: connections.calendar() });
48
+ expect(() => normalizeConnectionRequirements(inherited)).toThrow("only own");
49
+ const hidden = { support: connections.slack() };
50
+ Object.defineProperty(hidden, "hidden", { value: connections.calendar() });
51
+ expect(() => normalizeConnectionRequirements(hidden)).toThrow("enumerable strings");
52
+ const symbolic = { [Symbol("support")]: connections.slack() };
53
+ expect(() => normalizeConnectionRequirements(symbolic)).toThrow("enumerable strings");
54
+ const legacyDeclaration = { support: { ...connections.slack(), key: "other" } };
55
+ expect(() => normalizeConnectionRequirements(legacyDeclaration)).toThrow("object property");
42
56
  });
43
57
  });
44
58
 
45
59
  describe("workflow connection runtime", () => {
60
+ it("invokes separate bindings for multiple keys of the same provider", async () => {
61
+ vi.stubEnv(
62
+ "NOTION_CONNECTION_BINDINGS",
63
+ JSON.stringify([
64
+ { key: "support", type: "slack", id: "support-id" },
65
+ { key: "team", type: "slack", id: "team-id" },
66
+ { key: "undeclared", type: "slack", id: "hidden-id" },
67
+ ]),
68
+ );
69
+ const notion = new Client({ auth: "test" });
70
+ const request = vi.spyOn(notion, "request").mockResolvedValue({ result: undefined });
71
+ const declarations = { support: connections.slack(), team: connections.slack() };
72
+ const runtime = createWorkflowConnections(notion, declarations);
73
+ Object.assign(declarations, {
74
+ support: connections.calendar(),
75
+ injected: connections.slack(),
76
+ });
77
+ await runtime.support.findUserByEmail({ email: "support@example.com" });
78
+ await runtime.team.findUserByEmail({ email: "team@example.com" });
79
+ expect(request.mock.calls.map(([input]) => input.body)).toEqual([
80
+ {
81
+ connection: "support-id",
82
+ action: "findUserByEmail",
83
+ args: { email: "support@example.com" },
84
+ },
85
+ {
86
+ connection: "team-id",
87
+ action: "findUserByEmail",
88
+ args: { email: "team@example.com" },
89
+ },
90
+ ]);
91
+ expect(Object.keys(runtime)).toEqual(["support", "team"]);
92
+ expect(Reflect.get(runtime, "undeclared")).toBeUndefined();
93
+ expect(Reflect.get(runtime, "injected")).toBeUndefined();
94
+ expect(Reflect.get(runtime, "toString")).toBeUndefined();
95
+ expect(runtime.support).toBe(runtime.support);
96
+ });
97
+ it("does not parse missing or malformed bindings until a declared client is used", () => {
98
+ vi.stubEnv("NOTION_CONNECTION_BINDINGS", "not json");
99
+ const runtime = createWorkflowConnections(new Client(), { support: connections.slack() });
100
+ expect(Object.keys(runtime)).toEqual(["support"]);
101
+ expect(() => runtime.support).toThrow();
102
+ expect(Object.keys(createWorkflowConnections(new Client(), {}))).toEqual([]);
103
+ });
104
+
46
105
  it("preserves pagination through a generated OAuth provider method", async () => {
47
106
  vi.stubEnv(
48
107
  "NOTION_CONNECTION_BINDINGS",
@@ -52,9 +111,9 @@ describe("workflow connection runtime", () => {
52
111
  const request = vi
53
112
  .spyOn(notion, "request")
54
113
  .mockResolvedValue({ result: { files: [], nextPageToken: "next" } });
55
- const result = await createWorkflowConnections(notion)
56
- .googleDriveOauth("documents")
57
- .listFiles({ folderId: "folder", pageToken: "previous" });
114
+ const result = await createWorkflowConnections(notion, {
115
+ documents: connections.googleDriveOauth(),
116
+ }).documents.listFiles({ folderId: "folder", pageToken: "previous" });
58
117
  expect(result.nextPageToken).toBe("next");
59
118
  expect(request).toHaveBeenCalledWith({
60
119
  path: "tools/run/internal",
@@ -73,9 +132,9 @@ describe("workflow connection runtime", () => {
73
132
  );
74
133
  const notion = new Client({ auth: "test" });
75
134
  const request = vi.spyOn(notion, "request").mockResolvedValue({ result: [] });
76
- await createWorkflowConnections(notion)
77
- .box("documents")
78
- .search({ question: "Quarterly report" });
135
+ await createWorkflowConnections(notion, { documents: connections.box() }).documents.search({
136
+ question: "Quarterly report",
137
+ });
79
138
  expect(request).toHaveBeenCalledWith({
80
139
  path: "tools/run/internal",
81
140
  method: "post",
@@ -91,7 +150,9 @@ describe("workflow connection runtime", () => {
91
150
  vi.stubEnv("NOTION_CONNECTION_BINDINGS", '[{"key":"support","type":"calendar","id":"a"}]');
92
151
  const notion = new Client({ auth: "test" });
93
152
  const request = vi.spyOn(notion, "request");
94
- expect(() => createWorkflowConnections(notion).slack("support")).toThrow("not slack");
153
+ expect(
154
+ () => createWorkflowConnections(notion, { support: connections.slack() }).support,
155
+ ).toThrow("not slack");
95
156
  expect(request).not.toHaveBeenCalled();
96
157
  });
97
158
  it("invokes a typed Calendar method with the default binding", async () => {
@@ -101,7 +162,9 @@ describe("workflow connection runtime", () => {
101
162
  );
102
163
  const notion = new Client({ auth: "test" });
103
164
  const request = vi.spyOn(notion, "request").mockResolvedValue({ result: { accounts: [] } });
104
- const result = await createWorkflowConnections(notion).calendar().listCalendars({});
165
+ const result = await createWorkflowConnections(notion, {
166
+ calendar: connections.calendar(),
167
+ }).calendar.listCalendars({});
105
168
  expect(result.accounts).toEqual([]);
106
169
  expect(request).toHaveBeenCalledWith({
107
170
  path: "tools/run/internal",
@@ -117,9 +180,12 @@ describe("workflow connection runtime", () => {
117
180
  );
118
181
  const notion = new Client({ auth: "test" });
119
182
  const request = vi.spyOn(notion, "request").mockResolvedValue({ result: { ok: true } });
120
- const runtime = createWorkflowConnections(notion);
183
+ const runtime = createWorkflowConnections(notion, {
184
+ support: connections.slack(),
185
+ team: connections.slack(),
186
+ });
121
187
  await expect(
122
- runtime.slack("support").findUserByEmail({ email: "person@example.com" }),
188
+ runtime.support.findUserByEmail({ email: "person@example.com" }),
123
189
  ).resolves.toEqual({
124
190
  ok: true,
125
191
  });
@@ -132,7 +198,7 @@ describe("workflow connection runtime", () => {
132
198
  args: { email: "person@example.com" },
133
199
  },
134
200
  });
135
- expect(() => runtime.slack("slack")).toThrow("not bound");
201
+ expect(() => runtime.team).toThrow("not bound");
136
202
  expect(request).toHaveBeenCalledTimes(1);
137
203
  });
138
204
  it.each([
@@ -141,17 +207,115 @@ describe("workflow connection runtime", () => {
141
207
  '[{"key":"support","type":"slack","id":"a"},{"key":"support","type":"slack","id":"b"}]',
142
208
  ])("rejects malformed or ambiguous bindings: %s", (metadata) => {
143
209
  vi.stubEnv("NOTION_CONNECTION_BINDINGS", metadata);
144
- expect(() => createWorkflowConnections(new Client()).slack("support")).toThrow();
210
+ expect(
211
+ () => createWorkflowConnections(new Client(), { support: connections.slack() }).support,
212
+ ).toThrow();
145
213
  });
146
214
  it("propagates permission failures without retrying an alternate connection", async () => {
147
215
  vi.stubEnv("NOTION_CONNECTION_BINDINGS", '[{"key":"support","type":"slack","id":"a"}]');
148
216
  const notion = new Client({ auth: "test" });
149
217
  const request = vi.spyOn(notion, "request").mockRejectedValue(new Error("forbidden"));
150
218
  await expect(
151
- createWorkflowConnections(notion)
152
- .slack("support")
153
- .findUserByEmail({ email: "person@example.com" }),
219
+ createWorkflowConnections(notion, {
220
+ support: connections.slack(),
221
+ }).support.findUserByEmail({ email: "person@example.com" }),
154
222
  ).rejects.toThrow("forbidden");
155
223
  expect(request).toHaveBeenCalledTimes(1);
156
224
  });
157
225
  });
226
+
227
+ describe("generic OAuth connections", () => {
228
+ const config = {
229
+ authorizationEndpoint: "https://github.com/login/oauth/authorize",
230
+ tokenEndpoint: "https://github.com/login/oauth/access_token",
231
+ clientId: "client",
232
+ clientSecretEnv: "GITHUB_CLIENT_SECRET",
233
+ scope: "repo",
234
+ };
235
+ it("declares configuration with a secret reference", () => {
236
+ expect(connections.oauth(config)).toEqual({ type: "oauth", oauth: config });
237
+ const declaration = connections.oauth(config);
238
+ expectTypeOf(declaration.type).toEqualTypeOf<"oauth">();
239
+ expectTypeOf<
240
+ keyof WorkflowConnections<{ github: typeof declaration }>
241
+ >().toEqualTypeOf<"github">();
242
+ expectTypeOf<keyof WorkflowConnections<Record<never, never>>>().toEqualTypeOf<never>();
243
+ expect(normalizeConnectionRequirements({ github: declaration })).toEqual([
244
+ { type: "oauth", key: "github", oauth: config },
245
+ ]);
246
+ });
247
+ it("returns only the token bound to this workflow execution", async () => {
248
+ vi.stubEnv(
249
+ "NOTION_CONNECTION_BINDINGS",
250
+ JSON.stringify([{ key: "github", type: "oauth", id: "ab-cd" }]),
251
+ );
252
+ vi.stubEnv("NOTION_OAUTH_CONNECTION_ABCD", "scoped-token");
253
+ const notion = new Client({ auth: "test" });
254
+ const request = vi.spyOn(notion, "request");
255
+ expect(
256
+ await createWorkflowConnections(notion, {
257
+ github: connections.oauth(config),
258
+ }).github.accessToken(),
259
+ ).toBe("scoped-token");
260
+ expect(request).not.toHaveBeenCalled();
261
+ expect(
262
+ () => createWorkflowConnections(notion, { other: connections.oauth(config) }).other,
263
+ ).toThrow("not bound");
264
+ });
265
+ it("keeps separately keyed OAuth tokens isolated and omits undeclared bindings", async () => {
266
+ vi.stubEnv(
267
+ "NOTION_CONNECTION_BINDINGS",
268
+ JSON.stringify([
269
+ { key: "github", type: "oauth", id: "first-id" },
270
+ { key: "other", type: "oauth", id: "second-id" },
271
+ { key: "undeclared", type: "oauth", id: "hidden-id" },
272
+ ]),
273
+ );
274
+ vi.stubEnv("NOTION_OAUTH_CONNECTION_FIRSTID", "first-token");
275
+ vi.stubEnv("NOTION_OAUTH_CONNECTION_SECONDID", "second-token");
276
+ const runtime = createWorkflowConnections(new Client({ auth: "test" }), {
277
+ github: connections.oauth(config),
278
+ other: connections.oauth(config),
279
+ });
280
+ expect(await runtime.github.accessToken()).toBe("first-token");
281
+ expect(await runtime.other.accessToken()).toBe("second-token");
282
+ expect(Object.keys(runtime)).toEqual(["github", "other"]);
283
+ expect(Reflect.get(runtime, "undeclared")).toBeUndefined();
284
+ });
285
+
286
+ it("requires explicit authorization and does not fall back to worker OAuth", async () => {
287
+ vi.stubEnv(
288
+ "NOTION_CONNECTION_BINDINGS",
289
+ JSON.stringify([{ key: "github", type: "oauth", id: "unconfigured" }]),
290
+ );
291
+ vi.stubEnv("OAUTH_676974687562_ACCESS_TOKEN", "global-token");
292
+ await expect(
293
+ createWorkflowConnections(new Client({ auth: "test" }), {
294
+ github: connections.oauth(config),
295
+ }).github.accessToken(),
296
+ ).rejects.toThrow("requires authorization");
297
+ });
298
+ it("rejects a provider connection used as generic OAuth", () => {
299
+ vi.stubEnv(
300
+ "NOTION_CONNECTION_BINDINGS",
301
+ JSON.stringify([{ key: "github", type: "slack", id: "connection" }]),
302
+ );
303
+ expect(
304
+ () =>
305
+ createWorkflowConnections(new Client({ auth: "test" }), {
306
+ github: connections.oauth(config),
307
+ }).github,
308
+ ).toThrow("not oauth");
309
+ });
310
+ it.each(["http://localhost/token", "https://user:password@example.com/token"])(
311
+ "rejects unsafe token endpoint %s",
312
+ (tokenEndpoint) => {
313
+ expect(() => connections.oauth({ ...config, tokenEndpoint })).toThrow("HTTPS URLs");
314
+ },
315
+ );
316
+ it("protects the callback state and PKCE parameters", () => {
317
+ expect(() =>
318
+ connections.oauth({ ...config, authorizationParams: { state: "forged" } }),
319
+ ).toThrow("protocol fields");
320
+ });
321
+ });
@@ -1,61 +1,127 @@
1
+ import { validateOAuthConfiguration, type OAuthConfiguration } from "./oauth.js";
1
2
  import { connectionTriggerDefinitions } from "./connection-trigger-definitions.generated.js";
2
3
  import type { WorkflowTrigger } from "./triggers.generated.js";
3
4
  import type { Client } from "@notionhq/client";
4
- import { createConnectionProviders, type ConnectionProviders } from "./providers.generated.js";
5
+ import {
6
+ connections as providerConnections,
7
+ createConnectionClient,
8
+ type ConnectionClients,
9
+ } from "./providers.generated.js";
5
10
  import type { ConnectionActionInvoker } from "./connection-actions.js";
6
11
 
7
- /** A setup requirement. The server decides which providers are available. */
8
- export type WorkflowConnection<TProvider extends string = string, TKey extends string = string> = {
12
+ /** A setup requirement. The property name in a workflow supplies its key. */
13
+ export type WorkflowConnection<TProvider extends string = string> = {
9
14
  type: TProvider;
10
- key?: TKey;
15
+ oauth?: OAuthConfiguration;
11
16
  };
12
17
 
13
- /** Keys declared for one provider in a workflow. */
18
+ export type WorkflowConnectionDeclarations = Readonly<Record<string, WorkflowConnection>>;
19
+
20
+ /** Serialized connection requirement sent in the workflow manifest. */
21
+ export type WorkflowConnectionRequirement = WorkflowConnection & { key: string };
22
+
23
+ /** Declared property names belonging to one provider. */
14
24
  export type WorkflowConnectionKeys<
15
- TConnections extends readonly WorkflowConnection[],
25
+ TConnections extends WorkflowConnectionDeclarations,
16
26
  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;
27
+ > = Extract<
28
+ {
29
+ [TKey in keyof TConnections]: TConnections[TKey]["type"] extends TProvider ? TKey : never;
30
+ }[keyof TConnections],
31
+ string
32
+ >;
33
+
25
34
  type ConnectionBinding = { key: string; type: string; id: string };
26
35
 
27
36
  /** Requirements are declarations, not credentials or permission grants. */
28
- export { connections } from "./providers.generated.js";
37
+ export const connections = {
38
+ ...providerConnections,
39
+ oauth(options: OAuthConfiguration): WorkflowConnection<"oauth"> {
40
+ validateOAuthConfiguration(options);
41
+ return { type: "oauth", oauth: structuredClone(options) };
42
+ },
43
+ };
44
+ export type { OAuthConfiguration } from "./oauth.js";
45
+ export type OAuthConnection = { accessToken(): Promise<string> };
46
+
47
+ type WorkflowConnectionClients = ConnectionClients & { oauth: OAuthConnection };
48
+
49
+ type WorkflowConnectionClient = WorkflowConnectionClients[keyof WorkflowConnectionClients];
29
50
 
30
51
  export type WorkflowConnections<
31
- TConnections extends readonly WorkflowConnection[] = readonly WorkflowConnection[],
32
- > = Pick<ConnectionProviders, Extract<keyof ConnectionProviders, TConnections[number]["type"]>>;
52
+ TConnections extends WorkflowConnectionDeclarations = WorkflowConnectionDeclarations,
53
+ > = {
54
+ readonly [TKey in keyof TConnections]: WorkflowConnectionClients[Extract<
55
+ keyof WorkflowConnectionClients,
56
+ TConnections[TKey]["type"]
57
+ >];
58
+ };
33
59
 
34
60
  /** @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)) {
61
+ export function normalizeConnectionRequirements(
62
+ declarations: WorkflowConnectionDeclarations,
63
+ ): WorkflowConnectionRequirement[] {
64
+ if (typeof declarations !== "object" || declarations === null || Array.isArray(declarations)) {
65
+ throw new Error("Workflow connections must be an object keyed by connection name.");
66
+ }
67
+ const prototype: unknown = Object.getPrototypeOf(declarations);
68
+ if (prototype !== Object.prototype && prototype !== null) {
69
+ throw new Error("Workflow connections must contain only own connection declarations.");
70
+ }
71
+ const keys = Object.keys(declarations);
72
+ if (Reflect.ownKeys(declarations).length !== keys.length) {
73
+ throw new Error("Workflow connection keys must be enumerable strings.");
74
+ }
75
+ if (keys.length > 100) throw new Error("Workflows support at most 100 connections.");
76
+ return Object.entries(declarations).map(([key, requirement]) => {
77
+ if (
78
+ !/^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/.test(key) ||
79
+ ["__proto__", "prototype", "constructor"].includes(key)
80
+ ) {
42
81
  throw new Error("Invalid workflow connection key: " + key);
43
82
  }
44
- if (keys.has(key)) throw new Error("Duplicate workflow connection: " + key);
45
- keys.add(key);
46
- }
83
+ if (
84
+ typeof requirement !== "object" ||
85
+ requirement === null ||
86
+ typeof requirement.type !== "string" ||
87
+ !requirement.type ||
88
+ !Object.hasOwn(requirement, "type")
89
+ ) {
90
+ throw new Error("A workflow connection needs a provider type.");
91
+ }
92
+ if ("key" in requirement)
93
+ throw new Error(
94
+ "Declare a connection key as its workflow object property, not in the provider options.",
95
+ );
96
+
97
+ if (requirement.type === "oauth") {
98
+ if (!requirement.oauth)
99
+ throw new Error("OAuth connections require provider configuration.");
100
+ validateOAuthConfiguration(requirement.oauth);
101
+ } else if (requirement.oauth !== undefined) {
102
+ throw new Error("OAuth configuration is only valid on OAuth connections.");
103
+ }
104
+
105
+ return {
106
+ type: requirement.type,
107
+ key,
108
+ ...(requirement.oauth === undefined
109
+ ? {}
110
+ : { oauth: structuredClone(requirement.oauth) }),
111
+ };
112
+ });
47
113
  }
48
114
 
49
115
  /** @internal */
50
116
  export function validateTriggerConnections(
51
117
  triggers: readonly WorkflowTrigger[],
52
- requirements: readonly WorkflowConnection[],
118
+ requirements: readonly WorkflowConnectionRequirement[],
53
119
  ): void {
54
120
  const seen = new Set<string>();
55
121
  for (const trigger of triggers) {
56
122
  if (!("connectionKey" in trigger) || trigger.connectionKey === undefined) continue;
57
123
  const requirement = requirements.find(
58
- (connection) => (connection.key ?? connection.type) === trigger.connectionKey,
124
+ (connection) => connection.key === trigger.connectionKey,
59
125
  );
60
126
  const definition = connectionTriggerDefinitions.find(
61
127
  (definition) => definition.type === trigger.type,
@@ -75,10 +141,41 @@ export function validateTriggerConnections(
75
141
  }
76
142
 
77
143
  /** @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.
144
+ export function createWorkflowConnections<TConnections extends WorkflowConnectionDeclarations>(
145
+ notion: Client,
146
+ declarations: TConnections | undefined,
147
+ ): WorkflowConnections<TConnections>;
148
+ export function createWorkflowConnections(
149
+ notion: Client,
150
+ declarations: WorkflowConnectionDeclarations | undefined,
151
+ ): Record<string, WorkflowConnectionClient> {
152
+ const requirements = normalizeConnectionRequirements(declarations ?? {});
153
+ // Capture this execution's metadata, but do not parse unused bindings.
81
154
  const rawBindings = process.env.NOTION_CONNECTION_BINDINGS;
155
+ const clients: Record<string, WorkflowConnectionClient> = {};
156
+ Object.setPrototypeOf(clients, null);
157
+ for (const requirement of requirements) {
158
+ let client: WorkflowConnectionClient | undefined;
159
+ Object.defineProperty(clients, requirement.key, {
160
+ enumerable: true,
161
+ get() {
162
+ if (client) return client;
163
+ const binding = resolveBinding(requirement.key);
164
+ if (binding.type !== requirement.type) {
165
+ throw new Error(
166
+ `Workflow connection "${requirement.key}" is ${binding.type}, not ${requirement.type}.`,
167
+ );
168
+ }
169
+ client =
170
+ requirement.type === "oauth"
171
+ ? createOAuthConnection(binding)
172
+ : createConnectionClient(requirement.type, invoker(binding));
173
+ return client;
174
+ },
175
+ });
176
+ }
177
+ return Object.freeze(clients);
178
+
82
179
  function resolveBinding(key: string): ConnectionBinding {
83
180
  const bindings: unknown = JSON.parse(rawBindings ?? "[]");
84
181
  if (!Array.isArray(bindings) || !bindings.every(isConnectionBinding)) {
@@ -92,11 +189,7 @@ export function createWorkflowConnections(notion: Client): WorkflowConnections {
92
189
  }
93
190
  return { ...matches[0] };
94
191
  }
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
- }
192
+ function invoker(binding: ConnectionBinding): ConnectionActionInvoker {
100
193
  return async <Result>(action: string, args: object): Promise<Result> => {
101
194
  const response = await notion.request<{ result: Result }>({
102
195
  path: "tools/run/internal",
@@ -106,7 +199,20 @@ export function createWorkflowConnections(notion: Client): WorkflowConnections {
106
199
  return response.result;
107
200
  };
108
201
  }
109
- return createConnectionProviders(invoker);
202
+ }
203
+
204
+ function createOAuthConnection(binding: ConnectionBinding): OAuthConnection {
205
+ const envKey = `NOTION_OAUTH_CONNECTION_${binding.id.replaceAll("-", "").toUpperCase()}`;
206
+ const token = process.env[envKey];
207
+ return {
208
+ async accessToken() {
209
+ if (!token)
210
+ throw new Error(
211
+ `Workflow OAuth connection "${binding.key}" requires authorization.`,
212
+ );
213
+ return token;
214
+ },
215
+ };
110
216
  }
111
217
 
112
218
  function isConnectionBinding(value: unknown): value is ConnectionBinding {
package/src/index.ts CHANGED
@@ -3,5 +3,6 @@ export { customAgent } from "./notion-as-code/custom-agent.js";
3
3
  export { database } from "./notion-as-code/database.js";
4
4
  export { page } from "./notion-as-code/page.js";
5
5
  export { teamspace } from "./notion-as-code/teamspace.js";
6
+ export { view } from "./notion-as-code/view.js";
6
7
  export { sync } from "./sync.js";
7
8
  export { workflow } from "./workflow.js";
@@ -12,6 +12,7 @@ import { createPage, type ChildPageArgs, type PageHandle } from "./page.js";
12
12
  import { recordIntent } from "./recorder.js";
13
13
  import { assertParent, assertUserResourceId } from "./resource.js";
14
14
  import { createAppsDatabase, type AppsSchemaForProperties } from "./schema.js";
15
+ import type { ViewSchema } from "./views.js";
15
16
 
16
17
  /** Input for declaring a database and its optional data sources. */
17
18
  export type DatabaseArgs<
@@ -21,8 +22,7 @@ export type DatabaseArgs<
21
22
  /** Omit to create a private top-level database in the Apps workspace. */
22
23
  parent?: Parent;
23
24
  dataSources?: DataSources;
24
- // TODO: Port view types.
25
- views?: readonly unknown[];
25
+ views?: readonly ViewSchema[];
26
26
  icon?: NotionAsCodeIcon;
27
27
  // TODO: Port cover types.
28
28
  cover?: unknown;
@@ -81,7 +81,7 @@ export type DatabaseHandle<
81
81
  DataSourcePropertiesForResourceId<DataSources, ResourceId>
82
82
  >;
83
83
  };
84
- addView: (view: unknown) => void;
84
+ addView: (view: ViewSchema) => void;
85
85
  };
86
86
 
87
87
  /** A database whose sole data source is available without an ID lookup. */
@@ -4,12 +4,14 @@ import { date, datetime } from "./date.js";
4
4
  import { page } from "./page.js";
5
5
  import { assertUserResourceId } from "./resource.js";
6
6
  import { teamspace } from "./teamspace.js";
7
+ import { view } from "./view.js";
7
8
 
8
9
  const notionAsCodeFacade = {
9
10
  teamspace,
10
11
  database,
11
12
  page,
12
13
  customAgent,
14
+ view,
13
15
  text: (value: string) => [[value]],
14
16
  number: (value: number) => [[String(value)]],
15
17
  date,
@@ -15,6 +15,61 @@ export type {
15
15
  } from "./database.js";
16
16
  export type { ChildPageArgs, PageArgs, PageHandle } from "./page.js";
17
17
  export type { TeamspaceArgs, TeamspaceHandle } from "./teamspace.js";
18
+ export type { ViewArgs, ViewHandle } from "./view.js";
19
+ export type {
20
+ AdvancedFilterSchema,
21
+ BasePropertyFilter,
22
+ BaseViewSchema,
23
+ BoardViewSchema,
24
+ CalendarViewSchema,
25
+ CheckboxGroupByFormat,
26
+ CheckboxPropertyFilter,
27
+ CoverAspectFormat,
28
+ CoverFormat,
29
+ CoverSizeFormat,
30
+ DatabaseViewSortDirection,
31
+ DateFilterMode,
32
+ DateGroupByFormat,
33
+ DatePropertyFilter,
34
+ DatePropertyFilterValue,
35
+ DatePropertyTypes,
36
+ ExactDatePropertyFilterValue,
37
+ ExactRangeDatePropertyFilterValue,
38
+ FeedViewSchema,
39
+ FilterSchema,
40
+ FormulaGroupByFormat,
41
+ GalleryViewSchema,
42
+ GroupByFormat,
43
+ GroupByFormatBase,
44
+ GroupFormat,
45
+ ListViewSchema,
46
+ LocationGroupByFormat,
47
+ MultiSelectPropertyFilter,
48
+ NumberGroupByFormat,
49
+ NumberPropertyFilter,
50
+ PersonGroupByFormat,
51
+ PersonPropertyFilter,
52
+ PropertyFilterSchema,
53
+ PropertyFormat,
54
+ PropertyViewSortSchema,
55
+ PropertyVisibility,
56
+ RelationGroupByFormat,
57
+ RelationPropertyFilter,
58
+ RelativeDatePreset,
59
+ RelativeDatePropertyFilterValue,
60
+ RelativeDateRangeUnit,
61
+ RelativeToTodayDatePropertyFilterValue,
62
+ SelectGroupByFormat,
63
+ SelectPropertyFilter,
64
+ StatusGroupByFormat,
65
+ StatusPropertyFilter,
66
+ TableViewSchema,
67
+ TextGroupByFormat,
68
+ TextPropertyFilter,
69
+ TimelineViewSchema,
70
+ ViewSchema,
71
+ ViewType,
72
+ } from "./views.js";
18
73
 
19
74
  /** Experimental Notion-as-Code facade for Apps SDK project metadata. */
20
75
  export const notion = createNotion();