@notionhq/apps 0.0.15 → 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 (70) hide show
  1. package/AGENTS.md +13 -0
  2. package/README.md +69 -2
  3. package/dist/connections.d.ts +39 -11
  4. package/dist/connections.d.ts.map +1 -1
  5. package/dist/connections.js +85 -21
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/notion-as-code/database.d.ts +26 -3
  10. package/dist/notion-as-code/database.d.ts.map +1 -1
  11. package/dist/notion-as-code/database.js +27 -0
  12. package/dist/notion-as-code/handles.d.ts +2 -0
  13. package/dist/notion-as-code/handles.d.ts.map +1 -1
  14. package/dist/notion-as-code/handles.js +2 -0
  15. package/dist/notion-as-code/index.d.ts +4 -1
  16. package/dist/notion-as-code/index.d.ts.map +1 -1
  17. package/dist/notion-as-code/intents.d.ts +2 -1
  18. package/dist/notion-as-code/intents.d.ts.map +1 -1
  19. package/dist/notion-as-code/page.d.ts +3 -3
  20. package/dist/notion-as-code/page.d.ts.map +1 -1
  21. package/dist/notion-as-code/page.js +2 -7
  22. package/dist/notion-as-code/teamspace.d.ts +3 -3
  23. package/dist/notion-as-code/teamspace.d.ts.map +1 -1
  24. package/dist/notion-as-code/teamspace.js +2 -7
  25. package/dist/notion-as-code/view.d.ts +12 -0
  26. package/dist/notion-as-code/view.d.ts.map +1 -0
  27. package/dist/notion-as-code/view.js +13 -0
  28. package/dist/notion-as-code/views-types.test.d.ts +2 -0
  29. package/dist/notion-as-code/views-types.test.d.ts.map +1 -0
  30. package/dist/notion-as-code/views.d.ts +469 -0
  31. package/dist/notion-as-code/views.d.ts.map +1 -0
  32. package/dist/notion-as-code/views.js +0 -0
  33. package/dist/oauth.d.ts +11 -0
  34. package/dist/oauth.d.ts.map +1 -0
  35. package/dist/oauth.js +26 -0
  36. package/dist/providers.generated.d.ts +26 -86
  37. package/dist/providers.generated.d.ts.map +1 -1
  38. package/dist/providers.generated.js +54 -66
  39. package/dist/triggers.generated.d.ts +3 -3
  40. package/dist/triggers.generated.d.ts.map +1 -1
  41. package/dist/workflow.d.ts +8 -8
  42. package/dist/workflow.d.ts.map +1 -1
  43. package/dist/workflow.js +12 -6
  44. package/docs/CONNECTIONS.md +70 -22
  45. package/package.json +3 -1
  46. package/skills/connections/SKILL.md +85 -0
  47. package/skills/custom-blocks/SKILL.md +45 -0
  48. package/skills/notion-as-code/SKILL.md +195 -0
  49. package/skills/sync/SKILL.md +98 -0
  50. package/skills/workflow/SKILL.md +123 -0
  51. package/src/cli/build.test.ts +95 -3
  52. package/src/connections.test.ts +205 -41
  53. package/src/connections.ts +144 -38
  54. package/src/index.ts +1 -0
  55. package/src/notion-as-code/database.ts +75 -5
  56. package/src/notion-as-code/handles.ts +2 -0
  57. package/src/notion-as-code/index.ts +59 -0
  58. package/src/notion-as-code/intents.ts +2 -2
  59. package/src/notion-as-code/page.ts +3 -11
  60. package/src/notion-as-code/teamspace.ts +3 -11
  61. package/src/notion-as-code/view.ts +23 -0
  62. package/src/notion-as-code/views-types.test.ts +59 -0
  63. package/src/notion-as-code/views.ts +554 -0
  64. package/src/oauth.ts +40 -0
  65. package/src/providers.generated.ts +68 -163
  66. package/src/triggers.generated.ts +4 -4
  67. package/src/workflow-connections-types.test.ts +16 -10
  68. package/src/workflow-types.test.ts +88 -16
  69. package/src/workflow.test.ts +45 -17
  70. package/src/workflow.ts +31 -17
