safe-notion 0.1.0

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 ADDED
@@ -0,0 +1,15 @@
1
+ # notion-cli-for-ai
2
+
3
+ To install dependencies:
4
+
5
+ ```bash
6
+ bun install
7
+ ```
8
+
9
+ To run:
10
+
11
+ ```bash
12
+ bun run src/index.ts
13
+ ```
14
+
15
+ This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
@@ -0,0 +1,77 @@
1
+ {
2
+ // Notion Safe CLI - Configuration
3
+ // Rules are evaluated in order; first matching rule applies
4
+ //
5
+ // Permission types (granular format):
6
+ // page:read, page:update, page:create
7
+ // database:read, database:query, database:create
8
+ // block:read, block:append, block:delete
9
+ "rules": [
10
+ {
11
+ // Example 1: Read-only access to a specific page and all its children
12
+ "name": "Personal notes - read only",
13
+ "pageId": "12345678-1234-1234-1234-123456789abc",
14
+ "permissions": ["page:read", "database:read", "database:query", "block:read"]
15
+ },
16
+ {
17
+ // Example 2: Read + block append only (no page property updates, no delete)
18
+ // Use case: Reference documents that AI can add notes to but not modify
19
+ "name": "Reference docs - read and append",
20
+ "pageId": "11111111-1111-1111-1111-111111111111",
21
+ "permissions": ["page:read", "database:read", "database:query", "block:read", "block:append"]
22
+ },
23
+ {
24
+ // Example 3: Database with conditional access
25
+ // Only allows access when Assignee property matches the specified user
26
+ "name": "Work tasks - conditional",
27
+ "databaseId": "abcdef12-3456-7890-abcd-ef1234567890",
28
+ "permissions": ["page:read", "database:read", "database:query", "block:read", "page:update", "block:append", "page:create", "database:create"],
29
+ "condition": {
30
+ "property": "Assignee",
31
+ "type": "people",
32
+ "equals": "user-id-from-notion" // Replace with actual user ID
33
+ }
34
+ },
35
+ {
36
+ // Example 4: Database query and create only (no updates to existing pages)
37
+ // Use case: Task database where AI can create new tasks but not modify existing ones
38
+ "name": "Task DB - query and create only",
39
+ "databaseId": "22222222-2222-2222-2222-222222222222",
40
+ "permissions": ["database:read", "database:query", "database:create"]
41
+ },
42
+ {
43
+ // Example 5: Database with status-based condition
44
+ // Only allows access when Status is "In Progress"
45
+ "name": "Project tracker - status conditional",
46
+ "databaseId": "fedcba98-7654-3210-fedc-ba9876543210",
47
+ "permissions": ["page:read", "database:read", "database:query", "block:read", "page:update", "block:append"],
48
+ "condition": {
49
+ "property": "Status",
50
+ "type": "status",
51
+ "equals": "In Progress"
52
+ }
53
+ },
54
+ {
55
+ // Example 6: Full access to a workspace page
56
+ "name": "AI Workspace - full access",
57
+ "pageId": "workspace-page-id-here",
58
+ "permissions": ["page:read", "page:update", "page:create", "database:read", "database:query", "database:create", "block:read", "block:append", "block:delete"]
59
+ },
60
+ {
61
+ // Example 7: Database with checkbox condition
62
+ // Only allows access when "AI Editable" checkbox is true
63
+ "name": "Shared DB - checkbox conditional",
64
+ "databaseId": "shared-db-id-here",
65
+ "permissions": ["page:read", "database:read", "database:query", "block:read", "page:update", "block:append"],
66
+ "condition": {
67
+ "property": "AI Editable",
68
+ "type": "checkbox",
69
+ "equals": true
70
+ }
71
+ }
72
+ ],
73
+ // Default behavior when no rule matches
74
+ // "deny" - Block all access (recommended for safety)
75
+ // "read" - Allow read-only access
76
+ "defaultPermission": "deny"
77
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createBlockCommand(): Command;
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createConfigCommand(): Command;
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createDbCommand(): Command;
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createPageCommand(): Command;
@@ -0,0 +1,2 @@
1
+ export declare function outputJson(data: unknown): void;
2
+ export declare function handleError(error: unknown): never;
@@ -0,0 +1,9 @@
1
+ import { type Config } from "./types.ts";
2
+ export declare function getConfigPath(): string;
3
+ export declare function loadConfig(): Config;
4
+ export declare function validateConfig(configPath?: string): {
5
+ valid: boolean;
6
+ errors?: string[];
7
+ };
8
+ export declare function initConfig(): string;
9
+ export declare function getNotionToken(): string;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,679 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/index.ts
5
+ import { Command as Command5 } from "commander";
6
+
7
+ // src/commands/page.ts
8
+ import { Command } from "commander";
9
+
10
+ // src/notion-client.ts
11
+ import { Client } from "@notionhq/client";
12
+
13
+ // src/config.ts
14
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join, dirname } from "node:path";
17
+ import { parse as parseJsonc } from "jsonc-parser";
18
+
19
+ // src/types.ts
20
+ import { z } from "zod";
21
+ var GranularPermissionValues = [
22
+ "page:read",
23
+ "page:update",
24
+ "page:create",
25
+ "database:read",
26
+ "database:query",
27
+ "database:create",
28
+ "block:read",
29
+ "block:append",
30
+ "block:delete"
31
+ ];
32
+ var PermissionSchema = z.enum(GranularPermissionValues);
33
+ var ConditionSchema = z.object({
34
+ property: z.string(),
35
+ type: z.enum(["people", "select", "multi_select", "status", "checkbox"]),
36
+ equals: z.union([z.string(), z.boolean()])
37
+ });
38
+ var RuleSchema = z.object({
39
+ name: z.string(),
40
+ pageId: z.string().uuid().optional(),
41
+ databaseId: z.string().uuid().optional(),
42
+ permissions: z.array(PermissionSchema),
43
+ condition: ConditionSchema.optional()
44
+ }).refine((data) => data.pageId || data.databaseId, {
45
+ message: "Either pageId or databaseId must be specified"
46
+ });
47
+ var ConfigSchema = z.object({
48
+ rules: z.array(RuleSchema),
49
+ defaultPermission: z.enum(["deny", "read"]).default("deny")
50
+ });
51
+
52
+ // src/config.ts
53
+ var CONFIG_DIR = join(homedir(), ".config", "safe-notion");
54
+ var CONFIG_PATH = join(CONFIG_DIR, "config.jsonc");
55
+ function getConfigPath() {
56
+ return CONFIG_PATH;
57
+ }
58
+ function loadConfig() {
59
+ if (!existsSync(CONFIG_PATH)) {
60
+ throw new Error(`Config file not found: ${CONFIG_PATH}
61
+ Run 'notion-safe config init' to create a template.`);
62
+ }
63
+ const content = readFileSync(CONFIG_PATH, "utf-8");
64
+ const parsed = parseJsonc(content);
65
+ const result = ConfigSchema.safeParse(parsed);
66
+ if (!result.success) {
67
+ const errors = result.error.issues.map((e) => ` - ${e.path.join(".")}: ${e.message}`).join(`
68
+ `);
69
+ throw new Error(`Invalid config file:
70
+ ${errors}`);
71
+ }
72
+ return result.data;
73
+ }
74
+ function validateConfig(configPath) {
75
+ const path = configPath ?? CONFIG_PATH;
76
+ if (!existsSync(path)) {
77
+ return { valid: false, errors: [`Config file not found: ${path}`] };
78
+ }
79
+ try {
80
+ const content = readFileSync(path, "utf-8");
81
+ const parsed = parseJsonc(content);
82
+ const result = ConfigSchema.safeParse(parsed);
83
+ if (!result.success) {
84
+ return {
85
+ valid: false,
86
+ errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`)
87
+ };
88
+ }
89
+ return { valid: true };
90
+ } catch (error) {
91
+ return {
92
+ valid: false,
93
+ errors: [error instanceof Error ? error.message : "Unknown error"]
94
+ };
95
+ }
96
+ }
97
+ function initConfig() {
98
+ if (existsSync(CONFIG_PATH)) {
99
+ throw new Error(`Config file already exists: ${CONFIG_PATH}`);
100
+ }
101
+ const template = `{
102
+ // Safe Notion - Configuration
103
+ // Rules are evaluated in order; first matching rule applies
104
+ //
105
+ // Permission types (granular format):
106
+ // page:read, page:update, page:create
107
+ // database:read, database:query, database:create
108
+ // block:read, block:append, block:delete
109
+ "rules": [
110
+ {
111
+ // Rule name (for logging)
112
+ "name": "Example - Read only page",
113
+ // Page ID (applies to this page and all children)
114
+ "pageId": "00000000-0000-0000-0000-000000000000",
115
+ "permissions": ["page:read", "database:read", "database:query", "block:read"]
116
+ },
117
+ {
118
+ "name": "Example - Read + block append only",
119
+ // Allows reading and adding blocks, but NOT updating page properties or deleting
120
+ "pageId": "11111111-1111-1111-1111-111111111111",
121
+ "permissions": ["page:read", "database:read", "database:query", "block:read", "block:append"]
122
+ },
123
+ {
124
+ "name": "Example - Database with conditional write",
125
+ // Database ID
126
+ "databaseId": "22222222-2222-2222-2222-222222222222",
127
+ "permissions": ["page:read", "database:read", "database:query", "block:read", "page:update", "block:append"],
128
+ // Optional: Only allow if this condition is met
129
+ "condition": {
130
+ "property": "Assignee", // Property name
131
+ "type": "people", // Property type: people, select, multi_select, status, checkbox
132
+ "equals": "user-id" // Value to match (user ID for people, string for others)
133
+ }
134
+ },
135
+ {
136
+ "name": "Example - Database query and create only",
137
+ // Allows querying and creating pages in DB, but NOT updating existing pages
138
+ "databaseId": "33333333-3333-3333-3333-333333333333",
139
+ "permissions": ["database:read", "database:query", "database:create"]
140
+ },
141
+ {
142
+ "name": "Example - Full access page",
143
+ "pageId": "44444444-4444-4444-4444-444444444444",
144
+ "permissions": ["page:read", "page:update", "page:create", "database:read", "database:query", "database:create", "block:read", "block:append", "block:delete"]
145
+ }
146
+ ],
147
+ // Default behavior when no rule matches: "deny" or "read"
148
+ "defaultPermission": "deny"
149
+ }
150
+ `;
151
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
152
+ writeFileSync(CONFIG_PATH, template, "utf-8");
153
+ return CONFIG_PATH;
154
+ }
155
+ function getNotionToken() {
156
+ const token = process.env.NOTION_TOKEN;
157
+ if (!token) {
158
+ throw new Error(`NOTION_TOKEN environment variable is not set.
159
+ ` + "Get your API token from https://www.notion.so/my-integrations");
160
+ }
161
+ return token;
162
+ }
163
+
164
+ // src/permissions.ts
165
+ var parentCache = new Map;
166
+ var CACHE_TTL_MS = 10 * 60 * 1000;
167
+ function normalizeId(id) {
168
+ return id.replace(/-/g, "").toLowerCase();
169
+ }
170
+ function idsMatch(id1, id2) {
171
+ return normalizeId(id1) === normalizeId(id2);
172
+ }
173
+ async function getParentId(client, resourceId) {
174
+ const normalizedId = normalizeId(resourceId);
175
+ const now = Date.now();
176
+ const cached = parentCache.get(normalizedId);
177
+ if (cached && cached.expiresAt > now) {
178
+ return cached.value;
179
+ }
180
+ try {
181
+ const page = await client.pages.retrieve({ page_id: resourceId });
182
+ if ("parent" in page) {
183
+ let parentId = null;
184
+ if (page.parent.type === "page_id") {
185
+ parentId = page.parent.page_id;
186
+ } else if (page.parent.type === "database_id") {
187
+ parentId = page.parent.database_id;
188
+ } else if (page.parent.type === "data_source_id" && "database_id" in page.parent) {
189
+ parentId = page.parent.database_id;
190
+ }
191
+ parentCache.set(normalizedId, { value: parentId, expiresAt: now + CACHE_TTL_MS });
192
+ return parentId;
193
+ }
194
+ } catch {
195
+ try {
196
+ const block = await client.blocks.retrieve({ block_id: resourceId });
197
+ if ("parent" in block) {
198
+ let parentId = null;
199
+ if (block.parent.type === "page_id") {
200
+ parentId = block.parent.page_id;
201
+ } else if (block.parent.type === "database_id") {
202
+ parentId = block.parent.database_id;
203
+ } else if (block.parent.type === "block_id") {
204
+ parentId = block.parent.block_id;
205
+ }
206
+ parentCache.set(normalizedId, { value: parentId, expiresAt: now + CACHE_TTL_MS });
207
+ return parentId;
208
+ }
209
+ } catch {
210
+ try {
211
+ const db = await client.databases.retrieve({ database_id: resourceId });
212
+ if ("parent" in db) {
213
+ let parentId = null;
214
+ if (db.parent.type === "page_id") {
215
+ parentId = db.parent.page_id;
216
+ }
217
+ parentCache.set(normalizedId, { value: parentId, expiresAt: now + CACHE_TTL_MS });
218
+ return parentId;
219
+ }
220
+ } catch {}
221
+ }
222
+ }
223
+ parentCache.set(normalizedId, { value: null, expiresAt: now + CACHE_TTL_MS });
224
+ return null;
225
+ }
226
+ async function isDescendantOf(client, resourceId, ancestorId, maxDepth = 10) {
227
+ if (idsMatch(resourceId, ancestorId)) {
228
+ return true;
229
+ }
230
+ let currentId = resourceId;
231
+ let depth = 0;
232
+ while (currentId && depth < maxDepth) {
233
+ const parentId = await getParentId(client, currentId);
234
+ if (!parentId) {
235
+ return false;
236
+ }
237
+ if (idsMatch(parentId, ancestorId)) {
238
+ return true;
239
+ }
240
+ currentId = parentId;
241
+ depth++;
242
+ }
243
+ return false;
244
+ }
245
+ async function checkCondition(client, pageId, condition) {
246
+ try {
247
+ const page = await client.pages.retrieve({ page_id: pageId });
248
+ if (!("properties" in page)) {
249
+ return false;
250
+ }
251
+ const property = page.properties[condition.property];
252
+ if (!property) {
253
+ return false;
254
+ }
255
+ switch (condition.type) {
256
+ case "people": {
257
+ if (property.type !== "people")
258
+ return false;
259
+ return property.people.some((p) => p.id === condition.equals);
260
+ }
261
+ case "select": {
262
+ if (property.type !== "select")
263
+ return false;
264
+ return property.select?.name === condition.equals;
265
+ }
266
+ case "multi_select": {
267
+ if (property.type !== "multi_select")
268
+ return false;
269
+ return property.multi_select.some((s) => s.name === condition.equals);
270
+ }
271
+ case "status": {
272
+ if (property.type !== "status")
273
+ return false;
274
+ return property.status?.name === condition.equals;
275
+ }
276
+ case "checkbox": {
277
+ if (property.type !== "checkbox")
278
+ return false;
279
+ return property.checkbox === condition.equals;
280
+ }
281
+ default:
282
+ return false;
283
+ }
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+ async function checkPermission(client, config, resourceId, operation, pageIdForCondition) {
289
+ for (const rule of config.rules) {
290
+ let matches = false;
291
+ if (rule.pageId) {
292
+ matches = await isDescendantOf(client, resourceId, rule.pageId);
293
+ } else if (rule.databaseId) {
294
+ if (idsMatch(resourceId, rule.databaseId)) {
295
+ matches = true;
296
+ } else {
297
+ const parentId = await getParentId(client, resourceId);
298
+ if (parentId && idsMatch(parentId, rule.databaseId)) {
299
+ matches = true;
300
+ }
301
+ }
302
+ }
303
+ if (matches) {
304
+ const rulePermissions = new Set(rule.permissions);
305
+ const hasPermission = rulePermissions.has(operation);
306
+ if (!hasPermission) {
307
+ return {
308
+ allowed: false,
309
+ rule,
310
+ reason: `Operation '${operation}' not allowed by rule '${rule.name}'`
311
+ };
312
+ }
313
+ const writeOperations = [
314
+ "page:update",
315
+ "block:append",
316
+ "page:create",
317
+ "database:create"
318
+ ];
319
+ if (writeOperations.includes(operation) && rule.condition) {
320
+ const targetPageId = pageIdForCondition ?? resourceId;
321
+ const conditionMet = await checkCondition(client, targetPageId, rule.condition);
322
+ if (!conditionMet) {
323
+ return {
324
+ allowed: false,
325
+ rule,
326
+ reason: `Condition not met: ${rule.condition.property} must equal '${rule.condition.equals}'`
327
+ };
328
+ }
329
+ }
330
+ return {
331
+ allowed: true,
332
+ rule,
333
+ reason: `Allowed by rule '${rule.name}'`
334
+ };
335
+ }
336
+ }
337
+ const readOperations = [
338
+ "page:read",
339
+ "database:read",
340
+ "database:query",
341
+ "block:read"
342
+ ];
343
+ if (config.defaultPermission === "read" && readOperations.includes(operation)) {
344
+ return {
345
+ allowed: true,
346
+ reason: "Allowed by default read permission"
347
+ };
348
+ }
349
+ return {
350
+ allowed: false,
351
+ reason: "No matching rule and default permission is deny"
352
+ };
353
+ }
354
+ function clearCache() {
355
+ parentCache.clear();
356
+ }
357
+
358
+ // src/notion-client.ts
359
+ class NotionSafeClient {
360
+ client;
361
+ config;
362
+ constructor() {
363
+ const token = getNotionToken();
364
+ this.client = new Client({ auth: token });
365
+ this.config = loadConfig();
366
+ }
367
+ async ensurePermission(resourceId, operation, pageIdForCondition) {
368
+ const result = await checkPermission(this.client, this.config, resourceId, operation, pageIdForCondition);
369
+ if (!result.allowed) {
370
+ const error = {
371
+ error: result.reason,
372
+ code: "PERMISSION_DENIED"
373
+ };
374
+ throw error;
375
+ }
376
+ }
377
+ async getPage(pageId) {
378
+ await this.ensurePermission(pageId, "page:read");
379
+ return this.client.pages.retrieve({ page_id: pageId });
380
+ }
381
+ async createPage(params) {
382
+ let parentId;
383
+ if (params.parent && "page_id" in params.parent) {
384
+ parentId = params.parent.page_id;
385
+ } else if (params.parent && "database_id" in params.parent) {
386
+ parentId = params.parent.database_id;
387
+ } else {
388
+ throw { error: "Invalid parent type", code: "INVALID_PARENT" };
389
+ }
390
+ await this.ensurePermission(parentId, "page:create");
391
+ return this.client.pages.create(params);
392
+ }
393
+ async updatePage(pageId, properties) {
394
+ await this.ensurePermission(pageId, "page:update", pageId);
395
+ return this.client.pages.update({ page_id: pageId, properties });
396
+ }
397
+ async getDatabase(databaseId) {
398
+ await this.ensurePermission(databaseId, "database:read");
399
+ return this.client.databases.retrieve({ database_id: databaseId });
400
+ }
401
+ async queryDatabase(databaseId, params) {
402
+ await this.ensurePermission(databaseId, "database:query");
403
+ return this.client.databases.query({
404
+ database_id: databaseId,
405
+ ...params
406
+ });
407
+ }
408
+ async createDatabasePage(databaseId, properties) {
409
+ await this.ensurePermission(databaseId, "database:create");
410
+ return this.client.pages.create({
411
+ parent: { database_id: databaseId },
412
+ properties
413
+ });
414
+ }
415
+ async getBlock(blockId) {
416
+ await this.ensurePermission(blockId, "block:read");
417
+ return this.client.blocks.retrieve({ block_id: blockId });
418
+ }
419
+ async getBlockChildren(blockId, startCursor, pageSize) {
420
+ await this.ensurePermission(blockId, "block:read");
421
+ return this.client.blocks.children.list({
422
+ block_id: blockId,
423
+ start_cursor: startCursor,
424
+ page_size: pageSize
425
+ });
426
+ }
427
+ async appendBlockChildren(blockId, children) {
428
+ await this.ensurePermission(blockId, "block:append");
429
+ return this.client.blocks.children.append({
430
+ block_id: blockId,
431
+ children
432
+ });
433
+ }
434
+ async deleteBlock(blockId) {
435
+ await this.ensurePermission(blockId, "block:delete");
436
+ return this.client.blocks.delete({ block_id: blockId });
437
+ }
438
+ clearCache() {
439
+ clearCache();
440
+ }
441
+ }
442
+ var clientInstance = null;
443
+ function getClient() {
444
+ if (!clientInstance) {
445
+ clientInstance = new NotionSafeClient;
446
+ }
447
+ return clientInstance;
448
+ }
449
+
450
+ // src/commands/utils.ts
451
+ function outputJson(data) {
452
+ console.log(JSON.stringify(data, null, 2));
453
+ }
454
+ function handleError(error) {
455
+ if (isErrorResponse(error)) {
456
+ outputJson(error);
457
+ process.exit(1);
458
+ }
459
+ if (error instanceof Error) {
460
+ const response2 = {
461
+ error: error.message,
462
+ code: "UNKNOWN_ERROR"
463
+ };
464
+ outputJson(response2);
465
+ process.exit(1);
466
+ }
467
+ if (isNotionError(error)) {
468
+ const response2 = {
469
+ error: error.message,
470
+ code: error.code
471
+ };
472
+ outputJson(response2);
473
+ process.exit(1);
474
+ }
475
+ const response = {
476
+ error: String(error),
477
+ code: "UNKNOWN_ERROR"
478
+ };
479
+ outputJson(response);
480
+ process.exit(1);
481
+ }
482
+ function isErrorResponse(error) {
483
+ return typeof error === "object" && error !== null && "error" in error && "code" in error;
484
+ }
485
+ function isNotionError(error) {
486
+ return typeof error === "object" && error !== null && "message" in error && "code" in error;
487
+ }
488
+
489
+ // src/commands/page.ts
490
+ function createPageCommand() {
491
+ const page = new Command("page").description("Page operations");
492
+ page.command("get").description("Get a page by ID").argument("<page-id>", "Page ID").action(async (pageId) => {
493
+ try {
494
+ const client = getClient();
495
+ const result = await client.getPage(pageId);
496
+ outputJson(result);
497
+ } catch (error) {
498
+ handleError(error);
499
+ }
500
+ });
501
+ page.command("create").description("Create a new page").requiredOption("--parent <id>", "Parent page ID").requiredOption("--title <title>", "Page title").option("--icon <emoji>", "Page icon emoji").option("--content <json>", "Page content as JSON array of blocks").action(async (options) => {
502
+ try {
503
+ const client = getClient();
504
+ const properties = {
505
+ title: {
506
+ title: [{ text: { content: options.title } }]
507
+ }
508
+ };
509
+ const params = {
510
+ parent: { page_id: options.parent },
511
+ properties
512
+ };
513
+ if (options.icon) {
514
+ params.icon = { emoji: options.icon };
515
+ }
516
+ if (options.content) {
517
+ params.children = JSON.parse(options.content);
518
+ }
519
+ const result = await client.createPage(params);
520
+ outputJson(result);
521
+ } catch (error) {
522
+ handleError(error);
523
+ }
524
+ });
525
+ page.command("update").description("Update a page").argument("<page-id>", "Page ID").requiredOption("--properties <json>", "Properties to update as JSON").action(async (pageId, options) => {
526
+ try {
527
+ const client = getClient();
528
+ const properties = JSON.parse(options.properties);
529
+ const result = await client.updatePage(pageId, properties);
530
+ outputJson(result);
531
+ } catch (error) {
532
+ handleError(error);
533
+ }
534
+ });
535
+ return page;
536
+ }
537
+
538
+ // src/commands/db.ts
539
+ import { Command as Command2 } from "commander";
540
+ function createDbCommand() {
541
+ const db = new Command2("db").description("Database operations");
542
+ db.command("get").description("Get a database by ID").argument("<database-id>", "Database ID").action(async (databaseId) => {
543
+ try {
544
+ const client = getClient();
545
+ const result = await client.getDatabase(databaseId);
546
+ outputJson(result);
547
+ } catch (error) {
548
+ handleError(error);
549
+ }
550
+ });
551
+ db.command("query").description("Query a database").argument("<database-id>", "Database ID").option("--filter <json>", "Filter as JSON").option("--sorts <json>", "Sorts as JSON array").option("--start-cursor <cursor>", "Pagination cursor").option("--page-size <size>", "Number of results per page", "100").action(async (databaseId, options) => {
552
+ try {
553
+ const client = getClient();
554
+ const params = {};
555
+ if (options.filter) {
556
+ params.filter = JSON.parse(options.filter);
557
+ }
558
+ if (options.sorts) {
559
+ params.sorts = JSON.parse(options.sorts);
560
+ }
561
+ if (options.startCursor) {
562
+ params.start_cursor = options.startCursor;
563
+ }
564
+ if (options.pageSize) {
565
+ params.page_size = parseInt(options.pageSize, 10);
566
+ }
567
+ const result = await client.queryDatabase(databaseId, params);
568
+ outputJson(result);
569
+ } catch (error) {
570
+ handleError(error);
571
+ }
572
+ });
573
+ db.command("create-page").description("Create a new page in a database").argument("<database-id>", "Database ID").requiredOption("--properties <json>", "Page properties as JSON").action(async (databaseId, options) => {
574
+ try {
575
+ const client = getClient();
576
+ const properties = JSON.parse(options.properties);
577
+ const result = await client.createDatabasePage(databaseId, properties);
578
+ outputJson(result);
579
+ } catch (error) {
580
+ handleError(error);
581
+ }
582
+ });
583
+ return db;
584
+ }
585
+
586
+ // src/commands/block.ts
587
+ import { Command as Command3 } from "commander";
588
+ function createBlockCommand() {
589
+ const block = new Command3("block").description("Block operations");
590
+ block.command("get").description("Get a block by ID").argument("<block-id>", "Block ID").action(async (blockId) => {
591
+ try {
592
+ const client = getClient();
593
+ const result = await client.getBlock(blockId);
594
+ outputJson(result);
595
+ } catch (error) {
596
+ handleError(error);
597
+ }
598
+ });
599
+ block.command("children").description("Get children of a block").argument("<block-id>", "Block ID (can also be a page ID)").option("--start-cursor <cursor>", "Pagination cursor").option("--page-size <size>", "Number of results per page").action(async (blockId, options) => {
600
+ try {
601
+ const client = getClient();
602
+ const result = await client.getBlockChildren(blockId, options.startCursor, options.pageSize ? parseInt(options.pageSize, 10) : undefined);
603
+ outputJson(result);
604
+ } catch (error) {
605
+ handleError(error);
606
+ }
607
+ });
608
+ block.command("append").description("Append children to a block").argument("<block-id>", "Block ID (can also be a page ID)").requiredOption("--children <json>", "Children blocks as JSON array").action(async (blockId, options) => {
609
+ try {
610
+ const client = getClient();
611
+ const children = JSON.parse(options.children);
612
+ const result = await client.appendBlockChildren(blockId, children);
613
+ outputJson(result);
614
+ } catch (error) {
615
+ handleError(error);
616
+ }
617
+ });
618
+ block.command("delete").description("Delete a block").argument("<block-id>", "Block ID").action(async (blockId) => {
619
+ try {
620
+ const client = getClient();
621
+ const result = await client.deleteBlock(blockId);
622
+ outputJson(result);
623
+ } catch (error) {
624
+ handleError(error);
625
+ }
626
+ });
627
+ return block;
628
+ }
629
+
630
+ // src/commands/config.ts
631
+ import { Command as Command4 } from "commander";
632
+ function createConfigCommand() {
633
+ const config = new Command4("config").description("Configuration management");
634
+ config.command("validate").description("Validate the configuration file").option("--path <path>", "Path to config file").action((options) => {
635
+ try {
636
+ const result = validateConfig(options.path);
637
+ if (result.valid) {
638
+ outputJson({
639
+ valid: true,
640
+ path: options.path ?? getConfigPath()
641
+ });
642
+ } else {
643
+ outputJson({
644
+ valid: false,
645
+ errors: result.errors,
646
+ path: options.path ?? getConfigPath()
647
+ });
648
+ process.exit(1);
649
+ }
650
+ } catch (error) {
651
+ handleError(error);
652
+ }
653
+ });
654
+ config.command("init").description("Create a template configuration file").action(() => {
655
+ try {
656
+ const path = initConfig();
657
+ outputJson({
658
+ success: true,
659
+ path,
660
+ message: "Configuration template created. Edit it to add your rules."
661
+ });
662
+ } catch (error) {
663
+ handleError(error);
664
+ }
665
+ });
666
+ config.command("path").description("Show the configuration file path").action(() => {
667
+ outputJson({ path: getConfigPath() });
668
+ });
669
+ return config;
670
+ }
671
+
672
+ // src/index.ts
673
+ var program = new Command5;
674
+ program.name("notion-safe").description("A safe Notion API wrapper CLI for AI agents").version("0.1.0");
675
+ program.addCommand(createPageCommand());
676
+ program.addCommand(createDbCommand());
677
+ program.addCommand(createBlockCommand());
678
+ program.addCommand(createConfigCommand());
679
+ program.parse();
@@ -0,0 +1,29 @@
1
+ import { Client } from "@notionhq/client";
2
+ type CreatePageParams = Parameters<Client["pages"]["create"]>[0];
3
+ type UpdatePageParams = Parameters<Client["pages"]["update"]>[0];
4
+ type AppendBlockChildrenParams = Parameters<Client["blocks"]["children"]["append"]>[0];
5
+ interface QueryParams {
6
+ filter?: unknown;
7
+ sorts?: unknown[];
8
+ start_cursor?: string;
9
+ page_size?: number;
10
+ }
11
+ export declare class NotionSafeClient {
12
+ private client;
13
+ private config;
14
+ constructor();
15
+ private ensurePermission;
16
+ getPage(pageId: string): Promise<unknown>;
17
+ createPage(params: CreatePageParams): Promise<unknown>;
18
+ updatePage(pageId: string, properties: UpdatePageParams["properties"]): Promise<unknown>;
19
+ getDatabase(databaseId: string): Promise<unknown>;
20
+ queryDatabase(databaseId: string, params?: QueryParams): Promise<unknown>;
21
+ createDatabasePage(databaseId: string, properties: CreatePageParams["properties"]): Promise<unknown>;
22
+ getBlock(blockId: string): Promise<unknown>;
23
+ getBlockChildren(blockId: string, startCursor?: string, pageSize?: number): Promise<unknown>;
24
+ appendBlockChildren(blockId: string, children: AppendBlockChildrenParams["children"]): Promise<unknown>;
25
+ deleteBlock(blockId: string): Promise<unknown>;
26
+ clearCache(): void;
27
+ }
28
+ export declare function getClient(): NotionSafeClient;
29
+ export {};
@@ -0,0 +1,4 @@
1
+ import { Client } from "@notionhq/client";
2
+ import type { Config, OperationType, PermissionCheckResult } from "./types.ts";
3
+ export declare function checkPermission(client: Client, config: Config, resourceId: string, operation: OperationType, pageIdForCondition?: string): Promise<PermissionCheckResult>;
4
+ export declare function clearCache(): void;
@@ -0,0 +1,105 @@
1
+ import { z } from "zod";
2
+ export declare const GranularPermissionValues: readonly ["page:read", "page:update", "page:create", "database:read", "database:query", "database:create", "block:read", "block:append", "block:delete"];
3
+ export type GranularPermission = (typeof GranularPermissionValues)[number];
4
+ export declare const PermissionSchema: z.ZodEnum<{
5
+ "page:read": "page:read";
6
+ "page:update": "page:update";
7
+ "page:create": "page:create";
8
+ "database:read": "database:read";
9
+ "database:query": "database:query";
10
+ "database:create": "database:create";
11
+ "block:read": "block:read";
12
+ "block:append": "block:append";
13
+ "block:delete": "block:delete";
14
+ }>;
15
+ export type Permission = z.infer<typeof PermissionSchema>;
16
+ export declare const ConditionSchema: z.ZodObject<{
17
+ property: z.ZodString;
18
+ type: z.ZodEnum<{
19
+ people: "people";
20
+ select: "select";
21
+ multi_select: "multi_select";
22
+ status: "status";
23
+ checkbox: "checkbox";
24
+ }>;
25
+ equals: z.ZodUnion<readonly [z.ZodString, z.ZodBoolean]>;
26
+ }, z.core.$strip>;
27
+ export type Condition = z.infer<typeof ConditionSchema>;
28
+ export declare const RuleSchema: z.ZodObject<{
29
+ name: z.ZodString;
30
+ pageId: z.ZodOptional<z.ZodString>;
31
+ databaseId: z.ZodOptional<z.ZodString>;
32
+ permissions: z.ZodArray<z.ZodEnum<{
33
+ "page:read": "page:read";
34
+ "page:update": "page:update";
35
+ "page:create": "page:create";
36
+ "database:read": "database:read";
37
+ "database:query": "database:query";
38
+ "database:create": "database:create";
39
+ "block:read": "block:read";
40
+ "block:append": "block:append";
41
+ "block:delete": "block:delete";
42
+ }>>;
43
+ condition: z.ZodOptional<z.ZodObject<{
44
+ property: z.ZodString;
45
+ type: z.ZodEnum<{
46
+ people: "people";
47
+ select: "select";
48
+ multi_select: "multi_select";
49
+ status: "status";
50
+ checkbox: "checkbox";
51
+ }>;
52
+ equals: z.ZodUnion<readonly [z.ZodString, z.ZodBoolean]>;
53
+ }, z.core.$strip>>;
54
+ }, z.core.$strip>;
55
+ export type Rule = z.infer<typeof RuleSchema>;
56
+ export declare const ConfigSchema: z.ZodObject<{
57
+ rules: z.ZodArray<z.ZodObject<{
58
+ name: z.ZodString;
59
+ pageId: z.ZodOptional<z.ZodString>;
60
+ databaseId: z.ZodOptional<z.ZodString>;
61
+ permissions: z.ZodArray<z.ZodEnum<{
62
+ "page:read": "page:read";
63
+ "page:update": "page:update";
64
+ "page:create": "page:create";
65
+ "database:read": "database:read";
66
+ "database:query": "database:query";
67
+ "database:create": "database:create";
68
+ "block:read": "block:read";
69
+ "block:append": "block:append";
70
+ "block:delete": "block:delete";
71
+ }>>;
72
+ condition: z.ZodOptional<z.ZodObject<{
73
+ property: z.ZodString;
74
+ type: z.ZodEnum<{
75
+ people: "people";
76
+ select: "select";
77
+ multi_select: "multi_select";
78
+ status: "status";
79
+ checkbox: "checkbox";
80
+ }>;
81
+ equals: z.ZodUnion<readonly [z.ZodString, z.ZodBoolean]>;
82
+ }, z.core.$strip>>;
83
+ }, z.core.$strip>>;
84
+ defaultPermission: z.ZodDefault<z.ZodEnum<{
85
+ deny: "deny";
86
+ read: "read";
87
+ }>>;
88
+ }, z.core.$strip>;
89
+ export type Config = z.infer<typeof ConfigSchema>;
90
+ export interface PermissionCheckResult {
91
+ allowed: boolean;
92
+ rule?: Rule;
93
+ reason: string;
94
+ }
95
+ export interface ErrorResponse {
96
+ error: string;
97
+ code: string;
98
+ }
99
+ export type OperationType = GranularPermission;
100
+ export type ResourceType = "page" | "database" | "block";
101
+ export interface ResourceIdentifier {
102
+ type: ResourceType;
103
+ id: string;
104
+ parentId?: string;
105
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "safe-notion",
3
+ "version": "0.1.0",
4
+ "description": "A safe Notion API wrapper CLI for AI agents with granular permission control",
5
+ "license": "MIT",
6
+ "author": "shoppingjaws",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/shoppingjaws/safe-notion.git"
10
+ },
11
+ "homepage": "https://github.com/shoppingjaws/safe-notion#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/shoppingjaws/safe-notion/issues"
14
+ },
15
+ "keywords": [
16
+ "notion",
17
+ "notion-api",
18
+ "cli",
19
+ "ai",
20
+ "agent",
21
+ "permission",
22
+ "security",
23
+ "mcp"
24
+ ],
25
+ "type": "module",
26
+ "main": "dist/index.js",
27
+ "module": "dist/index.js",
28
+ "types": "dist/index.d.ts",
29
+ "bin": {
30
+ "notion-safe": "dist/index.js"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "config.sample.jsonc"
35
+ ],
36
+ "exports": {
37
+ ".": {
38
+ "import": "./dist/index.js",
39
+ "types": "./dist/index.d.ts"
40
+ }
41
+ },
42
+ "scripts": {
43
+ "build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && bun run build:types",
44
+ "build:types": "tsc -p tsconfig.build.json",
45
+ "prepublishOnly": "bun run build",
46
+ "typecheck": "tsc --noEmit"
47
+ },
48
+ "devDependencies": {
49
+ "@types/bun": "latest",
50
+ "@types/node": "^22.0.0",
51
+ "typescript": "^5.8.0"
52
+ },
53
+ "dependencies": {
54
+ "@notionhq/client": "^5.8.0",
55
+ "commander": "^14.0.2",
56
+ "jsonc-parser": "^3.3.1",
57
+ "zod": "^4.3.6"
58
+ },
59
+ "engines": {
60
+ "node": ">=22"
61
+ }
62
+ }