nsa-sheets-db-builder 4.0.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/bin/sheets-deployer.mjs +169 -0
  4. package/libs/alasql.js +15577 -0
  5. package/libs/common/gas_response_helper.ts +147 -0
  6. package/libs/common/gaserror.ts +101 -0
  7. package/libs/common/gaslogger.ts +172 -0
  8. package/libs/db_ddl.ts +316 -0
  9. package/libs/libraries.json +56 -0
  10. package/libs/spreadsheets_db.ts +4406 -0
  11. package/libs/triggers.ts +113 -0
  12. package/package.json +73 -0
  13. package/scripts/build.mjs +513 -0
  14. package/scripts/clean.mjs +31 -0
  15. package/scripts/create.mjs +94 -0
  16. package/scripts/ddl-handler.mjs +232 -0
  17. package/scripts/describe.mjs +38 -0
  18. package/scripts/drop.mjs +39 -0
  19. package/scripts/init.mjs +465 -0
  20. package/scripts/lib/utils.mjs +1019 -0
  21. package/scripts/login.mjs +102 -0
  22. package/scripts/provision.mjs +35 -0
  23. package/scripts/refresh-cache.mjs +34 -0
  24. package/scripts/set-key.mjs +48 -0
  25. package/scripts/setup-trigger.mjs +95 -0
  26. package/scripts/setup.mjs +677 -0
  27. package/scripts/show.mjs +37 -0
  28. package/scripts/sync.mjs +35 -0
  29. package/scripts/whoami.mjs +36 -0
  30. package/src/api/ddl-handler-entry.ts +136 -0
  31. package/src/api/ddl.ts +321 -0
  32. package/src/templates/.clasp.json.ejs +1 -0
  33. package/src/templates/appsscript.json.ejs +16 -0
  34. package/src/templates/config.ts.ejs +14 -0
  35. package/src/templates/ddl-handler-config.ts.ejs +3 -0
  36. package/src/templates/ddl-handler-main.ts.ejs +56 -0
  37. package/src/templates/main.ts.ejs +288 -0
  38. package/src/templates/rbac.ts.ejs +148 -0
  39. package/src/templates/views.ts.ejs +92 -0
  40. package/templates/blank.json +33 -0
  41. package/templates/blog-cms.json +507 -0
  42. package/templates/crm.json +360 -0
  43. package/templates/e-commerce.json +424 -0
  44. package/templates/inventory.json +307 -0