@@ -0,0 +1,123 @@
1
+ ---
2
+ name: workflow
3
+ description: Build or review Notion App workflows for typed triggers, durable replay-safe steps, and idempotent effects.
4
+ user-invocable: true
5
+ disable-model-invocation: true
6
+ ---
7
+
8
+ # Workflow
9
+
10
+ Use this skill to add or review a workflow in this template.
11
+
12
+ ## Build or change a workflow
13
+
14
+ 1. Read `AGENTS.md` and the existing files in `src/workflows/`.
15
+ 2. Inspect the installed workflow and trigger declarations.
16
+ 3. Choose the trigger, outcome, step boundaries, and required configuration.
17
+ 4. Create one camelCase file directly in `src/workflows/`.
18
+ 5. Default-export `workflow(...)` and use typed trigger creators.
19
+ 6. Put all non-deterministic work in awaited `context.step(...)` calls.
20
+ 7. Give each step a stable display name. For repeated steps, keep the name
21
+ constant and pass a stable, unique composite `key`, such as
22
+ `{ key: ["process-page", page.id] }`.
23
+ 8. Return JSON-safe values needed by later steps.
24
+ 9. Use the step `id` as an idempotency key when supported.
25
+ 10. Run `npm run check` and `npm run build`.
26
+
27
+ Each direct `src/workflows/*.ts` file must default-export `workflow(...)`.
28
+ The camelCase file name becomes its workflow key.
29
+ Keep workflow-specific helpers in `src/workflows/lib/` and shared helpers in
30
+ `src/lib/`; direct children of `src/workflows/` are discovered as workflows.
31
+
32
+ For provider triggers, follow [trigger connection binding](../connections/SKILL.md#bind-triggers-to-connections).
33
+ Use the typed trigger callback to check `connectionKey` against the declared
34
+ provider connections.
35
+
36
+ Use a step for every value that can change and every external effect: network
37
+ and Notion API calls, mutable state reads, timestamps, random values, generated
38
+ IDs, messages, creates, and updates. Keep deterministic transforms of the event
39
+ and completed step results outside a step.
40
+
41
+ Completed steps replay saved results. Do not rely on in-memory mutations inside
42
+ a step. Return the values required by later code. Keep calls in a stable order
43
+ and give every step a stable display name. The name is the replay key by
44
+ default. For a repeated step, do not interpolate an item ID or loop index into
45
+ the name; pass a stable composite key instead:
46
+
47
+ ```ts
48
+ await context.step("Process page", { key: ["process-page", page.id] }, async ({ id }) =>
49
+ processPage(page, { idempotencyKey: id }),
50
+ );
51
+ ```
52
+
53
+ Keys must be stable across retries and unique within one workflow run.
54
+
55
+ An external effect can succeed before its step result is saved. Pass the
56
+ callback `id` as an idempotency key when supported; otherwise use a stable
57
+ external ID, upsert, or duplicate check.
58
+
59
+ Do not write credentials. Add only environment variable names and safe
60
+ placeholders to `.env.example` when configuration is required. Return only
61
+ JSON-serializable step values, throw on failed requests and missing required
62
+ configuration, and do not log secrets or private payloads.
63
+
64
+ ## Resources created with the App
65
+
66
+ Use [Notion as Code](../notion-as-code/SKILL.md) for pages and databases that
67
+ should be created during deployment. Prefer it over equivalent manual setup
68
+ or API calls that create the App's resources. Use runtime API calls for dynamic
69
+ data changes, not as a substitute for supported Notion as Code setup.
70
+ Keep declarations at module scope in
71
+ an imported helper. For example, declare a guide page in `src/lib/resources.ts`:
72
+
73
+ ```ts
74
+ import { page } from "@notionhq/apps";
75
+
76
+ export const guide = page({
77
+ resourceId: "workflow-guide",
78
+ content: "# Workflow guide\nThis App runs a scheduled workflow.",
79
+ });
80
+ ```
81
+
82
+ Import the module from `src/workflows/sayHello.ts` so the build records it:
83
+
84
+ ```ts
85
+ import "../lib/resources";
86
+ import { workflow } from "@notionhq/apps";
87
+ import { triggers } from "@notionhq/apps/triggers";
88
+
89
+ export default workflow({
90
+ name: "Say Hello",
91
+ description: "Says hello on a recurring schedule.",
92
+ triggers: [triggers.scheduled()],
93
+ handler: async (_event, context) => {
94
+ await context.step("Say hello", () => {
95
+ console.log("Hello from your workflow!");
96
+ });
97
+ },
98
+ });
99
+ ```
100
+
101
+ The page is provisioned during deployment, not on every workflow run.
102
+ Database declarations follow the same import pattern; see the
103
+ [sync example](../sync/SKILL.md#choose-the-database-source) for using a Notion as Code
104
+ data source in a sync. Notion as Code resource IDs are declaration identities, not live
105
+ Notion UUIDs to pass to `context.notion`. Runtime API calls still belong in
106
+ durable steps and need actual resolved Notion IDs.
107
+
108
+ ## Review a workflow
109
+
110
+ Review every file in `src/workflows/` and the modules it calls. Report each
111
+ finding with its file, line, impact, and fix. Treat these as errors:
112
+
113
+ 1. A workflow is not a direct file or does not default-export `workflow`.
114
+ 2. Trigger-specific event fields are used without type narrowing.
115
+ 3. Non-deterministic work occurs outside an awaited step.
116
+ 4. Step order or names can change between retries.
117
+ 5. A step result is not JSON-serializable, or later code needs an in-memory mutation.
118
+ 6. A retry-sensitive write lacks an idempotency key or duplicate guard.
119
+ 7. External failures are ignored, or credentials are hard-coded or logged.
120
+ 8. An explicit trigger connection key is undeclared, belongs to the wrong
121
+ provider, or repeats the same trigger-type/key pair.
122
+
123
+ Run `npm run check` and `npm run build` when dependencies are installed.
@@ -2,9 +2,101 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- import { afterEach, describe, expect, it } from "vitest";
5
+ import { afterEach, describe, expect, expectTypeOf, it } from "vitest";
6
6
 
7
7
  import { buildApp } from "./build.js";
8
+ import {
9
+ notion,
10
+ type DataSourceHandle,
11
+ type SingleSourceDatabaseHandle,
12
+ } from "../notion-as-code/index.js";
13
+ import { finishMetadataRecording, startMetadataRecording } from "../notion-as-code/recorder.js";
14
+
15
+ describe("Notion-as-Code database shorthand", () => {
16
+ it("keeps provisioning and sync identity equivalent to an explicit source", () => {
17
+ const recorder = startMetadataRecording();
18
+ const database = notion.database({
19
+ resourceId: "issues",
20
+ name: "Issues",
21
+ properties: [
22
+ { resourceId: "title", name: "Name", type: "title" },
23
+ { resourceId: "count", name: "Count", type: "number" },
24
+ ],
25
+ });
26
+ expectTypeOf(database).toExtend<
27
+ SingleSourceDatabaseHandle<typeof database.dataSource.schema>
28
+ >();
29
+ expectTypeOf(
30
+ database.dataSource.database.config.schema.Count.type,
31
+ ).toEqualTypeOf<"number">();
32
+ database.dataSource.addPage({ resourceId: "issue" });
33
+ const shorthand = finishMetadataRecording(recorder).intents;
34
+
35
+ const explicitRecorder = startMetadataRecording();
36
+ const explicit = notion.database({
37
+ resourceId: "issues",
38
+ name: "Issues",
39
+ dataSources: [
40
+ {
41
+ resourceId: "issues-source",
42
+ name: "Issues",
43
+ properties: database.dataSource.schema,
44
+ },
45
+ ],
46
+ });
47
+ explicit.dataSources["issues-source"].addPage({ resourceId: "issue" });
48
+ // @ts-expect-error Explicit databases do not guarantee a single source.
49
+ void explicit.dataSource;
50
+ expect(shorthand).toEqual(finishMetadataRecording(explicitRecorder).intents);
51
+ expect(database.dataSource.database.key).toBe("issues-source");
52
+ expect(shorthand[1]).toEqual({
53
+ type: "page",
54
+ resourceId: "issue",
55
+ parent: { type: "resourceId", resourceId: "issues-source" },
56
+ });
57
+ });
58
+
59
+ it("preserves parent binding and inferred source types through child helpers", () => {
60
+ const recorder = startMetadataRecording();
61
+ const page = notion.page({ resourceId: "page" });
62
+ const team = notion.teamspace({ resourceId: "team", name: "Team", accessLevel: "private" });
63
+ for (const parent of [page, team]) {
64
+ const database = parent.addDatabase({
65
+ resourceId: `${parent.resourceId}-db`,
66
+ name: "Child",
67
+ properties: [
68
+ { resourceId: `${parent.resourceId}-title`, name: "Title", type: "title" },
69
+ ],
70
+ });
71
+ expectTypeOf(database.dataSource).toExtend<
72
+ DataSourceHandle<typeof database.dataSource.schema>
73
+ >();
74
+ expectTypeOf(database.dataSource.schema[0].name).toEqualTypeOf<"Title">();
75
+ }
76
+ const intents = finishMetadataRecording(recorder).intents;
77
+ expect(
78
+ intents.filter((intent) => intent.type === "database").map((intent) => intent.parent),
79
+ ).toEqual([
80
+ { type: "resourceId", resourceId: "page" },
81
+ { type: "resourceId", resourceId: "team" },
82
+ ]);
83
+ });
84
+
85
+ it("rejects mixed forms before recording an intent", () => {
86
+ const recorder = startMetadataRecording();
87
+ const declareMixedDatabase = () => {
88
+ // @ts-expect-error The shorthand and explicit forms are mutually exclusive.
89
+ notion.database({
90
+ resourceId: "mixed",
91
+ name: "Mixed",
92
+ properties: [],
93
+ dataSources: [],
94
+ });
95
+ };
96
+ expect(declareMixedDatabase).toThrow();
97
+ expect(finishMetadataRecording(recorder).intents).toEqual([]);
98
+ });
99
+ });
8
100
 
9
101
  const FIXTURES = fileURLToPath(new URL("../../test/fixtures/", import.meta.url));
10
102
  const EXAMPLES = fileURLToPath(new URL("../../examples/", import.meta.url));
@@ -89,7 +181,7 @@ describe("buildApp", () => {
89
181
  },
90
182
  },
91
183
  {
92
- key: "homebase-meetings-source",
184
+ key: "homebase-meetings-db-source",
93
185
  config: {
94
186
  initialTitle: "Meetings",
95
187
  schema: {
@@ -120,7 +212,7 @@ describe("buildApp", () => {
120
212
  type: "sync",
121
213
  key: "notionAsCodeIssues",
122
214
  config: {
123
- databaseKey: "homebase-meetings-source",
215
+ databaseKey: "homebase-meetings-db-source",
124
216
  primaryKeyProperty: "Meeting ID",
125
217
  mode: "incremental",
126
218
  schedule: { type: "interval", intervalMs: 3_600_000 },
@@ -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
+ });