@notionhq/apps 0.0.18 → 0.0.20
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/README.md +22 -1
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +12 -7
- package/dist/cli/emit-manifest.d.ts +1 -1
- package/dist/cli/emit-manifest.d.ts.map +1 -1
- package/dist/cli/emit-manifest.js +66 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/notion-as-code/custom-agent.d.ts +1 -0
- package/dist/notion-as-code/custom-agent.d.ts.map +1 -1
- package/dist/notion-as-code/custom-agent.js +1 -1
- package/dist/notion-as-code/database.d.ts +2 -0
- package/dist/notion-as-code/database.d.ts.map +1 -1
- package/dist/notion-as-code/database.js +3 -0
- package/dist/notion-as-code/date.d.ts +3 -2
- package/dist/notion-as-code/date.d.ts.map +1 -1
- package/dist/notion-as-code/handles.d.ts +8 -11
- package/dist/notion-as-code/handles.d.ts.map +1 -1
- package/dist/notion-as-code/handles.js +8 -7
- package/dist/notion-as-code/index.d.ts +8 -11
- package/dist/notion-as-code/index.d.ts.map +1 -1
- package/dist/notion-as-code/page.d.ts +50 -1
- package/dist/notion-as-code/page.d.ts.map +1 -1
- package/dist/notion-as-code/page.js +1 -0
- package/dist/notion-as-code/page.test.d.ts +2 -0
- package/dist/notion-as-code/page.test.d.ts.map +1 -0
- package/dist/workflow-access-types.test.d.ts +2 -0
- package/dist/workflow-access-types.test.d.ts.map +1 -0
- package/dist/workflow-access.d.ts +68 -0
- package/dist/workflow-access.d.ts.map +1 -0
- package/dist/workflow-access.js +135 -0
- package/dist/workflow.d.ts +17 -6
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +8 -1
- package/docs/BUILD.md +38 -4
- package/package.json +1 -1
- package/skills/notion-as-code/SKILL.md +16 -3
- package/skills/workflow/SKILL.md +53 -16
- package/src/cli/build.test.ts +24 -0
- package/src/cli/build.ts +13 -8
- package/src/cli/emit-manifest.ts +107 -0
- package/src/index.ts +20 -1
- package/src/notion-as-code/custom-agent.ts +2 -1
- package/src/notion-as-code/database.ts +5 -0
- package/src/notion-as-code/date.ts +4 -2
- package/src/notion-as-code/handles.ts +16 -12
- package/src/notion-as-code/index.ts +10 -1
- package/src/notion-as-code/page.test.ts +35 -0
- package/src/notion-as-code/page.ts +62 -2
- package/src/workflow-access-types.test.ts +69 -0
- package/src/workflow-access.ts +299 -0
- package/src/workflow.test.ts +112 -0
- package/src/workflow.ts +50 -7
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type { CustomAgentHandle } from "./notion-as-code/custom-agent.js";
|
|
2
|
+
import type {
|
|
3
|
+
DataSourceHandle,
|
|
4
|
+
DatabaseHandle,
|
|
5
|
+
SingleSourceDatabaseHandle,
|
|
6
|
+
} from "./notion-as-code/database.js";
|
|
7
|
+
import type { PageHandle } from "./notion-as-code/page.js";
|
|
8
|
+
|
|
9
|
+
/** A Notion-as-Code resource that can provide a named workflow binding. */
|
|
10
|
+
export type WorkflowAccessResource =
|
|
11
|
+
| PageHandle
|
|
12
|
+
| DatabaseHandle
|
|
13
|
+
| SingleSourceDatabaseHandle
|
|
14
|
+
| DataSourceHandle
|
|
15
|
+
| CustomAgentHandle;
|
|
16
|
+
|
|
17
|
+
/** Resource kinds accepted by workflow access declarations. */
|
|
18
|
+
export type WorkflowAccessResourceType = WorkflowAccessResource["resourceType"];
|
|
19
|
+
|
|
20
|
+
/** Levels accepted by pages, databases, and data sources. */
|
|
21
|
+
export type WorkflowAccessResourceLevel = "view" | "comment" | "edit" | "fullAccess";
|
|
22
|
+
|
|
23
|
+
/** The only level accepted by a custom agent. */
|
|
24
|
+
export type WorkflowAccessAgentLevel = "call";
|
|
25
|
+
|
|
26
|
+
/** Every level accepted by at least one workflow access resource. */
|
|
27
|
+
export type WorkflowAccessLevel = WorkflowAccessResourceLevel | WorkflowAccessAgentLevel;
|
|
28
|
+
|
|
29
|
+
/** The level set accepted by a particular resource kind. */
|
|
30
|
+
export type WorkflowAccessLevelFor<TType extends WorkflowAccessResourceType> =
|
|
31
|
+
TType extends "customAgent" ? WorkflowAccessAgentLevel : WorkflowAccessResourceLevel;
|
|
32
|
+
|
|
33
|
+
type AccessResourceFor<TType extends WorkflowAccessResourceType> = Extract<
|
|
34
|
+
WorkflowAccessResource,
|
|
35
|
+
{ resourceType: TType }
|
|
36
|
+
>;
|
|
37
|
+
|
|
38
|
+
/** A named binding declaration pairing one NaC handle with its allowed level. */
|
|
39
|
+
export type WorkflowAccessDeclaration = {
|
|
40
|
+
[TType in WorkflowAccessResourceType]: {
|
|
41
|
+
readonly resource: AccessResourceFor<TType>;
|
|
42
|
+
readonly level: WorkflowAccessLevelFor<TType>;
|
|
43
|
+
};
|
|
44
|
+
}[WorkflowAccessResourceType];
|
|
45
|
+
|
|
46
|
+
/** Named binding declarations keyed by the names exposed on workflow context. */
|
|
47
|
+
export type WorkflowAccessDeclarations = Readonly<Record<string, WorkflowAccessDeclaration>>;
|
|
48
|
+
|
|
49
|
+
/** A normalized named-binding requirement, including the required NaC resourceId. */
|
|
50
|
+
export type WorkflowAccessRequirement = {
|
|
51
|
+
[TType in WorkflowAccessResourceType]: {
|
|
52
|
+
readonly type: TType;
|
|
53
|
+
readonly resourceId: string;
|
|
54
|
+
readonly level: WorkflowAccessLevelFor<TType>;
|
|
55
|
+
};
|
|
56
|
+
}[WorkflowAccessResourceType];
|
|
57
|
+
|
|
58
|
+
/** Normalized named-binding requirements keyed by public alias. */
|
|
59
|
+
export type WorkflowAccessRequirements = Readonly<Record<string, WorkflowAccessRequirement>>;
|
|
60
|
+
|
|
61
|
+
/** The resolved NaC binding exposed to a workflow handler. */
|
|
62
|
+
export type WorkflowAccessValue<
|
|
63
|
+
TType extends WorkflowAccessResourceType = WorkflowAccessResourceType,
|
|
64
|
+
> = Readonly<{
|
|
65
|
+
type: TType;
|
|
66
|
+
id: string;
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
/** Resolved access values keyed by their public aliases. */
|
|
70
|
+
export type WorkflowAccess<
|
|
71
|
+
TAccess extends WorkflowAccessDeclarations = WorkflowAccessDeclarations,
|
|
72
|
+
> = Readonly<{
|
|
73
|
+
[TKey in keyof TAccess]: TAccess[TKey] extends {
|
|
74
|
+
readonly resource: { readonly resourceType: infer TType };
|
|
75
|
+
}
|
|
76
|
+
? TType extends WorkflowAccessResourceType
|
|
77
|
+
? WorkflowAccessValue<TType>
|
|
78
|
+
: never
|
|
79
|
+
: never;
|
|
80
|
+
}>;
|
|
81
|
+
|
|
82
|
+
/** Resolved NaC bindings keyed by public aliases, without declaration metadata. */
|
|
83
|
+
export type WorkflowAccessBindings = Readonly<Record<string, WorkflowAccessValue>>;
|
|
84
|
+
|
|
85
|
+
const ACCESS_ALIAS_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/;
|
|
86
|
+
const RESERVED_ACCESS_ALIASES = new Set<string>(["__proto__", "prototype", "constructor"]);
|
|
87
|
+
const RESOURCE_TYPES: Record<WorkflowAccessResourceType, true> = {
|
|
88
|
+
page: true,
|
|
89
|
+
database: true,
|
|
90
|
+
dataSource: true,
|
|
91
|
+
customAgent: true,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normalize public binding declarations without retaining the handle objects.
|
|
96
|
+
* Handles contain methods and schema objects, so they must never be cloned into
|
|
97
|
+
* the manifest or captured by the runtime transport. These declarations do not
|
|
98
|
+
* grant or change the workflow's existing Notion-module permissions.
|
|
99
|
+
*/
|
|
100
|
+
export function normalizeWorkflowAccess(
|
|
101
|
+
declarations: WorkflowAccessDeclarations | undefined,
|
|
102
|
+
): WorkflowAccessRequirements {
|
|
103
|
+
const source = declarations ?? {};
|
|
104
|
+
assertPlainObject(source, "Workflow access must be an object keyed by alias.");
|
|
105
|
+
const keys = Object.keys(source);
|
|
106
|
+
if (Reflect.ownKeys(source).length !== keys.length) {
|
|
107
|
+
throw new Error("Workflow access aliases must be enumerable strings.");
|
|
108
|
+
}
|
|
109
|
+
if (keys.length > 100) {
|
|
110
|
+
throw new Error("Workflows support at most 100 access requirements.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const normalized: Record<string, WorkflowAccessRequirement> = Object.create(null) as Record<
|
|
114
|
+
string,
|
|
115
|
+
WorkflowAccessRequirement
|
|
116
|
+
>;
|
|
117
|
+
for (const alias of keys) {
|
|
118
|
+
if (!ACCESS_ALIAS_PATTERN.test(alias) || RESERVED_ACCESS_ALIASES.has(alias)) {
|
|
119
|
+
throw new Error(`Invalid workflow access alias: ${alias}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const declaration: unknown = source[alias];
|
|
123
|
+
if (
|
|
124
|
+
!isRecord(declaration) ||
|
|
125
|
+
!Object.hasOwn(declaration, "resource") ||
|
|
126
|
+
!Object.hasOwn(declaration, "level")
|
|
127
|
+
) {
|
|
128
|
+
throw new Error(`Workflow access "${alias}" must declare a resource and level.`);
|
|
129
|
+
}
|
|
130
|
+
const resource: unknown = declaration.resource;
|
|
131
|
+
if (
|
|
132
|
+
!isRecord(resource) ||
|
|
133
|
+
!Object.hasOwn(resource, "resourceType") ||
|
|
134
|
+
!Object.hasOwn(resource, "resourceId") ||
|
|
135
|
+
typeof resource.resourceId !== "string" ||
|
|
136
|
+
resource.resourceId.length === 0 ||
|
|
137
|
+
!isWorkflowAccessResourceType(resource.resourceType)
|
|
138
|
+
) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Workflow access "${alias}" must reference a supported NaC resource handle.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let requirement: WorkflowAccessRequirement;
|
|
145
|
+
if (resource.resourceType === "customAgent") {
|
|
146
|
+
if (declaration.level !== "call") {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Workflow access "${alias}" custom agents only support level "call".`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
requirement = {
|
|
152
|
+
type: resource.resourceType,
|
|
153
|
+
resourceId: resource.resourceId,
|
|
154
|
+
level: declaration.level,
|
|
155
|
+
};
|
|
156
|
+
} else {
|
|
157
|
+
if (!isWorkflowAccessResourceLevel(declaration.level)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Workflow access "${alias}" ${resource.resourceType} level must be view, comment, edit, or fullAccess.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
requirement = {
|
|
163
|
+
type: resource.resourceType,
|
|
164
|
+
resourceId: resource.resourceId,
|
|
165
|
+
level: declaration.level,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
normalized[alias] = Object.freeze(requirement);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return Object.freeze(normalized);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve trusted per-invocation NaC bindings into the minimal context value.
|
|
176
|
+
* This exposes only declared aliases and does not determine Notion API
|
|
177
|
+
* authorization; the workflow's existing Notion-module permissions do that.
|
|
178
|
+
* The environment is deliberately not parsed when no binding is required.
|
|
179
|
+
*/
|
|
180
|
+
export function hydrateWorkflowAccess<TAccess extends WorkflowAccessDeclarations>(
|
|
181
|
+
requirements: WorkflowAccessRequirements | undefined,
|
|
182
|
+
): WorkflowAccess<TAccess>;
|
|
183
|
+
export function hydrateWorkflowAccess(
|
|
184
|
+
requirements: WorkflowAccessRequirements | undefined,
|
|
185
|
+
): WorkflowAccessBindings {
|
|
186
|
+
const expected = requirements ?? {};
|
|
187
|
+
const aliases = Object.keys(expected);
|
|
188
|
+
const empty: Record<string, WorkflowAccessValue> = Object.create(null) as Record<
|
|
189
|
+
string,
|
|
190
|
+
WorkflowAccessValue
|
|
191
|
+
>;
|
|
192
|
+
if (aliases.length === 0) {
|
|
193
|
+
return Object.freeze(empty);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const rawBindings = process.env.NOTION_ACCESS_BINDINGS;
|
|
197
|
+
if (rawBindings === undefined) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
"Workflow access requirements are not bound. Deploy and configure this requirement before running it.",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (rawBindings.length === 0) {
|
|
203
|
+
throw new Error("Invalid NOTION_ACCESS_BINDINGS metadata.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let parsed: unknown;
|
|
207
|
+
try {
|
|
208
|
+
parsed = JSON.parse(rawBindings);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new Error("Invalid NOTION_ACCESS_BINDINGS metadata.", { cause: error });
|
|
211
|
+
}
|
|
212
|
+
assertPlainObject(parsed, "Invalid NOTION_ACCESS_BINDINGS metadata.");
|
|
213
|
+
const bindingKeys = Object.keys(parsed);
|
|
214
|
+
if (
|
|
215
|
+
Reflect.ownKeys(parsed).length !== bindingKeys.length ||
|
|
216
|
+
bindingKeys.length !== aliases.length ||
|
|
217
|
+
bindingKeys.some((alias) => !Object.hasOwn(expected, alias))
|
|
218
|
+
) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
"Invalid NOTION_ACCESS_BINDINGS metadata: aliases do not match workflow access.",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const resolved: Record<string, WorkflowAccessValue> = Object.create(null) as Record<
|
|
225
|
+
string,
|
|
226
|
+
WorkflowAccessValue
|
|
227
|
+
>;
|
|
228
|
+
for (const alias of aliases) {
|
|
229
|
+
const requirement = expected[alias];
|
|
230
|
+
if (!requirement) {
|
|
231
|
+
throw new Error(`Workflow access "${alias}" is not declared.`);
|
|
232
|
+
}
|
|
233
|
+
const binding: unknown = parsed[alias];
|
|
234
|
+
if (!isBinding(binding)) {
|
|
235
|
+
throw new Error(`Invalid NOTION_ACCESS_BINDINGS metadata for access "${alias}".`);
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
binding.type !== requirement.type ||
|
|
239
|
+
binding.resourceId !== requirement.resourceId ||
|
|
240
|
+
binding.level !== requirement.level
|
|
241
|
+
) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Workflow access "${alias}" binding does not match its declared type, resourceId, or level.`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
resolved[alias] = Object.freeze({ type: binding.type, id: binding.id });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return Object.freeze(resolved);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isWorkflowAccessResourceType(value: unknown): value is WorkflowAccessResourceType {
|
|
253
|
+
return typeof value === "string" && Object.hasOwn(RESOURCE_TYPES, value);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isWorkflowAccessResourceLevel(value: unknown): value is WorkflowAccessResourceLevel {
|
|
257
|
+
return value === "view" || value === "comment" || value === "edit" || value === "fullAccess";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isBinding(value: unknown): value is {
|
|
261
|
+
type: WorkflowAccessResourceType;
|
|
262
|
+
resourceId: string;
|
|
263
|
+
level: WorkflowAccessResourceLevel | WorkflowAccessAgentLevel;
|
|
264
|
+
id: string;
|
|
265
|
+
} {
|
|
266
|
+
if (!isRecord(value) || Reflect.ownKeys(value).length !== 4) return false;
|
|
267
|
+
const keys = Object.keys(value);
|
|
268
|
+
if (
|
|
269
|
+
keys.length !== 4 ||
|
|
270
|
+
!keys.includes("type") ||
|
|
271
|
+
!keys.includes("resourceId") ||
|
|
272
|
+
!keys.includes("level") ||
|
|
273
|
+
!keys.includes("id")
|
|
274
|
+
) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
return (
|
|
278
|
+
isWorkflowAccessResourceType(value.type) &&
|
|
279
|
+
typeof value.resourceId === "string" &&
|
|
280
|
+
value.resourceId.length > 0 &&
|
|
281
|
+
typeof value.level === "string" &&
|
|
282
|
+
(isWorkflowAccessResourceLevel(value.level) || value.level === "call") &&
|
|
283
|
+
typeof value.id === "string" &&
|
|
284
|
+
value.id.length > 0
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function assertPlainObject(
|
|
289
|
+
value: unknown,
|
|
290
|
+
message: string,
|
|
291
|
+
): asserts value is Record<string, unknown> {
|
|
292
|
+
if (!isRecord(value)) throw new Error(message);
|
|
293
|
+
const prototype = Object.getPrototypeOf(value);
|
|
294
|
+
if (prototype !== Object.prototype && prototype !== null) throw new Error(message);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
298
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
299
|
+
}
|
package/src/workflow.test.ts
CHANGED
|
@@ -8,6 +8,9 @@ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi, type Moc
|
|
|
8
8
|
import { ExecutionError } from "./error.js";
|
|
9
9
|
import type { RunMetadata } from "./runtime-metadata.js";
|
|
10
10
|
import { triggers, type NotionPageCreatedEvent } from "./triggers.generated.js";
|
|
11
|
+
import { customAgent } from "./notion-as-code/custom-agent.js";
|
|
12
|
+
import { database } from "./notion-as-code/database.js";
|
|
13
|
+
import { page } from "./notion-as-code/page.js";
|
|
11
14
|
import {
|
|
12
15
|
connections,
|
|
13
16
|
workflow,
|
|
@@ -22,6 +25,7 @@ let metadataDirectory: string | undefined;
|
|
|
22
25
|
|
|
23
26
|
beforeEach(async () => {
|
|
24
27
|
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
28
|
+
delete process.env.NOTION_ACCESS_BINDINGS;
|
|
25
29
|
delete process.env.NOTION_WORKFLOW_STEP_DIRECTORY;
|
|
26
30
|
process.env.NOTION_WORKFLOW_CAPABILITY_KEY = "workflow-key";
|
|
27
31
|
checkpointDirectory = undefined;
|
|
@@ -31,6 +35,7 @@ beforeEach(async () => {
|
|
|
31
35
|
|
|
32
36
|
afterEach(async () => {
|
|
33
37
|
stdoutSpy.mockRestore();
|
|
38
|
+
delete process.env.NOTION_ACCESS_BINDINGS;
|
|
34
39
|
delete process.env.NOTION_WORKFLOW_STEP_DIRECTORY;
|
|
35
40
|
delete process.env.NOTION_WORKFLOW_CAPABILITY_KEY;
|
|
36
41
|
delete process.env.NOTION_INPUT_PAYLOAD_PATH;
|
|
@@ -1190,6 +1195,113 @@ describe("workflow", () => {
|
|
|
1190
1195
|
),
|
|
1191
1196
|
);
|
|
1192
1197
|
});
|
|
1198
|
+
it("normalizes NaC access and hydrates all supported resource kinds", async () => {
|
|
1199
|
+
const home = page({ resourceId: "home" });
|
|
1200
|
+
const table = database("table", {
|
|
1201
|
+
dataSourceResourceId: "table-source",
|
|
1202
|
+
name: "Table",
|
|
1203
|
+
schema: { Title: { resourceId: "title", type: "title" } },
|
|
1204
|
+
});
|
|
1205
|
+
const agent = customAgent({ resourceId: "agent", name: "Agent" });
|
|
1206
|
+
let resolvedAccess:
|
|
1207
|
+
| {
|
|
1208
|
+
readonly home: { readonly type: "page"; readonly id: string };
|
|
1209
|
+
readonly table: { readonly type: "database"; readonly id: string };
|
|
1210
|
+
readonly source: { readonly type: "dataSource"; readonly id: string };
|
|
1211
|
+
readonly agent: { readonly type: "customAgent"; readonly id: string };
|
|
1212
|
+
}
|
|
1213
|
+
| undefined;
|
|
1214
|
+
const definition = workflow({
|
|
1215
|
+
name: "Access",
|
|
1216
|
+
description: "Hydrates access",
|
|
1217
|
+
triggers: [triggers.notionPageCreated()],
|
|
1218
|
+
access: {
|
|
1219
|
+
home: { resource: home, level: "view" },
|
|
1220
|
+
table: { resource: table, level: "edit" },
|
|
1221
|
+
source: { resource: table.dataSource, level: "comment" },
|
|
1222
|
+
agent: { resource: agent, level: "call" },
|
|
1223
|
+
},
|
|
1224
|
+
handler: (_event, context) => {
|
|
1225
|
+
expectTypeOf(context.access.home.type).toEqualTypeOf<"page">();
|
|
1226
|
+
expectTypeOf(context.access.table.type).toEqualTypeOf<"database">();
|
|
1227
|
+
expectTypeOf(context.access.source.type).toEqualTypeOf<"dataSource">();
|
|
1228
|
+
expectTypeOf(context.access.agent.type).toEqualTypeOf<"customAgent">();
|
|
1229
|
+
resolvedAccess = context.access;
|
|
1230
|
+
},
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
expect(definition.config.access).toEqual({
|
|
1234
|
+
home: { type: "page", resourceId: "home", level: "view" },
|
|
1235
|
+
table: { type: "database", resourceId: "table", level: "edit" },
|
|
1236
|
+
source: { type: "dataSource", resourceId: "table-source", level: "comment" },
|
|
1237
|
+
agent: { type: "customAgent", resourceId: "agent", level: "call" },
|
|
1238
|
+
});
|
|
1239
|
+
process.env.NOTION_ACCESS_BINDINGS = JSON.stringify({
|
|
1240
|
+
home: { type: "page", resourceId: "home", level: "view", id: "page-id" },
|
|
1241
|
+
table: { type: "database", resourceId: "table", level: "edit", id: "database-id" },
|
|
1242
|
+
source: {
|
|
1243
|
+
type: "dataSource",
|
|
1244
|
+
resourceId: "table-source",
|
|
1245
|
+
level: "comment",
|
|
1246
|
+
id: "data-source-id",
|
|
1247
|
+
},
|
|
1248
|
+
agent: { type: "customAgent", resourceId: "agent", level: "call", id: "agent-id" },
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
await definition.handler(event, { concreteOutput: true });
|
|
1252
|
+
|
|
1253
|
+
expect(resolvedAccess).toEqual({
|
|
1254
|
+
home: { type: "page", id: "page-id" },
|
|
1255
|
+
table: { type: "database", id: "database-id" },
|
|
1256
|
+
source: { type: "dataSource", id: "data-source-id" },
|
|
1257
|
+
agent: { type: "customAgent", id: "agent-id" },
|
|
1258
|
+
});
|
|
1259
|
+
expect(Object.isFrozen(resolvedAccess)).toBe(true);
|
|
1260
|
+
expect(Object.isFrozen(resolvedAccess?.home)).toBe(true);
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
it("rejects missing or mismatched access bindings before the handler", async () => {
|
|
1264
|
+
const handler = vi.fn();
|
|
1265
|
+
const definition = workflow({
|
|
1266
|
+
name: "Access",
|
|
1267
|
+
description: "Requires access",
|
|
1268
|
+
triggers: [triggers.notionPageCreated()],
|
|
1269
|
+
access: { home: { resource: page({ resourceId: "home" }), level: "view" } },
|
|
1270
|
+
handler,
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
await expect(definition.handler(event, { concreteOutput: true })).rejects.toThrow(
|
|
1274
|
+
"not bound",
|
|
1275
|
+
);
|
|
1276
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1277
|
+
|
|
1278
|
+
process.env.NOTION_ACCESS_BINDINGS = JSON.stringify({
|
|
1279
|
+
home: { type: "page", resourceId: "other", level: "view", id: "page-id" },
|
|
1280
|
+
});
|
|
1281
|
+
await expect(definition.handler(event, { concreteOutput: true })).rejects.toThrow(
|
|
1282
|
+
"does not match",
|
|
1283
|
+
);
|
|
1284
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it("does not parse unused access metadata when no access is declared", async () => {
|
|
1288
|
+
process.env.NOTION_ACCESS_BINDINGS = "{malformed";
|
|
1289
|
+
let contextAccess: unknown;
|
|
1290
|
+
const definition = workflow({
|
|
1291
|
+
name: "No Access",
|
|
1292
|
+
description: "Runs without access",
|
|
1293
|
+
triggers: [triggers.notionPageCreated()],
|
|
1294
|
+
handler: (_event, context) => {
|
|
1295
|
+
contextAccess = context.access;
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
await expect(definition.handler(event, { concreteOutput: true })).resolves.toEqual({
|
|
1300
|
+
status: "success",
|
|
1301
|
+
});
|
|
1302
|
+
expect(contextAccess).toEqual({});
|
|
1303
|
+
expect(Object.isFrozen(contextAccess)).toBe(true);
|
|
1304
|
+
});
|
|
1193
1305
|
});
|
|
1194
1306
|
|
|
1195
1307
|
describe("connection-bound triggers", () => {
|
package/src/workflow.ts
CHANGED
|
@@ -6,6 +6,13 @@ import {
|
|
|
6
6
|
type WorkflowConnectionRequirement,
|
|
7
7
|
type WorkflowConnections,
|
|
8
8
|
} from "./connections.js";
|
|
9
|
+
import {
|
|
10
|
+
hydrateWorkflowAccess,
|
|
11
|
+
normalizeWorkflowAccess,
|
|
12
|
+
type WorkflowAccess,
|
|
13
|
+
type WorkflowAccessDeclarations,
|
|
14
|
+
type WorkflowAccessRequirements,
|
|
15
|
+
} from "./workflow-access.js";
|
|
9
16
|
import { createHash } from "node:crypto";
|
|
10
17
|
import { readFile } from "node:fs/promises";
|
|
11
18
|
import { join } from "node:path";
|
|
@@ -69,6 +76,21 @@ type WorkflowHandlerResult = { status: "success" } | WorkflowWaitUntilResult;
|
|
|
69
76
|
|
|
70
77
|
const MAX_WORKFLOW_WAIT_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
71
78
|
|
|
79
|
+
export type {
|
|
80
|
+
WorkflowAccess,
|
|
81
|
+
WorkflowAccessAgentLevel,
|
|
82
|
+
WorkflowAccessBindings,
|
|
83
|
+
WorkflowAccessDeclaration,
|
|
84
|
+
WorkflowAccessDeclarations,
|
|
85
|
+
WorkflowAccessLevel,
|
|
86
|
+
WorkflowAccessLevelFor,
|
|
87
|
+
WorkflowAccessResource,
|
|
88
|
+
WorkflowAccessResourceLevel,
|
|
89
|
+
WorkflowAccessResourceType,
|
|
90
|
+
WorkflowAccessRequirement,
|
|
91
|
+
WorkflowAccessRequirements,
|
|
92
|
+
WorkflowAccessValue,
|
|
93
|
+
} from "./workflow-access.js";
|
|
72
94
|
export type WorkflowEvent = WorkflowEventMap[keyof WorkflowEventMap];
|
|
73
95
|
|
|
74
96
|
export type WorkflowEventForTrigger<T extends WorkflowTrigger> = WorkflowEventMap[T["type"]];
|
|
@@ -92,6 +114,7 @@ export {
|
|
|
92
114
|
export type WorkflowConfiguration<
|
|
93
115
|
TTriggers extends readonly [WorkflowTrigger, ...WorkflowTrigger[]],
|
|
94
116
|
TConnections extends WorkflowConnectionDeclarations = WorkflowConnectionDeclarations,
|
|
117
|
+
TAccess extends WorkflowAccessDeclarations = WorkflowAccessDeclarations,
|
|
95
118
|
> = {
|
|
96
119
|
/**
|
|
97
120
|
* A human-readable name for the workflow, shown in the UI when viewing workflows
|
|
@@ -110,6 +133,13 @@ export type WorkflowConfiguration<
|
|
|
110
133
|
*/
|
|
111
134
|
triggers: TTriggers;
|
|
112
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Notion-as-Code resources whose access is configured during cloud deployment.
|
|
138
|
+
* Deployment reconciles grants through the existing Notion-module permissions
|
|
139
|
+
* and supplies named runtime bindings on `context.access`.
|
|
140
|
+
*/
|
|
141
|
+
access?: TAccess;
|
|
142
|
+
|
|
113
143
|
/**
|
|
114
144
|
* Connections that must be set up before this workflow can run.
|
|
115
145
|
*/
|
|
@@ -117,7 +147,7 @@ export type WorkflowConfiguration<
|
|
|
117
147
|
|
|
118
148
|
handler: (
|
|
119
149
|
event: WorkflowEventForTriggers<TTriggers>,
|
|
120
|
-
context: WorkflowContext<TConnections>,
|
|
150
|
+
context: WorkflowContext<TConnections, TAccess>,
|
|
121
151
|
) => Promise<void> | void;
|
|
122
152
|
};
|
|
123
153
|
|
|
@@ -125,7 +155,8 @@ export type WorkflowConfiguration<
|
|
|
125
155
|
export type WorkflowTriggerConfiguration<
|
|
126
156
|
TTriggers extends readonly [WorkflowTrigger, ...WorkflowTrigger[]],
|
|
127
157
|
TConnections extends WorkflowConnectionDeclarations,
|
|
128
|
-
|
|
158
|
+
TAccess extends WorkflowAccessDeclarations = WorkflowAccessDeclarations,
|
|
159
|
+
> = Omit<WorkflowConfiguration<TTriggers, TConnections, TAccess>, "triggers"> & {
|
|
129
160
|
triggers: (context: { triggers: WorkflowTriggerCreators<NoInfer<TConnections>> }) => TTriggers;
|
|
130
161
|
};
|
|
131
162
|
|
|
@@ -147,6 +178,7 @@ export type Workflow<
|
|
|
147
178
|
description: string;
|
|
148
179
|
triggers: TTriggers;
|
|
149
180
|
connections?: readonly WorkflowConnectionRequirement[];
|
|
181
|
+
access?: WorkflowAccessRequirements;
|
|
150
182
|
};
|
|
151
183
|
handler: (
|
|
152
184
|
event: WorkflowEventForTriggers<TTriggers>,
|
|
@@ -186,20 +218,26 @@ export function workflow<
|
|
|
186
218
|
...WorkflowTriggerForConnections<NoInfer<TConnections>>[],
|
|
187
219
|
],
|
|
188
220
|
const TConnections extends WorkflowConnectionDeclarations = Record<never, never>,
|
|
189
|
-
|
|
221
|
+
const TAccess extends WorkflowAccessDeclarations = Record<never, never>,
|
|
222
|
+
>(
|
|
223
|
+
configuration: WorkflowTriggerConfiguration<TTriggers, TConnections, TAccess>,
|
|
224
|
+
): Workflow<TTriggers>;
|
|
190
225
|
export function workflow<
|
|
191
226
|
const TTriggers extends readonly [WorkflowTrigger, ...WorkflowTrigger[]],
|
|
192
227
|
const TConnections extends WorkflowConnectionDeclarations = Record<never, never>,
|
|
193
|
-
|
|
228
|
+
const TAccess extends WorkflowAccessDeclarations = Record<never, never>,
|
|
229
|
+
>(configuration: WorkflowConfiguration<TTriggers, TConnections, TAccess>): Workflow<TTriggers>;
|
|
194
230
|
export function workflow<
|
|
195
231
|
const TTriggers extends readonly [WorkflowTrigger, ...WorkflowTrigger[]],
|
|
196
232
|
const TConnections extends WorkflowConnectionDeclarations = Record<never, never>,
|
|
233
|
+
const TAccess extends WorkflowAccessDeclarations = Record<never, never>,
|
|
197
234
|
>(
|
|
198
235
|
configuration:
|
|
199
|
-
| WorkflowConfiguration<TTriggers, TConnections>
|
|
200
|
-
| WorkflowTriggerConfiguration<TTriggers, TConnections>,
|
|
236
|
+
| WorkflowConfiguration<TTriggers, TConnections, TAccess>
|
|
237
|
+
| WorkflowTriggerConfiguration<TTriggers, TConnections, TAccess>,
|
|
201
238
|
): Workflow<TTriggers> {
|
|
202
239
|
const requirements = normalizeConnectionRequirements(configuration.connections ?? {});
|
|
240
|
+
const accessRequirements = normalizeWorkflowAccess(configuration.access);
|
|
203
241
|
const connectionDeclarations =
|
|
204
242
|
configuration.connections === undefined
|
|
205
243
|
? undefined
|
|
@@ -217,6 +255,7 @@ export function workflow<
|
|
|
217
255
|
description: configuration.description,
|
|
218
256
|
triggers,
|
|
219
257
|
...(configuration.connections === undefined ? {} : { connections: requirements }),
|
|
258
|
+
...(configuration.access === undefined ? {} : { access: accessRequirements }),
|
|
220
259
|
},
|
|
221
260
|
async handler(
|
|
222
261
|
event: WorkflowEventForTriggers<TTriggers>,
|
|
@@ -227,8 +266,9 @@ export function workflow<
|
|
|
227
266
|
const runMetadata = readRunMetadata();
|
|
228
267
|
const baseContext = createCapabilityContext();
|
|
229
268
|
const step = createStep(runMetadata);
|
|
230
|
-
const capabilityContext: WorkflowContext<TConnections> = {
|
|
269
|
+
const capabilityContext: WorkflowContext<TConnections, TAccess> = {
|
|
231
270
|
...baseContext,
|
|
271
|
+
access: hydrateWorkflowAccess<TAccess>(accessRequirements),
|
|
232
272
|
connections: createWorkflowConnections(
|
|
233
273
|
baseContext.notion,
|
|
234
274
|
connectionDeclarations,
|
|
@@ -324,7 +364,10 @@ type WorkflowWait = {
|
|
|
324
364
|
/** Context passed to a workflow handler. */
|
|
325
365
|
export type WorkflowContext<
|
|
326
366
|
TConnections extends WorkflowConnectionDeclarations = WorkflowConnectionDeclarations,
|
|
367
|
+
TAccess extends WorkflowAccessDeclarations = WorkflowAccessDeclarations,
|
|
327
368
|
> = CapabilityContext & {
|
|
369
|
+
/** Resolved NaC bindings for this invocation; UI-granted resources are not added here. */
|
|
370
|
+
readonly access: WorkflowAccess<TAccess>;
|
|
328
371
|
connections: WorkflowConnections<TConnections>;
|
|
329
372
|
} & RunMetadata & {
|
|
330
373
|
/**
|