@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.
- package/AGENTS.md +13 -0
- package/README.md +69 -2
- package/dist/connections.d.ts +39 -11
- package/dist/connections.d.ts.map +1 -1
- package/dist/connections.js +85 -21
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/notion-as-code/database.d.ts +26 -3
- package/dist/notion-as-code/database.d.ts.map +1 -1
- package/dist/notion-as-code/database.js +27 -0
- package/dist/notion-as-code/handles.d.ts +2 -0
- package/dist/notion-as-code/handles.d.ts.map +1 -1
- package/dist/notion-as-code/handles.js +2 -0
- package/dist/notion-as-code/index.d.ts +4 -1
- package/dist/notion-as-code/index.d.ts.map +1 -1
- package/dist/notion-as-code/intents.d.ts +2 -1
- package/dist/notion-as-code/intents.d.ts.map +1 -1
- package/dist/notion-as-code/page.d.ts +3 -3
- package/dist/notion-as-code/page.d.ts.map +1 -1
- package/dist/notion-as-code/page.js +2 -7
- package/dist/notion-as-code/teamspace.d.ts +3 -3
- package/dist/notion-as-code/teamspace.d.ts.map +1 -1
- package/dist/notion-as-code/teamspace.js +2 -7
- package/dist/notion-as-code/view.d.ts +12 -0
- package/dist/notion-as-code/view.d.ts.map +1 -0
- package/dist/notion-as-code/view.js +13 -0
- package/dist/notion-as-code/views-types.test.d.ts +2 -0
- package/dist/notion-as-code/views-types.test.d.ts.map +1 -0
- package/dist/notion-as-code/views.d.ts +469 -0
- package/dist/notion-as-code/views.d.ts.map +1 -0
- package/dist/notion-as-code/views.js +0 -0
- package/dist/oauth.d.ts +11 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +26 -0
- package/dist/providers.generated.d.ts +26 -86
- package/dist/providers.generated.d.ts.map +1 -1
- package/dist/providers.generated.js +54 -66
- package/dist/triggers.generated.d.ts +3 -3
- package/dist/triggers.generated.d.ts.map +1 -1
- package/dist/workflow.d.ts +8 -8
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +12 -6
- package/docs/CONNECTIONS.md +70 -22
- package/package.json +3 -1
- package/skills/connections/SKILL.md +85 -0
- package/skills/custom-blocks/SKILL.md +45 -0
- package/skills/notion-as-code/SKILL.md +195 -0
- package/skills/sync/SKILL.md +98 -0
- package/skills/workflow/SKILL.md +123 -0
- package/src/cli/build.test.ts +95 -3
- package/src/connections.test.ts +205 -41
- package/src/connections.ts +144 -38
- package/src/index.ts +1 -0
- package/src/notion-as-code/database.ts +75 -5
- package/src/notion-as-code/handles.ts +2 -0
- package/src/notion-as-code/index.ts +59 -0
- package/src/notion-as-code/intents.ts +2 -2
- package/src/notion-as-code/page.ts +3 -11
- package/src/notion-as-code/teamspace.ts +3 -11
- package/src/notion-as-code/view.ts +23 -0
- package/src/notion-as-code/views-types.test.ts +59 -0
- package/src/notion-as-code/views.ts +554 -0
- package/src/oauth.ts +40 -0
- package/src/providers.generated.ts +68 -163
- package/src/triggers.generated.ts +4 -4
- package/src/workflow-connections-types.test.ts +16 -10
- package/src/workflow-types.test.ts +88 -16
- package/src/workflow.test.ts +45 -17
- 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.
|
package/src/cli/build.test.ts
CHANGED
|
@@ -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 },
|
package/src/connections.test.ts
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
16
|
-
expect(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
57
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
153
|
-
|
|
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
|
+
});
|