@@ -0,0 +1,288 @@
1
+ // ========================================
2
+ // Sheets Deployer — Entry Point (POST-only)
3
+ // Auto-generated by nsa-sheets-db-builder
4
+ // Instance type: <%- instance_type %>
5
+ <% if (rbac_enabled) { -%>
6
+ // RBAC: enabled (auth mode: <%- auth_mode %>)
7
+ <% } -%>
8
+ <% if (views_enabled) { -%>
9
+ // SQL Views: enabled (<%- view_names.length %> view(s))
10
+ <% } -%>
11
+ // ========================================
12
+
13
+ function getLogger(): any {
14
+ if (typeof GASLoggerV2 !== 'undefined') {
15
+ return new GASLoggerV2(DEPLOYER_CONFIG.name, null, LOGGING_VERBOSITY_LEVEL, '');
16
+ }
17
+ return {
18
+ info: (src: string, msg: string, ctx?: any) => console.log(`[INFO] ${src}: ${msg}`),
19
+ warn: (src: string, msg: string, ctx?: any) => console.warn(`[WARN] ${src}: ${msg}`),
20
+ error: (err: any) => console.error(`[ERROR]`, err?.message || err),
21
+ debug: (src: string, msg: string, ctx?: any) => {},
22
+ getTraceId: () => ''
23
+ };
24
+ }
25
+
26
+ // ── Auth ──────────────────────────────────
27
+
28
+ function validateApiKey(key: string): boolean {
29
+ const storedKey = PropertiesService.getScriptProperties().getProperty('DDL_API_KEY');
30
+ if (!storedKey) return false;
31
+ return key === storedKey;
32
+ }
33
+
34
+ // ── Response Helper ───────────────────────
35
+
36
+ function jsonResponse(data: any): GoogleAppsScript.Content.TextOutput {
37
+ return ContentService
38
+ .createTextOutput(JSON.stringify(data))
39
+ .setMimeType(ContentService.MimeType.JSON);
40
+ }
41
+
42
+ // ── DB Instance Helper ──────────────────────
43
+
44
+ function getDb(logger: any): any {
45
+ const systemSpreadsheetId = DEPLOYER_CONFIG.systemSpreadsheetId;
46
+ const tables = typeof DB_SCHEMA !== 'undefined' ? DB_SCHEMA : {};
47
+
48
+ return new DBV2({
49
+ name: DEPLOYER_CONFIG.name,
50
+ systemSpreadsheetId,
51
+ tables,
52
+ loggingVerbosity: DEPLOYER_CONFIG.loggingVerbosity
53
+ } as DBConfigV2, logger);
54
+ }
55
+
56
+ // ── POST Handler ──────────────────────────
57
+
58
+ function doPost(e: GoogleAppsScript.Events.DoPost): GoogleAppsScript.Content.TextOutput {
59
+ const logger = getLogger();
60
+
61
+ try {
62
+ const body = JSON.parse(e?.postData?.contents || '{}');
63
+
64
+ // Validate API key
65
+ if (!validateApiKey(body.apiKey)) {
66
+ logger.warn('doPost', 'Unauthorized request — invalid API key');
67
+ return jsonResponse({ error: 'Unauthorized: invalid API key' });
68
+ }
69
+
70
+ // ── DDL Admin Actions (per-script ops only) ────
71
+ // Generic DDL operations (setup, provision, sync, etc.) are handled
72
+ // by the shared DDL handler web app. This endpoint only handles
73
+ // per-script operations that need PropertiesService / CacheService / ScriptApp.
74
+ if (body.ddlAction) {
75
+ const adminKey = DEPLOYER_CONFIG.ddlAdminKey;
76
+ if (!adminKey || body.ddlKey !== adminKey) {
77
+ return jsonResponse({ error: 'Unauthorized: invalid DDL admin key' });
78
+ }
79
+ const allowed = [
80
+ 'ddlRotateApiKey', 'ddlSetApiKey', 'ddlGetApiKey',
81
+ 'ddlCacheSchema', 'ddlGetCachedSchema', 'ddlInvalidateCache', 'ddlRefreshCache',
82
+ 'ddlSetupCacheTrigger', 'ddlRemoveCacheTrigger'
83
+ ];
84
+ if (!allowed.includes(body.ddlAction)) {
85
+ return jsonResponse({ error: `DDL action "${body.ddlAction}" not available on this endpoint` });
86
+ }
87
+ const fn = (globalThis as any)[body.ddlAction];
88
+ if (typeof fn !== 'function') {
89
+ return jsonResponse({ error: `Unknown DDL function: ${body.ddlAction}` });
90
+ }
91
+ return jsonResponse(fn(body.params));
92
+ }
93
+
94
+ const action = body.action;
95
+ if (!action) {
96
+ return jsonResponse({ error: 'Missing "action" parameter' });
97
+ }
98
+
99
+ // ── Status ──────────────────────────
100
+ if (action === 'status') {
101
+ return jsonResponse({
102
+ status: 'ok',
103
+ name: DEPLOYER_CONFIG.name,
104
+ env: DEPLOYER_CONFIG.env,
105
+ instanceType: DEPLOYER_CONFIG.instanceType<% if (rbac_enabled) { %>,
106
+ rbac: true,
107
+ authMode: '<%- auth_mode %>'<% } %><% if (views_enabled) { %>,
108
+ views: [<%- view_names.map(v => `'${v}'`).join(', ') %>]<% } %>
109
+ });
110
+ }
111
+
112
+ <% if (rbac_enabled) { -%>
113
+ // ── Resolve User ─────────────────────
114
+ const db = getDb(logger);
115
+ const currentUser = resolveUser(body, db);
116
+ if (!currentUser) {
117
+ logger.warn('doPost', 'RBAC: could not resolve user identity');
118
+ return jsonResponse({ error: 'Unauthorized: user identity required' });
119
+ }
120
+ <% } else { -%>
121
+ const db = getDb(logger);
122
+ <% } -%>
123
+
124
+ // ══════════════════════════════════════
125
+ // CAPABILITY-BASED ROUTING
126
+ //
127
+ // All permissions use unified hasCapability() check:
128
+ // "table:action" — table CRUD (e.g. "entries:list")
129
+ // "view:name" — SQL views (e.g. "view:activeUsers")
130
+ // "methodName" — custom methods (e.g. "getStats")
131
+ // ══════════════════════════════════════
132
+
133
+ // ── Table CRUD ───────────────────────
134
+ if (body.table) {
135
+ return handleCrudAction(action, body, db, logger<%- rbac_enabled ? ', currentUser' : '' %>);
136
+ }
137
+
138
+ <% if (views_enabled) { -%>
139
+ // ── SQL Views ────────────────────────
140
+ if (action === 'listViews') {
141
+ <% if (rbac_enabled) { -%>
142
+ const viewNames = Object.keys(VIEWS_CONFIG || {});
143
+ const available = viewNames.filter(v => hasCapability(currentUser.role, 'view:' + v));
144
+ return jsonResponse({ views: available });
145
+ <% } else { -%>
146
+ return jsonResponse({ views: Object.keys(VIEWS_CONFIG || {}) });
147
+ <% } -%>
148
+ }
149
+
150
+ <% for (const viewName of view_names) { -%>
151
+ if (action === '<%- viewName %>') {
152
+ <% if (rbac_enabled) { -%>
153
+ if (!hasCapability(currentUser.role, 'view:<%- viewName %>')) {
154
+ return jsonResponse({ error: 'Forbidden: missing capability "view:<%- viewName %>"' });
155
+ }
156
+ return jsonResponse(executeView('<%- viewName %>', db, body.params, currentUser));
157
+ <% } else { -%>
158
+ return jsonResponse(executeView('<%- viewName %>', db, body.params));
159
+ <% } -%>
160
+ }
161
+
162
+ <% } -%>
163
+ <% } -%>
164
+ // ── Custom Methods ───────────────────
165
+ <% if (custom_methods && custom_methods.length > 0) { -%>
166
+ <% for (const method of custom_methods) { -%>
167
+ if (action === '<%- method %>') {
168
+ <% if (rbac_enabled) { -%>
169
+ if (!hasCapability(currentUser.role, '<%- method %>')) {
170
+ return jsonResponse({ error: `Forbidden: missing capability "<%- method %>"` });
171
+ }
172
+ return handle_<%- method %>(body, db, logger, currentUser);
173
+ <% } else { -%>
174
+ return handle_<%- method %>(body, db, logger);
175
+ <% } -%>
176
+ }
177
+
178
+ <% } -%>
179
+ <% } -%>
180
+ return jsonResponse({ error: `Unknown action: ${action}` });
181
+
182
+ } catch (err: any) {
183
+ logger.warn('doPost', 'Request failed', { error: String(err) });
184
+ return jsonResponse({ error: err.message || String(err) });
185
+ }
186
+ }
187
+
188
+ // ── CRUD Handler ──────────────────────────
189
+
190
+ <% const isReader = instance_type === 'read-only' || instance_type === 'read-write'; -%>
191
+ <% const isWriter = instance_type === 'write-only' || instance_type === 'read-write'; -%>
192
+
193
+ function handleCrudAction(action: string, body: any, db: any, logger: any<%- rbac_enabled ? ', user: { email: string; role: string; id: string }' : '' %>): GoogleAppsScript.Content.TextOutput {
194
+ const tableName = body.table;
195
+
196
+ const table = db.table(tableName);
197
+ if (!table) {
198
+ return jsonResponse({ error: `Table "${tableName}" not found` });
199
+ }
200
+
201
+ <% if (rbac_enabled) { -%>
202
+ // Capability check: "tableName:action"
203
+ if (!hasCapability(user.role, tableName + ':' + action)) {
204
+ return jsonResponse({ error: `Forbidden: missing capability "${tableName}:${action}"` });
205
+ }
206
+
207
+ // Row-level filter
208
+ const rbacFilter = resolveFilter(user.role, tableName, user);
209
+ const hasRbacFilter = Object.keys(rbacFilter).length > 0;
210
+
211
+ <% } -%>
212
+ switch (action) {
213
+ <% if (isReader) { -%>
214
+ case 'list': {
215
+ <% if (rbac_enabled) { -%>
216
+ const userOptions = body.options || {};
217
+ const userFilters = userOptions.filters || {};
218
+ const mergedOptions = { ...userOptions, filters: { ...userFilters, ...rbacFilter } };
219
+ return jsonResponse(table.list(mergedOptions));
220
+ <% } else { -%>
221
+ return jsonResponse(table.list(body.options || {}));
222
+ <% } -%>
223
+ }
224
+
225
+ case 'get': {
226
+ if (!body.id) return jsonResponse({ error: 'Missing "id" for get action' });
227
+ <% if (rbac_enabled) { -%>
228
+ const result = table.get(body.id);
229
+ if (hasRbacFilter && result?.data && !matchesFilter(result.data, rbacFilter)) {
230
+ return jsonResponse({ error: `Record not found: ${body.id}` });
231
+ }
232
+ return jsonResponse(result);
233
+ <% } else { -%>
234
+ return jsonResponse(table.get(body.id));
235
+ <% } -%>
236
+ }
237
+
238
+ <% } -%>
239
+ <% if (isWriter) { -%>
240
+ case 'create':
241
+ if (!body.data) return jsonResponse({ error: 'Missing "data" for create action' });
242
+ return jsonResponse(table.create(body.data));
243
+
244
+ case 'update': {
245
+ if (!body.id) return jsonResponse({ error: 'Missing "id" for update action' });
246
+ if (!body.data) return jsonResponse({ error: 'Missing "data" for update action' });
247
+ <% if (rbac_enabled) { -%>
248
+ if (hasRbacFilter) {
249
+ const existing = table.get(body.id);
250
+ if (!existing?.data || !matchesFilter(existing.data, rbacFilter)) {
251
+ return jsonResponse({ error: `Record not found: ${body.id}` });
252
+ }
253
+ }
254
+ <% } -%>
255
+ return jsonResponse(table.update(body.id, body.data));
256
+ }
257
+
258
+ case 'delete': {
259
+ if (!body.id) return jsonResponse({ error: 'Missing "id" for delete action' });
260
+ <% if (rbac_enabled) { -%>
261
+ if (hasRbacFilter) {
262
+ const existing = table.get(body.id);
263
+ if (!existing?.data || !matchesFilter(existing.data, rbacFilter)) {
264
+ return jsonResponse({ error: `Record not found: ${body.id}` });
265
+ }
266
+ }
267
+ <% } -%>
268
+ return jsonResponse(table.delete(body.id));
269
+ }
270
+
271
+ <% } -%>
272
+ <% if (views_enabled) { -%>
273
+ case 'query':
274
+ if (!body.view) return jsonResponse({ error: 'Missing "view" for query action' });
275
+ <% if (rbac_enabled) { -%>
276
+ if (!hasCapability(user.role, 'view:' + body.view)) {
277
+ return jsonResponse({ error: `Forbidden: missing capability "view:${body.view}"` });
278
+ }
279
+ return jsonResponse(executeView(body.view, db, body.params, user));
280
+ <% } else { -%>
281
+ return jsonResponse(executeView(body.view, db, body.params));
282
+ <% } -%>
283
+
284
+ <% } -%>
285
+ default:
286
+ return jsonResponse({ error: `Unknown or disallowed action "${action}" for instance type "<%- instance_type %>"` });
287
+ }
288
+ }
@@ -0,0 +1,148 @@
1
+ // ========================================
2
+ // RBAC Middleware — Auto-generated by nsa-sheets-db-builder
3
+ // Auth mode: <%- auth_mode %>
4
+ // ========================================
5
+
6
+ /**
7
+ * RBAC role definitions — loaded from rbac.json at build time.
8
+ *
9
+ * Per-role config:
10
+ * capabilities — unified permission list (what you CAN DO)
11
+ * filter — per-table user ownership field (what you CAN SEE)
12
+ *
13
+ * Capability notation:
14
+ * "entries:list" — specific table + action
15
+ * "entries:*" — all actions on a table
16
+ * "*:list" — action on all tables
17
+ * "*:*" — all table actions
18
+ * "view:myEntries" — specific SQL view
19
+ * "view:*" — all SQL views
20
+ * "getStats" — custom method (no colon)
21
+ * "*" — all custom methods
22
+ *
23
+ * Filter notation (privacy — row-level ownership):
24
+ * "entries": "author_id" — records filtered by author_id = user.id
25
+ * Only declares the user field. For complex filtering, use views or custom methods.
26
+ */
27
+ var RBAC_CONFIG = <%- JSON.stringify(rbac_config, null, 2) %>;
28
+
29
+ // ── Auth ─────────────────────────────────
30
+
31
+ /**
32
+ * Resolve the current user identity based on auth mode.
33
+ *
34
+ * @param body - The parsed request body
35
+ * @param db - DBV2 instance (for user/role lookups)
36
+ * @returns {{ email: string, role: string, id: string } | null}
37
+ */
38
+ function resolveUser(body: any, db: any): { email: string; role: string; id: string } | null {
39
+ <% if (auth_mode === 'noAuth') { -%>
40
+ return { email: 'system', role: 'admin', id: 'system' };
41
+ <% } else if (auth_mode === 'google') { -%>
42
+ try {
43
+ const email = Session.getActiveUser().getEmail();
44
+ if (!email) return null;
45
+
46
+ const usersTable = db.table('users');
47
+ if (!usersTable) return null;
48
+
49
+ const allUsers = usersTable.list({ filters: { email: email, __archived__: false } });
50
+ const user = allUsers?.data?.[0];
51
+
52
+ if (!user) {
53
+ return { email, role: RBAC_CONFIG.defaultRole || 'viewer', id: '' };
54
+ }
55
+
56
+ return { email, role: user.role || RBAC_CONFIG.defaultRole || 'viewer', id: user.id || '' };
57
+ } catch (e) {
58
+ return null;
59
+ }
60
+ <% } else { -%>
61
+ if (!body.user || !body.user.email) return null;
62
+
63
+ const email = body.user.email;
64
+ const passphrase = body.user.passphrase || '';
65
+
66
+ const usersTable = db.table('users');
67
+ if (!usersTable) return null;
68
+
69
+ const allUsers = usersTable.list({ filters: { email: email, __archived__: false } });
70
+ const user = allUsers?.data?.[0];
71
+
72
+ if (!user) return null;
73
+ if (user.passphrase && user.passphrase !== passphrase) return null;
74
+
75
+ return { email, role: user.role || RBAC_CONFIG.defaultRole || 'viewer', id: user.id || '' };
76
+ <% } -%>
77
+ }
78
+
79
+ // ── Capabilities ─────────────────────────
80
+
81
+ /**
82
+ * Check if a role has a capability.
83
+ *
84
+ * For table operations (contains ":"):
85
+ * "entries:list" matches: entries:list, entries:*, *:list, *:*
86
+ *
87
+ * For custom methods (no ":"):
88
+ * "getStats" matches: getStats, *
89
+ *
90
+ * @param role - Role name
91
+ * @param capability - "table:action" or "methodName"
92
+ */
93
+ function hasCapability(role: string, capability: string): boolean {
94
+ const roleDef = RBAC_CONFIG.roles?.[role];
95
+ if (!roleDef || !Array.isArray(roleDef.capabilities)) return false;
96
+
97
+ const caps = roleDef.capabilities;
98
+
99
+ // Direct match
100
+ if (caps.includes(capability)) return true;
101
+
102
+ const colonIdx = capability.indexOf(':');
103
+
104
+ if (colonIdx === -1) {
105
+ // Custom method — check method wildcard
106
+ return caps.includes('*');
107
+ }
108
+
109
+ // Table capability — check wildcard patterns
110
+ const table = capability.substring(0, colonIdx);
111
+ const action = capability.substring(colonIdx + 1);
112
+
113
+ return caps.includes(table + ':*') ||
114
+ caps.includes('*:' + action) ||
115
+ caps.includes('*:*');
116
+ }
117
+
118
+ // ── Privacy (row-level ownership) ────────
119
+
120
+ /**
121
+ * Resolve the user-ownership filter for a role + table.
122
+ *
123
+ * Filter config is a simple mapping: table name → user field name.
124
+ * "entries": "author_id" → { author_id: user.id }
125
+ *
126
+ * Returns empty object if no filter applies (no row-level restriction).
127
+ * For complex filtering logic, use SQL views or custom methods instead.
128
+ */
129
+ function resolveFilter(role: string, tableName: string, user: { email: string; role: string; id: string }): Record<string, any> {
130
+ const roleDef = RBAC_CONFIG.roles?.[role];
131
+ if (!roleDef || !roleDef.filter) return {};
132
+
133
+ const userField = roleDef.filter[tableName];
134
+ if (!userField || typeof userField !== 'string') return {};
135
+
136
+ return { [userField]: user.id };
137
+ }
138
+
139
+ /**
140
+ * Check if a record belongs to the user (ownership check).
141
+ * Used for get/update/delete post-fetch validation.
142
+ */
143
+ function matchesFilter(record: Record<string, any>, filter: Record<string, any>): boolean {
144
+ for (const key of Object.keys(filter)) {
145
+ if (record[key] !== filter[key]) return false;
146
+ }
147
+ return true;
148
+ }
@@ -0,0 +1,92 @@
1
+ // ========================================
2
+ // Views Runtime — Auto-generated by nsa-sheets-db-builder
3
+ // SQL views via alasql, with optional RBAC gating
4
+ // ========================================
5
+
6
+ /**
7
+ * View definitions — loaded from views.json at build time.
8
+ * Each view: { query, description?, roles?, params? }
9
+ */
10
+ var VIEWS_CONFIG: Record<string, any> = <%- JSON.stringify(views_config, null, 2) %>;
11
+
12
+ /**
13
+ * Execute a named view query against in-memory table data.
14
+ *
15
+ * Flow:
16
+ * 1. Load all referenced tables into alasql in-memory DB
17
+ * 2. Merge params: client params + server-injected $user.* (user wins — can't be spoofed)
18
+ * 3. Execute the view's SQL query with {{param}} substitution
19
+ * 4. Return the result set
20
+ *
21
+ * @param viewName - The view name (key in VIEWS_CONFIG)
22
+ * @param db - DBV2 instance
23
+ * @param params - Optional query parameters (for parameterized views)
24
+ * @param user - Optional authenticated user (injected by RBAC middleware)
25
+ * @returns { data: any[], viewName: string } | { error: string }
26
+ */
27
+ function executeView(viewName: string, db: any, params?: Record<string, any>, user?: { email: string; role: string; id: string }): any {
28
+ const viewDef = VIEWS_CONFIG[viewName];
29
+ if (!viewDef) {
30
+ return { error: `View "${viewName}" not found` };
31
+ }
32
+
33
+ try {
34
+ // Get all table names referenced in the schema
35
+ const schema = typeof DB_SCHEMA !== 'undefined' ? DB_SCHEMA : {};
36
+ const tableNames = Object.keys(schema);
37
+
38
+ // Load tables into alasql
39
+ for (const tableName of tableNames) {
40
+ const table = db.table(tableName);
41
+ if (!table) continue;
42
+
43
+ const result = table.list({});
44
+ const rows = result?.data || [];
45
+
46
+ // Drop if exists, then create and insert
47
+ try { alasql(`DROP TABLE IF EXISTS ${tableName}`); } catch (_) {}
48
+ if (rows.length > 0) {
49
+ alasql(`CREATE TABLE ${tableName}`);
50
+ alasql.tables[tableName].data = rows;
51
+ } else {
52
+ alasql(`CREATE TABLE ${tableName}`);
53
+ }
54
+ }
55
+
56
+ // Execute the view query
57
+ let query = viewDef.query;
58
+
59
+ // Merge params: client-supplied first, then $user.* (server-side, can't be spoofed)
60
+ const queryParams: Record<string, any> = { ...(params || {}) };
61
+ if (user) {
62
+ queryParams['$user.email'] = user.email;
63
+ queryParams['$user.id'] = user.id;
64
+ queryParams['$user.role'] = user.role;
65
+ }
66
+
67
+ // Parameter substitution: {{paramName}} → value
68
+ for (const [key, value] of Object.entries(queryParams)) {
69
+ query = query.replace(new RegExp(`\\{\\{${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}\\}`, 'g'), String(value));
70
+ }
71
+
72
+ const data = alasql(query);
73
+
74
+ return { data, viewName, rowCount: data.length };
75
+ } catch (err: any) {
76
+ return { error: `View query failed: ${err.message || String(err)}` };
77
+ }
78
+ }
79
+
80
+ /**
81
+ * List all available view names (optionally filtered by RBAC role).
82
+ */
83
+ function listViews(role?: string): string[] {
84
+ const viewNames = Object.keys(VIEWS_CONFIG);
85
+ if (!role) return viewNames;
86
+
87
+ return viewNames.filter(name => {
88
+ const viewDef = VIEWS_CONFIG[name];
89
+ if (!viewDef.roles || viewDef.roles.length === 0) return true;
90
+ return viewDef.roles.includes(role);
91
+ });
92
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "blank",
3
+ "description": "Minimal starter — one example table",
4
+ "tables": {
5
+ "items": {
6
+ "spreadsheetId": "__new__",
7
+ "sheetName": "items",
8
+ "schema": {
9
+ "id": {
10
+ "type": "string",
11
+ "primaryKey": true,
12
+ "required": true
13
+ },
14
+ "name": {
15
+ "type": "string",
16
+ "required": true
17
+ },
18
+ "description": {
19
+ "type": "string"
20
+ },
21
+ "__audit__": {
22
+ "type": "object"
23
+ },
24
+ "__archived__": {
25
+ "type": "boolean",
26
+ "defaultValue": false
27
+ }
28
+ },
29
+ "idGenerator": "UUID",
30
+ "deleteMode": "soft"
31
+ }
32
+ }
33
+ }