@notionhq/apps 0.0.18 → 0.0.19
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 +17 -0
- 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/page.d.ts +1 -0
- package/dist/notion-as-code/page.d.ts.map +1 -1
- package/dist/notion-as-code/page.js +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 +8 -0
- package/skills/workflow/SKILL.md +50 -15
- 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/page.ts +2 -0
- 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
package/dist/workflow.js
CHANGED
|
@@ -3,6 +3,10 @@ import {
|
|
|
3
3
|
normalizeConnectionRequirements,
|
|
4
4
|
validateTriggerConnections
|
|
5
5
|
} from "./connections.js";
|
|
6
|
+
import {
|
|
7
|
+
hydrateWorkflowAccess,
|
|
8
|
+
normalizeWorkflowAccess
|
|
9
|
+
} from "./workflow-access.js";
|
|
6
10
|
import { createHash } from "node:crypto";
|
|
7
11
|
import { readFile } from "node:fs/promises";
|
|
8
12
|
import { join } from "node:path";
|
|
@@ -19,6 +23,7 @@ import {
|
|
|
19
23
|
} from "./connections.js";
|
|
20
24
|
function workflow(configuration) {
|
|
21
25
|
const requirements = normalizeConnectionRequirements(configuration.connections ?? {});
|
|
26
|
+
const accessRequirements = normalizeWorkflowAccess(configuration.access);
|
|
22
27
|
const connectionDeclarations = configuration.connections === void 0 ? void 0 : structuredClone(configuration.connections);
|
|
23
28
|
const triggers = typeof configuration.triggers === "function" ? configuration.triggers({ triggers: createWorkflowTriggers() }) : configuration.triggers;
|
|
24
29
|
validateTriggerConnections(triggers, requirements);
|
|
@@ -28,7 +33,8 @@ function workflow(configuration) {
|
|
|
28
33
|
name: configuration.name,
|
|
29
34
|
description: configuration.description,
|
|
30
35
|
triggers,
|
|
31
|
-
...configuration.connections === void 0 ? {} : { connections: requirements }
|
|
36
|
+
...configuration.connections === void 0 ? {} : { connections: requirements },
|
|
37
|
+
...configuration.access === void 0 ? {} : { access: accessRequirements }
|
|
32
38
|
},
|
|
33
39
|
async handler(event, options) {
|
|
34
40
|
try {
|
|
@@ -38,6 +44,7 @@ function workflow(configuration) {
|
|
|
38
44
|
const step = createStep(runMetadata);
|
|
39
45
|
const capabilityContext = {
|
|
40
46
|
...baseContext,
|
|
47
|
+
access: hydrateWorkflowAccess(accessRequirements),
|
|
41
48
|
connections: createWorkflowConnections(
|
|
42
49
|
baseContext.notion,
|
|
43
50
|
connectionDeclarations
|
package/docs/BUILD.md
CHANGED
|
@@ -43,10 +43,11 @@ natural capability owner.
|
|
|
43
43
|
1. Discover top-level files in the capability directories.
|
|
44
44
|
2. Generate `.notion/entry.ts` with workflow and sync imports and a dispatcher.
|
|
45
45
|
3. Bundle the app's code into `dist/worker.js`, leaving npm packages external.
|
|
46
|
-
4. Evaluate custom-block declarations separately
|
|
47
|
-
configuration, and write `dist/manifest.json`. Blocks do not enter `worker.js`.
|
|
46
|
+
4. Evaluate custom-block declarations separately. Blocks do not enter `worker.js`.
|
|
48
47
|
5. Evaluate worker capabilities in a separate build-only metadata bundle to record
|
|
49
|
-
Notion-as-Code declarations
|
|
48
|
+
Notion-as-Code declarations. Validate workflow access references against those
|
|
49
|
+
declarations before writing the manifest and optional provisioning artifact.
|
|
50
|
+
6. Emit `dist/manifest.json` and, when declarations exist, `dist/provisioning.json`.
|
|
50
51
|
|
|
51
52
|
Capability modules must therefore be importable without secrets or network access.
|
|
52
53
|
Read required environment variables and make requests inside handlers or workflow
|
|
@@ -170,7 +171,8 @@ uploaded project must declare its SDK dependency and a `build` script that invok
|
|
|
170
171
|
Worker using `workersCreateWorker` with `createApp: true`, or updates the existing
|
|
171
172
|
Worker, then uploads source and calls `workersBuildWorker` with the Worker ID.
|
|
172
173
|
For App-linked Workers, the build endpoint coordinates capability registration,
|
|
173
|
-
Notion-as-Code provisioning,
|
|
174
|
+
Notion-as-Code provisioning, database attachment, workflow binding resolution,
|
|
175
|
+
and workflow permission reconciliation before reporting build success.
|
|
174
176
|
It returns the normal build result and run ID, not provisioning state. No separate
|
|
175
177
|
Apps deployment endpoint is required.
|
|
176
178
|
|
|
@@ -193,6 +195,9 @@ flow and local state, and reconciles attachments. It reads the optional
|
|
|
193
195
|
`dist/provisioning.json` locally and does not upload it separately or invoke
|
|
194
196
|
the cloud build endpoint.
|
|
195
197
|
|
|
198
|
+
Workflows with nonempty `access` require cloud deployment. The local-build path
|
|
199
|
+
rejects them after the SDK build and before uploading or deploying the bundle.
|
|
200
|
+
|
|
196
201
|
Resource bindings are not stored in SDK build output. Local deployments retain a
|
|
197
202
|
local state file and report `state_file`; cloud deployments keep installation-owned
|
|
198
203
|
resource mappings and state on the server. Cloud builds do not import local state
|
|
@@ -202,6 +207,35 @@ switching between modes may recreate resources rather than reuse existing bindin
|
|
|
202
207
|
Cloud failures never automatically retry locally. There is no rollback: code or
|
|
203
208
|
resources may already have changed when a deployment fails.
|
|
204
209
|
|
|
210
|
+
## Workflow access
|
|
211
|
+
|
|
212
|
+
`workflow({ access: { handbook: { resource: handbook, level: "view" } }, ... })`
|
|
213
|
+
declares access to a NaC resource and a named runtime binding. The manifest
|
|
214
|
+
contains only the alias and its symbolic `{ type, resourceId, level }`
|
|
215
|
+
requirement; live IDs are not build output.
|
|
216
|
+
Build validation rejects references missing from the current provisioning
|
|
217
|
+
declarations or having the wrong resource kind. `resourceId` is required because
|
|
218
|
+
it identifies the declaration used for this validation and binding.
|
|
219
|
+
|
|
220
|
+
After provisioning, cloud deployment resolves requirements against the successful
|
|
221
|
+
NaC apply result. It reconciles code-managed grants through the existing
|
|
222
|
+
Notion-module permissions and saves those permissions together with resolved
|
|
223
|
+
bindings on the workflow's worker module, keyed by capability and alias, in one
|
|
224
|
+
workflow transaction. Bindings are not authorization: the existing permissions
|
|
225
|
+
authorize `context.notion` calls. Redeployment updates or removes code-managed
|
|
226
|
+
grants as declarations change while preserving manually configured UI grants.
|
|
227
|
+
|
|
228
|
+
Execution reads the selected workflow configuration, not the latest NaC state,
|
|
229
|
+
validates the bindings against the capability requirements, and injects only that
|
|
230
|
+
capability's declared bindings. The SDK exposes typed, readonly `{ type, id }`
|
|
231
|
+
entries in `context.access`; UI-granted resources do not appear there. Use live
|
|
232
|
+
record IDs directly in `context.notion` calls for UI-granted resources; those
|
|
233
|
+
calls remain subject to the existing permissions. Missing or mismatched bindings
|
|
234
|
+
fail before the handler executes.
|
|
235
|
+
|
|
236
|
+
See the [workflow skill](../skills/workflow/SKILL.md#resources-created-with-the-app)
|
|
237
|
+
for supported resource types, access levels, and an authoring example.
|
|
238
|
+
|
|
205
239
|
## Manifest
|
|
206
240
|
|
|
207
241
|
The workflow-only manifest retains the platform's existing resource fields as empty arrays:
|
package/package.json
CHANGED
|
@@ -24,6 +24,14 @@ owned by one capability, use a local module such as
|
|
|
24
24
|
are shared across capabilities or do not have a natural capability owner. Module
|
|
25
25
|
evaluation must work without credentials or network access.
|
|
26
26
|
|
|
27
|
+
To configure a workflow's access to a declared page, database, data source, or
|
|
28
|
+
custom agent, pass its handle in `access: { alias: { resource: handle, level } }`.
|
|
29
|
+
The handler receives the live identity as `context.access.alias.id`; importing
|
|
30
|
+
a declaration alone does not request access. See the
|
|
31
|
+
[workflow skill](../workflow/SKILL.md#resources-created-with-the-app) for levels
|
|
32
|
+
and examples. Nonempty workflow `access` requires cloud deployment; `--local-build`
|
|
33
|
+
rejects it before upload.
|
|
34
|
+
|
|
27
35
|
## Where declarations are picked up
|
|
28
36
|
|
|
29
37
|
The Apps build discovers direct `.ts` children of `src/workflows/` and
|
package/skills/workflow/SKILL.md
CHANGED
|
@@ -110,8 +110,8 @@ configuration, and do not log secrets or private payloads.
|
|
|
110
110
|
|
|
111
111
|
## Resources created with the App
|
|
112
112
|
|
|
113
|
-
Use [Notion as Code](../notion-as-code/SKILL.md) for pages and
|
|
114
|
-
should be created during deployment. Prefer it over equivalent manual setup
|
|
113
|
+
Use [Notion as Code](../notion-as-code/SKILL.md) for pages, databases, and custom agents
|
|
114
|
+
that should be created during deployment. Prefer it over equivalent manual setup
|
|
115
115
|
or API calls that create the App's resources. Use runtime API calls for dynamic
|
|
116
116
|
data changes, not as a substitute for supported Notion as Code setup.
|
|
117
117
|
Keep declarations at module scope. A small workflow-specific resource can be
|
|
@@ -127,32 +127,67 @@ export const guide = page({
|
|
|
127
127
|
});
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
Import the
|
|
130
|
+
Import the handle into the workflow and declare the named binding it needs:
|
|
131
131
|
|
|
132
132
|
```ts
|
|
133
133
|
import { workflow } from "@notionhq/apps";
|
|
134
134
|
import { triggers } from "@notionhq/apps/triggers";
|
|
135
135
|
|
|
136
|
-
import "./sayHello/lib/notion.js";
|
|
136
|
+
import { guide } from "./sayHello/lib/notion.js";
|
|
137
137
|
|
|
138
138
|
export default workflow({
|
|
139
|
-
name: "
|
|
140
|
-
description: "
|
|
139
|
+
name: "Read guide",
|
|
140
|
+
description: "Reads the App's guide on a recurring schedule.",
|
|
141
141
|
triggers: [triggers.scheduled()],
|
|
142
|
+
access: { guide: { resource: guide, level: "view" } },
|
|
142
143
|
handler: async (_event, context) => {
|
|
143
|
-
await context.step("
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
await context.step("Read guide", () =>
|
|
145
|
+
context.notion.pages.retrieve({ page_id: context.access.guide.id }),
|
|
146
|
+
);
|
|
146
147
|
},
|
|
147
148
|
});
|
|
148
149
|
```
|
|
149
150
|
|
|
150
|
-
The page is provisioned during deployment, not on every workflow run.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
The page is provisioned during deployment, not on every workflow run. Inline
|
|
152
|
+
declarations in `access` work too. Importing a declaration alone provisions the
|
|
153
|
+
resource but does not create a `context.access` alias. An `access` entry declares
|
|
154
|
+
the needed permission level and a named binding for that NaC resource.
|
|
155
|
+
|
|
156
|
+
`access` accepts page, database, data-source, and custom-agent handles. Pages,
|
|
157
|
+
databases, and data sources support `view`, `comment`, `edit`, and `fullAccess`;
|
|
158
|
+
custom agents support only `call`. Keys are workflow-local aliases. The handler's
|
|
159
|
+
typed `context.access` exposes each declared alias as a readonly `{ type, id }`,
|
|
160
|
+
not the declaration handle or a permission-management API. UI-granted resources
|
|
161
|
+
are permissions only: they do not create `context.access` aliases.
|
|
162
|
+
|
|
163
|
+
For a resource already granted to the workflow in Notion's UI, use its live
|
|
164
|
+
record ID directly in a `context.notion` call:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
await context.step("Read granted page", () =>
|
|
168
|
+
context.notion.pages.retrieve({ page_id: "page-id" }),
|
|
169
|
+
);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Such a UI grant needs no `access` entry, NaC declaration, or NaC `resourceId`.
|
|
173
|
+
The call remains subject to the workflow's existing Notion-module permissions.
|
|
174
|
+
|
|
175
|
+
Use a data-source handle when calling `context.notion.dataSources`: its resolved
|
|
176
|
+
ID is a collection ID. A database handle resolves to the database block ID;
|
|
177
|
+
these identities are not interchangeable. Notion as Code `resourceId` values
|
|
178
|
+
remain declaration identities, not live Notion UUIDs.
|
|
179
|
+
|
|
180
|
+
Cloud deployment resolves declared handles to live IDs and reconciles their
|
|
181
|
+
grants through the existing Notion-module permissions. It saves permissions and
|
|
182
|
+
bindings together; bindings themselves do not authorize calls. Changing `access`
|
|
183
|
+
updates code-managed grants and bindings while preserving manually configured
|
|
184
|
+
UI grants. Normal draft/publish and runtime authorization still apply. Missing
|
|
185
|
+
or mismatched required bindings fail before the handler runs.
|
|
186
|
+
|
|
187
|
+
Deploy workflows with nonempty `access` using `ntn apps deploy`, without
|
|
188
|
+
`--local-build`. Local-build deployment rejects them before upload. Runtime API
|
|
189
|
+
calls still belong in durable steps. For syncs, see the
|
|
190
|
+
[data-source example](../sync/SKILL.md#choose-the-database-source).
|
|
156
191
|
|
|
157
192
|
## Review a workflow
|
|
158
193
|
|
package/src/cli/build.test.ts
CHANGED
|
@@ -204,6 +204,30 @@ describe("buildApp", () => {
|
|
|
204
204
|
/hello.ts.*customBlock/,
|
|
205
205
|
);
|
|
206
206
|
});
|
|
207
|
+
it("emits symbolic access requirements for every NaC resource kind", async () => {
|
|
208
|
+
const { manifest } = await buildApp(fixture("workflow-access"));
|
|
209
|
+
expect(manifest.capabilities).toEqual([
|
|
210
|
+
{
|
|
211
|
+
type: "workflow",
|
|
212
|
+
key: "access",
|
|
213
|
+
config: {
|
|
214
|
+
name: "Access",
|
|
215
|
+
description: "Declares all workflow access resource kinds",
|
|
216
|
+
triggers: [{ type: "notion.page.created" }],
|
|
217
|
+
access: {
|
|
218
|
+
home: { type: "page", resourceId: "home", level: "view" },
|
|
219
|
+
records: { type: "database", resourceId: "records", level: "edit" },
|
|
220
|
+
source: {
|
|
221
|
+
type: "dataSource",
|
|
222
|
+
resourceId: "records-source",
|
|
223
|
+
level: "comment",
|
|
224
|
+
},
|
|
225
|
+
agent: { type: "customAgent", resourceId: "agent", level: "call" },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
207
231
|
|
|
208
232
|
it("builds a sync manifest with a Notion-as-Code meetings database", async () => {
|
|
209
233
|
const { manifest } = await buildApp(example("sync"));
|
package/src/cli/build.ts
CHANGED
|
@@ -54,24 +54,29 @@ export async function buildApp(projectRoot: string): Promise<BuildResult> {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const blockConfigs = await buildBlockDeclarations(projectRoot, capabilities);
|
|
57
|
-
const
|
|
58
|
-
const manifestPath = path.join(projectRoot, "dist", "manifest.json");
|
|
59
|
-
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`);
|
|
60
|
-
|
|
61
|
-
await emitProvisioningArtifact(
|
|
57
|
+
const entityProvisioning = await buildMetadata(
|
|
62
58
|
projectRoot,
|
|
63
59
|
capabilities.filter((capability) => capability.runtime === "worker"),
|
|
64
60
|
);
|
|
61
|
+
const manifest = await extractManifest(
|
|
62
|
+
bundlePath,
|
|
63
|
+
capabilities,
|
|
64
|
+
blockConfigs,
|
|
65
|
+
entityProvisioning,
|
|
66
|
+
);
|
|
67
|
+
const manifestPath = path.join(projectRoot, "dist", "manifest.json");
|
|
68
|
+
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`);
|
|
69
|
+
|
|
70
|
+
await emitProvisioningArtifact(projectRoot, entityProvisioning);
|
|
65
71
|
|
|
66
72
|
return { manifest, bundlePath, manifestPath };
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
/**
|
|
75
|
+
/** Emit the Notion-as-Code artifact recorded by the build-only metadata bundle. */
|
|
70
76
|
async function emitProvisioningArtifact(
|
|
71
77
|
projectRoot: string,
|
|
72
|
-
|
|
78
|
+
entityProvisioning: RecordedProvisioning,
|
|
73
79
|
): Promise<void> {
|
|
74
|
-
const entityProvisioning = await buildMetadata(projectRoot, capabilities);
|
|
75
80
|
const provisioningPath = path.join(projectRoot, "dist", "provisioning.json");
|
|
76
81
|
if (entityProvisioning.intents.length === 0) {
|
|
77
82
|
await fs.promises.rm(provisioningPath, { force: true });
|
package/src/cli/emit-manifest.ts
CHANGED
|
@@ -217,6 +217,7 @@ export async function extractManifest(
|
|
|
217
217
|
bundlePath: string,
|
|
218
218
|
capabilities: readonly DiscoveredCapability[],
|
|
219
219
|
blockConfigs: Record<string, unknown> = {},
|
|
220
|
+
provisioning?: RecordedProvisioning,
|
|
220
221
|
): Promise<AppManifest> {
|
|
221
222
|
clearRegisteredPacers();
|
|
222
223
|
const bundle = await importBundle(bundlePath);
|
|
@@ -267,8 +268,12 @@ export async function extractManifest(
|
|
|
267
268
|
const { database: _database, ...rest } = config;
|
|
268
269
|
config = { ...rest, databaseKey };
|
|
269
270
|
}
|
|
271
|
+
if (capability.type === "workflow") {
|
|
272
|
+
validateWorkflowAccessReferences(config.access, provisioning, capability.sourcePath);
|
|
273
|
+
}
|
|
270
274
|
|
|
271
275
|
assertJsonSafe(config, `${capability.sourcePath} config`);
|
|
276
|
+
|
|
272
277
|
return { type: capability.type, key: capability.key, config };
|
|
273
278
|
});
|
|
274
279
|
|
|
@@ -300,6 +305,108 @@ export async function extractManifest(
|
|
|
300
305
|
return manifest as AppManifest;
|
|
301
306
|
}
|
|
302
307
|
|
|
308
|
+
const WORKFLOW_ACCESS_TYPES: Record<string, true> = {
|
|
309
|
+
page: true,
|
|
310
|
+
database: true,
|
|
311
|
+
dataSource: true,
|
|
312
|
+
customAgent: true,
|
|
313
|
+
};
|
|
314
|
+
const WORKFLOW_ACCESS_LEVELS: Record<string, true> = {
|
|
315
|
+
view: true,
|
|
316
|
+
comment: true,
|
|
317
|
+
edit: true,
|
|
318
|
+
fullAccess: true,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
function validateWorkflowAccessReferences(
|
|
322
|
+
access: unknown,
|
|
323
|
+
provisioning: RecordedProvisioning | undefined,
|
|
324
|
+
capabilityPath: string,
|
|
325
|
+
): void {
|
|
326
|
+
if (access === undefined) return;
|
|
327
|
+
if (
|
|
328
|
+
typeof access !== "object" ||
|
|
329
|
+
access === null ||
|
|
330
|
+
Array.isArray(access) ||
|
|
331
|
+
(provisioning === undefined && Object.keys(access).length > 0)
|
|
332
|
+
) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`${capabilityPath}: workflow access must reference resources recorded by the current build`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (provisioning === undefined) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const accessObject = access as Record<string, unknown>;
|
|
342
|
+
const aliases = Object.keys(accessObject);
|
|
343
|
+
if (Reflect.ownKeys(accessObject).length !== aliases.length) {
|
|
344
|
+
throw new Error(`${capabilityPath}: workflow access aliases must be enumerable strings`);
|
|
345
|
+
}
|
|
346
|
+
for (const alias of aliases) {
|
|
347
|
+
if (
|
|
348
|
+
!/^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/.test(alias) ||
|
|
349
|
+
alias === "__proto__" ||
|
|
350
|
+
alias === "prototype" ||
|
|
351
|
+
alias === "constructor"
|
|
352
|
+
) {
|
|
353
|
+
throw new Error(`${capabilityPath}: invalid workflow access alias "${alias}"`);
|
|
354
|
+
}
|
|
355
|
+
const requirement = accessObject[alias];
|
|
356
|
+
if (
|
|
357
|
+
typeof requirement !== "object" ||
|
|
358
|
+
requirement === null ||
|
|
359
|
+
Array.isArray(requirement) ||
|
|
360
|
+
Reflect.ownKeys(requirement).length !== 3 ||
|
|
361
|
+
!Object.hasOwn(requirement, "type") ||
|
|
362
|
+
!Object.hasOwn(requirement, "resourceId") ||
|
|
363
|
+
!Object.hasOwn(requirement, "level")
|
|
364
|
+
) {
|
|
365
|
+
throw new Error(`${capabilityPath}: invalid workflow access requirement "${alias}"`);
|
|
366
|
+
}
|
|
367
|
+
const requirementObject = requirement as Record<string, unknown>;
|
|
368
|
+
const type = requirementObject.type;
|
|
369
|
+
const resourceId = requirementObject.resourceId;
|
|
370
|
+
const level = requirementObject.level;
|
|
371
|
+
if (
|
|
372
|
+
typeof type !== "string" ||
|
|
373
|
+
!Object.hasOwn(WORKFLOW_ACCESS_TYPES, type) ||
|
|
374
|
+
typeof resourceId !== "string" ||
|
|
375
|
+
resourceId.length === 0 ||
|
|
376
|
+
typeof level !== "string" ||
|
|
377
|
+
(type === "customAgent"
|
|
378
|
+
? level !== "call"
|
|
379
|
+
: !Object.hasOwn(WORKFLOW_ACCESS_LEVELS, level))
|
|
380
|
+
) {
|
|
381
|
+
throw new Error(`${capabilityPath}: invalid workflow access requirement "${alias}"`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (
|
|
385
|
+
!provisioning.intents.some((intent) => {
|
|
386
|
+
switch (type) {
|
|
387
|
+
case "page":
|
|
388
|
+
return intent.type === "page" && intent.resourceId === resourceId;
|
|
389
|
+
case "database":
|
|
390
|
+
return intent.type === "database" && intent.resourceId === resourceId;
|
|
391
|
+
case "customAgent":
|
|
392
|
+
return intent.type === "custom_agent" && intent.resourceId === resourceId;
|
|
393
|
+
case "dataSource":
|
|
394
|
+
return (
|
|
395
|
+
intent.type === "database" &&
|
|
396
|
+
(intent.dataSources ?? []).some(
|
|
397
|
+
(dataSource) => dataSource.resourceId === resourceId,
|
|
398
|
+
)
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`${capabilityPath}: workflow access "${alias}" references ${type} "${resourceId}" that is not declared by the current build`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
303
410
|
/** Import the metadata bundle and return the provisioning declarations it recorded. */
|
|
304
411
|
export async function extractProvisioning(bundlePath: string): Promise<RecordedProvisioning> {
|
|
305
412
|
const bundle = await importBundle(bundlePath);
|
package/src/index.ts
CHANGED
|
@@ -5,5 +5,24 @@ export { page } from "./notion-as-code/page.js";
|
|
|
5
5
|
export { teamspace } from "./notion-as-code/teamspace.js";
|
|
6
6
|
export { view } from "./notion-as-code/view.js";
|
|
7
7
|
export { sync } from "./sync.js";
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
workflow,
|
|
10
|
+
type Workflow,
|
|
11
|
+
type WorkflowAccess,
|
|
12
|
+
type WorkflowAccessAgentLevel,
|
|
13
|
+
type WorkflowAccessBindings,
|
|
14
|
+
type WorkflowAccessDeclaration,
|
|
15
|
+
type WorkflowAccessDeclarations,
|
|
16
|
+
type WorkflowAccessLevel,
|
|
17
|
+
type WorkflowAccessLevelFor,
|
|
18
|
+
type WorkflowAccessResource,
|
|
19
|
+
type WorkflowAccessResourceLevel,
|
|
20
|
+
type WorkflowAccessResourceType,
|
|
21
|
+
type WorkflowAccessRequirement,
|
|
22
|
+
type WorkflowAccessRequirements,
|
|
23
|
+
type WorkflowAccessValue,
|
|
24
|
+
type WorkflowConfiguration,
|
|
25
|
+
type WorkflowContext,
|
|
26
|
+
type WorkflowTriggerConfiguration,
|
|
27
|
+
} from "./workflow.js";
|
|
9
28
|
export type { WorkflowState, WorkflowStateValue } from "./workflow-state.js";
|
|
@@ -14,6 +14,7 @@ export type CustomAgentArgs = {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
export type CustomAgentHandle = {
|
|
17
|
+
readonly resourceType: "customAgent";
|
|
17
18
|
readonly resourceId: string;
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -26,5 +27,5 @@ export function customAgent(args: CustomAgentArgs): CustomAgentHandle {
|
|
|
26
27
|
recordIntent({ type: "custom_agent", ...args } satisfies {
|
|
27
28
|
type: "custom_agent";
|
|
28
29
|
} & CustomAgentIntent);
|
|
29
|
-
return { resourceId: args.resourceId };
|
|
30
|
+
return { resourceType: "customAgent", resourceId: args.resourceId };
|
|
30
31
|
}
|
|
@@ -113,6 +113,7 @@ export type ChildDatabaseArgs<
|
|
|
113
113
|
|
|
114
114
|
/** A source handle retains its keyed authoring schema for sync inference. */
|
|
115
115
|
export type DataSourceHandle<Schema extends NotionAsCodeSchema = NotionAsCodeSchema> = {
|
|
116
|
+
readonly resourceType: "dataSource";
|
|
116
117
|
readonly resourceId: string;
|
|
117
118
|
readonly schema: Schema;
|
|
118
119
|
readonly database: Database<AppsSchemaForSchema<Schema>>;
|
|
@@ -120,6 +121,7 @@ export type DataSourceHandle<Schema extends NotionAsCodeSchema = NotionAsCodeSch
|
|
|
120
121
|
};
|
|
121
122
|
|
|
122
123
|
type DatabaseHandleBase = {
|
|
124
|
+
readonly resourceType: "database";
|
|
123
125
|
readonly resourceId: string;
|
|
124
126
|
addView: (view: ViewSchema) => void;
|
|
125
127
|
};
|
|
@@ -262,6 +264,7 @@ function buildDatabaseHandle(
|
|
|
262
264
|
throw new Error(`Database "${databaseResourceId}" is missing its data source`);
|
|
263
265
|
}
|
|
264
266
|
return {
|
|
267
|
+
resourceType: "database",
|
|
265
268
|
resourceId: databaseResourceId,
|
|
266
269
|
dataSource,
|
|
267
270
|
addView: handle.addView,
|
|
@@ -396,6 +399,7 @@ function createDatabase(
|
|
|
396
399
|
recordIntent({ ...intent, type: "database" });
|
|
397
400
|
|
|
398
401
|
return {
|
|
402
|
+
resourceType: "database",
|
|
399
403
|
resourceId: intent.resourceId,
|
|
400
404
|
datasources: handles,
|
|
401
405
|
addView(view) {
|
|
@@ -428,6 +432,7 @@ function createDataSourceHandle(
|
|
|
428
432
|
}
|
|
429
433
|
|
|
430
434
|
return {
|
|
435
|
+
resourceType: "dataSource",
|
|
431
436
|
resourceId: dataSource.resourceId,
|
|
432
437
|
schema,
|
|
433
438
|
database: createAppsDatabase(dataSource.resourceId, dataSource),
|
|
@@ -23,6 +23,7 @@ export type PageArgs = {
|
|
|
23
23
|
export type ChildPageArgs = Omit<PageArgs, "parent">;
|
|
24
24
|
|
|
25
25
|
export type PageHandle = {
|
|
26
|
+
readonly resourceType: "page";
|
|
26
27
|
readonly resourceId: string;
|
|
27
28
|
addDatabase: ChildDatabaseFactory;
|
|
28
29
|
addPage: (args: ChildPageArgs) => PageHandle;
|
|
@@ -50,6 +51,7 @@ export function createPage(args: ChildPageArgs | PageArgs, parent: Parent): Page
|
|
|
50
51
|
} satisfies { type: "page" } & PageIntent);
|
|
51
52
|
|
|
52
53
|
return {
|
|
54
|
+
resourceType: "page",
|
|
53
55
|
resourceId: args.resourceId,
|
|
54
56
|
addDatabase: createChildDatabase({ type: "resourceId", resourceId: args.resourceId }),
|
|
55
57
|
addPage(pageArgs) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { expectTypeOf, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { customAgent } from "./notion-as-code/custom-agent.js";
|
|
4
|
+
import { database } from "./notion-as-code/database.js";
|
|
5
|
+
import { page } from "./notion-as-code/page.js";
|
|
6
|
+
import { triggers } from "./triggers.generated.js";
|
|
7
|
+
import { workflow } from "./workflow.js";
|
|
8
|
+
|
|
9
|
+
it("infers access aliases and rejects resource-specific levels", () => {
|
|
10
|
+
const home = page({ resourceId: "home" });
|
|
11
|
+
const agent = customAgent({ resourceId: "agent", name: "Agent" });
|
|
12
|
+
const singleDatabase = database("single-database", {
|
|
13
|
+
dataSourceResourceId: "single-source",
|
|
14
|
+
name: "Single",
|
|
15
|
+
schema: { Title: { resourceId: "single-title", type: "title" } },
|
|
16
|
+
});
|
|
17
|
+
const multiDatabase = database("multi-database", {
|
|
18
|
+
name: "Multi",
|
|
19
|
+
datasources: {
|
|
20
|
+
Records: {
|
|
21
|
+
resourceId: "multi-source",
|
|
22
|
+
schema: { Title: { resourceId: "multi-title", type: "title" } },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
workflow({
|
|
27
|
+
name: "Access types",
|
|
28
|
+
description: "Checks access types",
|
|
29
|
+
triggers: [triggers.notionPageCreated()],
|
|
30
|
+
access: {
|
|
31
|
+
home: { resource: home, level: "view" },
|
|
32
|
+
singleDatabase: { resource: singleDatabase, level: "edit" },
|
|
33
|
+
multiDatabase: { resource: multiDatabase, level: "view" },
|
|
34
|
+
},
|
|
35
|
+
handler: (_event, context) => {
|
|
36
|
+
expectTypeOf(context.access.home.type).toEqualTypeOf<"page">();
|
|
37
|
+
expectTypeOf(context.access.home.id).toEqualTypeOf<string>();
|
|
38
|
+
expectTypeOf(context.access.singleDatabase.type).toEqualTypeOf<"database">();
|
|
39
|
+
expectTypeOf(context.access.multiDatabase.type).toEqualTypeOf<"database">();
|
|
40
|
+
// @ts-expect-error Access aliases are limited to declarations.
|
|
41
|
+
void context.access.missing;
|
|
42
|
+
// @ts-expect-error Access values are readonly.
|
|
43
|
+
context.access.home.id = "changed";
|
|
44
|
+
// @ts-expect-error The access map is readonly.
|
|
45
|
+
context.access = {};
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const invalidAccessLevels = () => {
|
|
50
|
+
workflow({
|
|
51
|
+
name: "Agent access types",
|
|
52
|
+
description: "Checks custom agent access types",
|
|
53
|
+
triggers: [triggers.notionPageCreated()],
|
|
54
|
+
// @ts-expect-error Custom agents only support the call level.
|
|
55
|
+
access: { agent: { resource: agent, level: "view" } },
|
|
56
|
+
handler: () => {},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
workflow({
|
|
60
|
+
name: "Page access types",
|
|
61
|
+
description: "Checks page access types",
|
|
62
|
+
triggers: [triggers.notionPageCreated()],
|
|
63
|
+
// @ts-expect-error Pages do not support the custom-agent call level.
|
|
64
|
+
access: { home: { resource: home, level: "call" } },
|
|
65
|
+
handler: () => {},
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
void invalidAccessLevels;
|
|
69
|
+
});
|