writbase 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 +121 -0
- package/dist/writbase.js +707 -0
- package/migrations/00001_enums.sql +7 -0
- package/migrations/00002_core_tables.sql +105 -0
- package/migrations/00003_constraints_indexes.sql +69 -0
- package/migrations/00004_rls_policies.sql +169 -0
- package/migrations/00005_seed_app_settings.sql +2 -0
- package/migrations/00006_rate_limit_rpc.sql +14 -0
- package/migrations/00007_rate_limit_rpc_search_path.sql +14 -0
- package/migrations/00008_indexes_and_cleanup.sql +22 -0
- package/migrations/00009_atomic_mutations.sql +175 -0
- package/migrations/00010_user_rate_limits.sql +52 -0
- package/migrations/00011_atomic_permission_update.sql +31 -0
- package/migrations/00012_pg_cron_cleanup.sql +27 -0
- package/migrations/00013_max_agent_keys.sql +4 -0
- package/migrations/00014_event_log_indexes.sql +2 -0
- package/migrations/00015_auth_rate_limits.sql +69 -0
- package/migrations/00016_request_log.sql +42 -0
- package/migrations/00017_task_search.sql +58 -0
- package/migrations/00018_inter_agent_exchange.sql +423 -0
- package/migrations/00019_workspaces.sql +782 -0
- package/migrations/00020_can_comment.sql +46 -0
- package/migrations/00021_webhook_delivery.sql +125 -0
- package/migrations/00022_task_archive.sql +380 -0
- package/migrations/00023_get_top_tasks.sql +21 -0
- package/migrations/00024_agent_key_defaults.sql +55 -0
- package/migrations/00025_production_automation.sql +353 -0
- package/migrations/00026_top_tasks_exclude_terminal.sql +19 -0
- package/migrations/00027_rename_agent_key_defaults.sql +51 -0
- package/package.json +34 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
CREATE TYPE priority AS ENUM ('low', 'medium', 'high', 'critical');
|
|
2
|
+
CREATE TYPE status AS ENUM ('todo', 'in_progress', 'blocked', 'done', 'cancelled');
|
|
3
|
+
CREATE TYPE actor_type AS ENUM ('human', 'agent', 'system');
|
|
4
|
+
CREATE TYPE source AS ENUM ('ui', 'mcp', 'api', 'system');
|
|
5
|
+
CREATE TYPE event_category AS ENUM ('task', 'admin', 'system');
|
|
6
|
+
CREATE TYPE target_type AS ENUM ('task', 'agent_key', 'project', 'department');
|
|
7
|
+
CREATE TYPE agent_role AS ENUM ('worker', 'manager');
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
-- projects
|
|
2
|
+
CREATE TABLE projects (
|
|
3
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4
|
+
name text UNIQUE NOT NULL,
|
|
5
|
+
slug text UNIQUE NOT NULL,
|
|
6
|
+
is_archived boolean DEFAULT false NOT NULL,
|
|
7
|
+
created_at timestamptz DEFAULT now() NOT NULL,
|
|
8
|
+
created_by uuid REFERENCES auth.users(id)
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
-- departments
|
|
12
|
+
CREATE TABLE departments (
|
|
13
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
14
|
+
name text UNIQUE NOT NULL,
|
|
15
|
+
slug text UNIQUE NOT NULL,
|
|
16
|
+
is_archived boolean DEFAULT false NOT NULL,
|
|
17
|
+
created_at timestamptz DEFAULT now() NOT NULL,
|
|
18
|
+
created_by uuid REFERENCES auth.users(id)
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
-- tasks
|
|
22
|
+
CREATE TABLE tasks (
|
|
23
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
24
|
+
project_id uuid NOT NULL REFERENCES projects(id),
|
|
25
|
+
department_id uuid REFERENCES departments(id),
|
|
26
|
+
priority priority DEFAULT 'medium' NOT NULL,
|
|
27
|
+
description text NOT NULL,
|
|
28
|
+
notes text,
|
|
29
|
+
due_date timestamptz,
|
|
30
|
+
status status DEFAULT 'todo' NOT NULL,
|
|
31
|
+
version integer DEFAULT 1 NOT NULL,
|
|
32
|
+
created_at timestamptz DEFAULT now() NOT NULL,
|
|
33
|
+
updated_at timestamptz DEFAULT now() NOT NULL,
|
|
34
|
+
created_by_type actor_type NOT NULL,
|
|
35
|
+
created_by_id text NOT NULL,
|
|
36
|
+
updated_by_type actor_type NOT NULL,
|
|
37
|
+
updated_by_id text NOT NULL,
|
|
38
|
+
source source NOT NULL
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- event_log
|
|
42
|
+
CREATE TABLE event_log (
|
|
43
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
44
|
+
event_category event_category NOT NULL,
|
|
45
|
+
target_type target_type NOT NULL,
|
|
46
|
+
target_id uuid NOT NULL,
|
|
47
|
+
event_type text NOT NULL,
|
|
48
|
+
field_name text,
|
|
49
|
+
old_value jsonb,
|
|
50
|
+
new_value jsonb,
|
|
51
|
+
actor_type actor_type NOT NULL,
|
|
52
|
+
actor_id text NOT NULL,
|
|
53
|
+
actor_label text NOT NULL,
|
|
54
|
+
source source NOT NULL,
|
|
55
|
+
created_at timestamptz DEFAULT now() NOT NULL
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- agent_keys
|
|
59
|
+
CREATE TABLE agent_keys (
|
|
60
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
61
|
+
name text NOT NULL,
|
|
62
|
+
role agent_role DEFAULT 'worker' NOT NULL,
|
|
63
|
+
key_hash text NOT NULL,
|
|
64
|
+
key_prefix text NOT NULL,
|
|
65
|
+
is_active boolean DEFAULT true NOT NULL,
|
|
66
|
+
special_prompt text,
|
|
67
|
+
created_at timestamptz DEFAULT now() NOT NULL,
|
|
68
|
+
last_used_at timestamptz,
|
|
69
|
+
created_by text NOT NULL
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
-- agent_permissions
|
|
73
|
+
CREATE TABLE agent_permissions (
|
|
74
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
75
|
+
agent_key_id uuid NOT NULL REFERENCES agent_keys(id) ON DELETE CASCADE,
|
|
76
|
+
project_id uuid NOT NULL REFERENCES projects(id),
|
|
77
|
+
department_id uuid REFERENCES departments(id),
|
|
78
|
+
can_read boolean DEFAULT false NOT NULL,
|
|
79
|
+
can_create boolean DEFAULT false NOT NULL,
|
|
80
|
+
can_update boolean DEFAULT false NOT NULL,
|
|
81
|
+
created_at timestamptz DEFAULT now() NOT NULL,
|
|
82
|
+
UNIQUE NULLS NOT DISTINCT (agent_key_id, project_id, department_id)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
-- app_settings
|
|
86
|
+
CREATE TABLE app_settings (
|
|
87
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
88
|
+
department_required boolean DEFAULT false NOT NULL,
|
|
89
|
+
require_human_approval_for_agent_keys boolean DEFAULT false NOT NULL,
|
|
90
|
+
created_at timestamptz DEFAULT now() NOT NULL,
|
|
91
|
+
updated_at timestamptz DEFAULT now() NOT NULL
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
-- rate_limits
|
|
95
|
+
CREATE TABLE rate_limits (
|
|
96
|
+
agent_key_id uuid NOT NULL REFERENCES agent_keys(id),
|
|
97
|
+
window_start timestamptz NOT NULL,
|
|
98
|
+
request_count integer DEFAULT 1 NOT NULL,
|
|
99
|
+
UNIQUE (agent_key_id, window_start)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
-- admin_users
|
|
103
|
+
CREATE TABLE admin_users (
|
|
104
|
+
user_id uuid REFERENCES auth.users(id) PRIMARY KEY
|
|
105
|
+
);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
-- Task indexes
|
|
2
|
+
CREATE INDEX idx_tasks_project_dept_status ON tasks(project_id, department_id, status);
|
|
3
|
+
CREATE INDEX idx_tasks_project_created_id ON tasks(project_id, created_at, id);
|
|
4
|
+
CREATE INDEX idx_tasks_updated_at ON tasks(updated_at);
|
|
5
|
+
|
|
6
|
+
-- Event log indexes
|
|
7
|
+
CREATE INDEX idx_event_log_target ON event_log(target_id, event_category);
|
|
8
|
+
CREATE INDEX idx_event_log_created_at ON event_log(created_at);
|
|
9
|
+
|
|
10
|
+
-- Agent permissions index
|
|
11
|
+
CREATE INDEX idx_agent_permissions_key ON agent_permissions(agent_key_id);
|
|
12
|
+
|
|
13
|
+
-- Rate limits index
|
|
14
|
+
CREATE INDEX idx_rate_limits_key_window ON rate_limits(agent_key_id, window_start);
|
|
15
|
+
|
|
16
|
+
-- Description min length check
|
|
17
|
+
ALTER TABLE tasks ADD CONSTRAINT chk_description_min_length CHECK (char_length(description) >= 3);
|
|
18
|
+
|
|
19
|
+
-- Revoke mutation on event_log for non-service roles
|
|
20
|
+
REVOKE UPDATE, DELETE ON event_log FROM anon, authenticated;
|
|
21
|
+
|
|
22
|
+
-- Cursor pagination RPC
|
|
23
|
+
CREATE OR REPLACE FUNCTION get_tasks_page(
|
|
24
|
+
p_project_id uuid,
|
|
25
|
+
p_department_id uuid DEFAULT NULL,
|
|
26
|
+
p_status status DEFAULT NULL,
|
|
27
|
+
p_priority priority DEFAULT NULL,
|
|
28
|
+
p_updated_after timestamptz DEFAULT NULL,
|
|
29
|
+
p_cursor_created_at timestamptz DEFAULT NULL,
|
|
30
|
+
p_cursor_id uuid DEFAULT NULL,
|
|
31
|
+
p_limit integer DEFAULT 20
|
|
32
|
+
)
|
|
33
|
+
RETURNS TABLE (
|
|
34
|
+
id uuid,
|
|
35
|
+
project_id uuid,
|
|
36
|
+
department_id uuid,
|
|
37
|
+
priority priority,
|
|
38
|
+
description text,
|
|
39
|
+
notes text,
|
|
40
|
+
due_date timestamptz,
|
|
41
|
+
status status,
|
|
42
|
+
version integer,
|
|
43
|
+
created_at timestamptz,
|
|
44
|
+
updated_at timestamptz,
|
|
45
|
+
created_by_type actor_type,
|
|
46
|
+
created_by_id text,
|
|
47
|
+
updated_by_type actor_type,
|
|
48
|
+
updated_by_id text,
|
|
49
|
+
source source
|
|
50
|
+
)
|
|
51
|
+
LANGUAGE sql
|
|
52
|
+
STABLE
|
|
53
|
+
AS $$
|
|
54
|
+
SELECT t.id, t.project_id, t.department_id, t.priority, t.description,
|
|
55
|
+
t.notes, t.due_date, t.status, t.version, t.created_at, t.updated_at,
|
|
56
|
+
t.created_by_type, t.created_by_id, t.updated_by_type, t.updated_by_id, t.source
|
|
57
|
+
FROM tasks t
|
|
58
|
+
WHERE t.project_id = p_project_id
|
|
59
|
+
AND (p_department_id IS NULL OR t.department_id = p_department_id)
|
|
60
|
+
AND (p_status IS NULL OR t.status = p_status)
|
|
61
|
+
AND (p_priority IS NULL OR t.priority = p_priority)
|
|
62
|
+
AND (p_updated_after IS NULL OR t.updated_at > p_updated_after)
|
|
63
|
+
AND (
|
|
64
|
+
p_cursor_created_at IS NULL
|
|
65
|
+
OR (t.created_at, t.id) > (p_cursor_created_at, p_cursor_id)
|
|
66
|
+
)
|
|
67
|
+
ORDER BY t.created_at ASC, t.id ASC
|
|
68
|
+
LIMIT LEAST(p_limit, 50);
|
|
69
|
+
$$;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
-- Enable RLS on all tables
|
|
2
|
+
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
|
3
|
+
ALTER TABLE departments ENABLE ROW LEVEL SECURITY;
|
|
4
|
+
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
|
|
5
|
+
ALTER TABLE event_log ENABLE ROW LEVEL SECURITY;
|
|
6
|
+
ALTER TABLE agent_keys ENABLE ROW LEVEL SECURITY;
|
|
7
|
+
ALTER TABLE agent_permissions ENABLE ROW LEVEL SECURITY;
|
|
8
|
+
ALTER TABLE app_settings ENABLE ROW LEVEL SECURITY;
|
|
9
|
+
ALTER TABLE rate_limits ENABLE ROW LEVEL SECURITY;
|
|
10
|
+
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
|
|
11
|
+
|
|
12
|
+
-- Helper: admin check expression
|
|
13
|
+
-- auth.uid() IN (SELECT user_id FROM admin_users)
|
|
14
|
+
|
|
15
|
+
-- projects: full CRUD for admins
|
|
16
|
+
CREATE POLICY "admin_select_projects" ON projects
|
|
17
|
+
FOR SELECT TO authenticated
|
|
18
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
19
|
+
|
|
20
|
+
CREATE POLICY "admin_insert_projects" ON projects
|
|
21
|
+
FOR INSERT TO authenticated
|
|
22
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
23
|
+
|
|
24
|
+
CREATE POLICY "admin_update_projects" ON projects
|
|
25
|
+
FOR UPDATE TO authenticated
|
|
26
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users))
|
|
27
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
28
|
+
|
|
29
|
+
CREATE POLICY "admin_delete_projects" ON projects
|
|
30
|
+
FOR DELETE TO authenticated
|
|
31
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
32
|
+
|
|
33
|
+
-- departments: full CRUD for admins
|
|
34
|
+
CREATE POLICY "admin_select_departments" ON departments
|
|
35
|
+
FOR SELECT TO authenticated
|
|
36
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
37
|
+
|
|
38
|
+
CREATE POLICY "admin_insert_departments" ON departments
|
|
39
|
+
FOR INSERT TO authenticated
|
|
40
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
41
|
+
|
|
42
|
+
CREATE POLICY "admin_update_departments" ON departments
|
|
43
|
+
FOR UPDATE TO authenticated
|
|
44
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users))
|
|
45
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
46
|
+
|
|
47
|
+
CREATE POLICY "admin_delete_departments" ON departments
|
|
48
|
+
FOR DELETE TO authenticated
|
|
49
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
50
|
+
|
|
51
|
+
-- tasks: full CRUD for admins
|
|
52
|
+
CREATE POLICY "admin_select_tasks" ON tasks
|
|
53
|
+
FOR SELECT TO authenticated
|
|
54
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
55
|
+
|
|
56
|
+
CREATE POLICY "admin_insert_tasks" ON tasks
|
|
57
|
+
FOR INSERT TO authenticated
|
|
58
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
59
|
+
|
|
60
|
+
CREATE POLICY "admin_update_tasks" ON tasks
|
|
61
|
+
FOR UPDATE TO authenticated
|
|
62
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users))
|
|
63
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
64
|
+
|
|
65
|
+
CREATE POLICY "admin_delete_tasks" ON tasks
|
|
66
|
+
FOR DELETE TO authenticated
|
|
67
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
68
|
+
|
|
69
|
+
-- agent_keys: full CRUD for admins
|
|
70
|
+
CREATE POLICY "admin_select_agent_keys" ON agent_keys
|
|
71
|
+
FOR SELECT TO authenticated
|
|
72
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
73
|
+
|
|
74
|
+
CREATE POLICY "admin_insert_agent_keys" ON agent_keys
|
|
75
|
+
FOR INSERT TO authenticated
|
|
76
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
77
|
+
|
|
78
|
+
CREATE POLICY "admin_update_agent_keys" ON agent_keys
|
|
79
|
+
FOR UPDATE TO authenticated
|
|
80
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users))
|
|
81
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
82
|
+
|
|
83
|
+
CREATE POLICY "admin_delete_agent_keys" ON agent_keys
|
|
84
|
+
FOR DELETE TO authenticated
|
|
85
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
86
|
+
|
|
87
|
+
-- agent_permissions: full CRUD for admins
|
|
88
|
+
CREATE POLICY "admin_select_agent_permissions" ON agent_permissions
|
|
89
|
+
FOR SELECT TO authenticated
|
|
90
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
91
|
+
|
|
92
|
+
CREATE POLICY "admin_insert_agent_permissions" ON agent_permissions
|
|
93
|
+
FOR INSERT TO authenticated
|
|
94
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
95
|
+
|
|
96
|
+
CREATE POLICY "admin_update_agent_permissions" ON agent_permissions
|
|
97
|
+
FOR UPDATE TO authenticated
|
|
98
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users))
|
|
99
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
100
|
+
|
|
101
|
+
CREATE POLICY "admin_delete_agent_permissions" ON agent_permissions
|
|
102
|
+
FOR DELETE TO authenticated
|
|
103
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
104
|
+
|
|
105
|
+
-- app_settings: full CRUD for admins
|
|
106
|
+
CREATE POLICY "admin_select_app_settings" ON app_settings
|
|
107
|
+
FOR SELECT TO authenticated
|
|
108
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
109
|
+
|
|
110
|
+
CREATE POLICY "admin_insert_app_settings" ON app_settings
|
|
111
|
+
FOR INSERT TO authenticated
|
|
112
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
113
|
+
|
|
114
|
+
CREATE POLICY "admin_update_app_settings" ON app_settings
|
|
115
|
+
FOR UPDATE TO authenticated
|
|
116
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users))
|
|
117
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
118
|
+
|
|
119
|
+
CREATE POLICY "admin_delete_app_settings" ON app_settings
|
|
120
|
+
FOR DELETE TO authenticated
|
|
121
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
122
|
+
|
|
123
|
+
-- event_log: SELECT only for admins, INSERT via service_role only (no RLS policy for insert = blocked for anon/authenticated)
|
|
124
|
+
CREATE POLICY "admin_select_event_log" ON event_log
|
|
125
|
+
FOR SELECT TO authenticated
|
|
126
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
127
|
+
|
|
128
|
+
-- rate_limits: no direct access (managed by service_role in Edge Functions)
|
|
129
|
+
-- No policies = all access denied for anon/authenticated; service_role bypasses RLS
|
|
130
|
+
|
|
131
|
+
-- admin_users: SELECT for authenticated (so RLS policies can self-check)
|
|
132
|
+
CREATE POLICY "authenticated_select_admin_users" ON admin_users
|
|
133
|
+
FOR SELECT TO authenticated
|
|
134
|
+
USING (true);
|
|
135
|
+
|
|
136
|
+
-- admin_users: INSERT/UPDATE/DELETE for existing admins only
|
|
137
|
+
CREATE POLICY "admin_insert_admin_users" ON admin_users
|
|
138
|
+
FOR INSERT TO authenticated
|
|
139
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
140
|
+
|
|
141
|
+
CREATE POLICY "admin_update_admin_users" ON admin_users
|
|
142
|
+
FOR UPDATE TO authenticated
|
|
143
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users))
|
|
144
|
+
WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
145
|
+
|
|
146
|
+
CREATE POLICY "admin_delete_admin_users" ON admin_users
|
|
147
|
+
FOR DELETE TO authenticated
|
|
148
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
149
|
+
|
|
150
|
+
-- Append-only trigger on event_log (service_role bypasses RLS but not triggers)
|
|
151
|
+
CREATE FUNCTION prevent_event_log_mutation() RETURNS TRIGGER AS $$
|
|
152
|
+
BEGIN RAISE EXCEPTION 'event_log is append-only: % not permitted', TG_OP; END;
|
|
153
|
+
$$ LANGUAGE plpgsql;
|
|
154
|
+
|
|
155
|
+
CREATE TRIGGER enforce_append_only BEFORE UPDATE OR DELETE ON event_log
|
|
156
|
+
FOR EACH ROW EXECUTE FUNCTION prevent_event_log_mutation();
|
|
157
|
+
|
|
158
|
+
-- updated_at trigger for tasks
|
|
159
|
+
CREATE OR REPLACE FUNCTION update_updated_at()
|
|
160
|
+
RETURNS TRIGGER AS $$
|
|
161
|
+
BEGIN
|
|
162
|
+
NEW.updated_at = now();
|
|
163
|
+
RETURN NEW;
|
|
164
|
+
END;
|
|
165
|
+
$$ LANGUAGE plpgsql;
|
|
166
|
+
|
|
167
|
+
CREATE TRIGGER tasks_updated_at
|
|
168
|
+
BEFORE UPDATE ON tasks
|
|
169
|
+
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- RPC used by the rate-limit Edge Function module.
|
|
2
|
+
-- Atomically increments the per-minute request counter for an agent key.
|
|
3
|
+
CREATE OR REPLACE FUNCTION increment_rate_limit(p_key_id uuid)
|
|
4
|
+
RETURNS integer
|
|
5
|
+
LANGUAGE sql
|
|
6
|
+
VOLATILE
|
|
7
|
+
SECURITY DEFINER
|
|
8
|
+
AS $$
|
|
9
|
+
INSERT INTO rate_limits (agent_key_id, window_start, request_count)
|
|
10
|
+
VALUES (p_key_id, date_trunc('minute', now()), 1)
|
|
11
|
+
ON CONFLICT (agent_key_id, window_start)
|
|
12
|
+
DO UPDATE SET request_count = rate_limits.request_count + 1
|
|
13
|
+
RETURNING request_count;
|
|
14
|
+
$$;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- Fix SECURITY DEFINER function: add SET search_path to prevent schema hijacking.
|
|
2
|
+
CREATE OR REPLACE FUNCTION increment_rate_limit(p_key_id uuid)
|
|
3
|
+
RETURNS integer
|
|
4
|
+
LANGUAGE sql
|
|
5
|
+
VOLATILE
|
|
6
|
+
SECURITY DEFINER
|
|
7
|
+
SET search_path = public
|
|
8
|
+
AS $$
|
|
9
|
+
INSERT INTO rate_limits (agent_key_id, window_start, request_count)
|
|
10
|
+
VALUES (p_key_id, date_trunc('minute', now()), 1)
|
|
11
|
+
ON CONFLICT (agent_key_id, window_start)
|
|
12
|
+
DO UPDATE SET request_count = rate_limits.request_count + 1
|
|
13
|
+
RETURNING request_count;
|
|
14
|
+
$$;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- Add missing indexes for performance (G8)
|
|
2
|
+
CREATE INDEX IF NOT EXISTS idx_event_log_actor_id ON event_log (actor_id);
|
|
3
|
+
CREATE INDEX IF NOT EXISTS idx_agent_permissions_project_dept ON agent_permissions (project_id, department_id);
|
|
4
|
+
|
|
5
|
+
-- RPC function for rate_limits cleanup (G7)
|
|
6
|
+
-- Call via pg_cron: SELECT cleanup_rate_limits();
|
|
7
|
+
-- Recommended schedule: every 15 minutes
|
|
8
|
+
-- Deletes rate_limit windows older than 5 minutes (well past the 1-minute window)
|
|
9
|
+
CREATE OR REPLACE FUNCTION cleanup_rate_limits()
|
|
10
|
+
RETURNS integer
|
|
11
|
+
LANGUAGE sql
|
|
12
|
+
VOLATILE
|
|
13
|
+
SECURITY DEFINER
|
|
14
|
+
SET search_path = public
|
|
15
|
+
AS $$
|
|
16
|
+
WITH deleted AS (
|
|
17
|
+
DELETE FROM rate_limits
|
|
18
|
+
WHERE window_start < now() - interval '5 minutes'
|
|
19
|
+
RETURNING 1
|
|
20
|
+
)
|
|
21
|
+
SELECT count(*)::integer FROM deleted;
|
|
22
|
+
$$;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
-- Atomic task creation: INSERT task + event_log in a single transaction.
|
|
2
|
+
-- Accepts a single jsonb payload to avoid positional parameter bugs.
|
|
3
|
+
CREATE OR REPLACE FUNCTION create_task_with_event(p_payload jsonb)
|
|
4
|
+
RETURNS tasks
|
|
5
|
+
LANGUAGE plpgsql
|
|
6
|
+
SECURITY DEFINER
|
|
7
|
+
SET search_path = public
|
|
8
|
+
AS $$
|
|
9
|
+
DECLARE
|
|
10
|
+
v_project projects%ROWTYPE;
|
|
11
|
+
v_dept departments%ROWTYPE;
|
|
12
|
+
v_task tasks%ROWTYPE;
|
|
13
|
+
v_dept_id uuid;
|
|
14
|
+
BEGIN
|
|
15
|
+
-- 1. Validate project exists and is not archived
|
|
16
|
+
SELECT * INTO v_project
|
|
17
|
+
FROM projects
|
|
18
|
+
WHERE id = (p_payload->>'project_id')::uuid;
|
|
19
|
+
|
|
20
|
+
IF NOT FOUND THEN
|
|
21
|
+
RAISE EXCEPTION 'project_not_found:Project not found';
|
|
22
|
+
END IF;
|
|
23
|
+
|
|
24
|
+
IF v_project.is_archived THEN
|
|
25
|
+
RAISE EXCEPTION 'project_archived:Cannot create tasks in an archived project';
|
|
26
|
+
END IF;
|
|
27
|
+
|
|
28
|
+
-- 2. Validate department if provided
|
|
29
|
+
v_dept_id := (p_payload->>'department_id')::uuid;
|
|
30
|
+
IF v_dept_id IS NOT NULL THEN
|
|
31
|
+
SELECT * INTO v_dept
|
|
32
|
+
FROM departments
|
|
33
|
+
WHERE id = v_dept_id;
|
|
34
|
+
|
|
35
|
+
IF NOT FOUND THEN
|
|
36
|
+
RAISE EXCEPTION 'department_not_found:Department not found';
|
|
37
|
+
END IF;
|
|
38
|
+
|
|
39
|
+
IF v_dept.is_archived THEN
|
|
40
|
+
RAISE EXCEPTION 'department_archived:Cannot create tasks in an archived department';
|
|
41
|
+
END IF;
|
|
42
|
+
END IF;
|
|
43
|
+
|
|
44
|
+
-- 3. Insert task
|
|
45
|
+
INSERT INTO tasks (
|
|
46
|
+
project_id, department_id, priority, description, notes,
|
|
47
|
+
due_date, status, version,
|
|
48
|
+
created_by_type, created_by_id, updated_by_type, updated_by_id, source
|
|
49
|
+
) VALUES (
|
|
50
|
+
(p_payload->>'project_id')::uuid,
|
|
51
|
+
v_dept_id,
|
|
52
|
+
COALESCE((p_payload->>'priority')::priority, 'medium'),
|
|
53
|
+
p_payload->>'description',
|
|
54
|
+
p_payload->>'notes',
|
|
55
|
+
(p_payload->>'due_date')::timestamptz,
|
|
56
|
+
COALESCE((p_payload->>'status')::status, 'todo'),
|
|
57
|
+
1,
|
|
58
|
+
(p_payload->>'created_by_type')::actor_type,
|
|
59
|
+
p_payload->>'created_by_id',
|
|
60
|
+
(p_payload->>'created_by_type')::actor_type,
|
|
61
|
+
p_payload->>'created_by_id',
|
|
62
|
+
(p_payload->>'source')::source
|
|
63
|
+
)
|
|
64
|
+
RETURNING * INTO v_task;
|
|
65
|
+
|
|
66
|
+
-- 4. Insert event_log
|
|
67
|
+
INSERT INTO event_log (
|
|
68
|
+
event_category, target_type, target_id, event_type,
|
|
69
|
+
actor_type, actor_id, actor_label, source
|
|
70
|
+
) VALUES (
|
|
71
|
+
'task', 'task', v_task.id, 'task.created',
|
|
72
|
+
(p_payload->>'actor_type')::actor_type,
|
|
73
|
+
p_payload->>'actor_id',
|
|
74
|
+
p_payload->>'actor_label',
|
|
75
|
+
(p_payload->>'source')::source
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
RETURN v_task;
|
|
79
|
+
END;
|
|
80
|
+
$$;
|
|
81
|
+
|
|
82
|
+
-- Atomic task update: UPDATE task + event_log rows in a single transaction.
|
|
83
|
+
-- Does field-level diff in Postgres using IS DISTINCT FROM.
|
|
84
|
+
CREATE OR REPLACE FUNCTION update_task_with_events(p_payload jsonb)
|
|
85
|
+
RETURNS tasks
|
|
86
|
+
LANGUAGE plpgsql
|
|
87
|
+
SECURITY DEFINER
|
|
88
|
+
SET search_path = public
|
|
89
|
+
AS $$
|
|
90
|
+
DECLARE
|
|
91
|
+
v_old tasks%ROWTYPE;
|
|
92
|
+
v_new tasks%ROWTYPE;
|
|
93
|
+
v_task_id uuid;
|
|
94
|
+
v_version integer;
|
|
95
|
+
v_updates jsonb;
|
|
96
|
+
v_actor_type actor_type;
|
|
97
|
+
v_actor_id text;
|
|
98
|
+
v_actor_label text;
|
|
99
|
+
v_source source;
|
|
100
|
+
BEGIN
|
|
101
|
+
v_task_id := (p_payload->>'task_id')::uuid;
|
|
102
|
+
v_version := (p_payload->>'version')::integer;
|
|
103
|
+
v_updates := p_payload->'fields';
|
|
104
|
+
v_actor_type := (p_payload->>'actor_type')::actor_type;
|
|
105
|
+
v_actor_id := p_payload->>'actor_id';
|
|
106
|
+
v_actor_label := p_payload->>'actor_label';
|
|
107
|
+
v_source := (p_payload->>'source')::source;
|
|
108
|
+
|
|
109
|
+
-- 1. Fetch existing task
|
|
110
|
+
SELECT * INTO v_old FROM tasks WHERE id = v_task_id;
|
|
111
|
+
|
|
112
|
+
IF NOT FOUND THEN
|
|
113
|
+
RAISE EXCEPTION 'task_not_found:Task not found';
|
|
114
|
+
END IF;
|
|
115
|
+
|
|
116
|
+
-- 2. Version check
|
|
117
|
+
IF v_old.version <> v_version THEN
|
|
118
|
+
RAISE EXCEPTION 'version_conflict:Version conflict: expected %, current is %', v_version, v_old.version;
|
|
119
|
+
END IF;
|
|
120
|
+
|
|
121
|
+
-- 3. Update task (only provided fields)
|
|
122
|
+
UPDATE tasks SET
|
|
123
|
+
priority = COALESCE((v_updates->>'priority')::priority, priority),
|
|
124
|
+
description = COALESCE(v_updates->>'description', description),
|
|
125
|
+
notes = CASE WHEN v_updates ? 'notes' THEN v_updates->>'notes' ELSE notes END,
|
|
126
|
+
department_id = CASE WHEN v_updates ? 'department_id' THEN (v_updates->>'department_id')::uuid ELSE department_id END,
|
|
127
|
+
due_date = CASE WHEN v_updates ? 'due_date' THEN (v_updates->>'due_date')::timestamptz ELSE due_date END,
|
|
128
|
+
status = COALESCE((v_updates->>'status')::status, status),
|
|
129
|
+
version = version + 1,
|
|
130
|
+
updated_at = now(),
|
|
131
|
+
updated_by_type = v_actor_type,
|
|
132
|
+
updated_by_id = v_actor_id,
|
|
133
|
+
source = v_source
|
|
134
|
+
WHERE id = v_task_id AND version = v_version
|
|
135
|
+
RETURNING * INTO v_new;
|
|
136
|
+
|
|
137
|
+
IF NOT FOUND THEN
|
|
138
|
+
-- Race condition: version changed between SELECT and UPDATE
|
|
139
|
+
RAISE EXCEPTION 'version_conflict:Task was modified concurrently';
|
|
140
|
+
END IF;
|
|
141
|
+
|
|
142
|
+
-- 4. Field-level diff using IS DISTINCT FROM, insert event_log rows
|
|
143
|
+
IF v_new.priority IS DISTINCT FROM v_old.priority THEN
|
|
144
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
145
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'priority', to_jsonb(v_old.priority::text), to_jsonb(v_new.priority::text), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
146
|
+
END IF;
|
|
147
|
+
|
|
148
|
+
IF v_new.description IS DISTINCT FROM v_old.description THEN
|
|
149
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
150
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'description', to_jsonb(v_old.description), to_jsonb(v_new.description), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
151
|
+
END IF;
|
|
152
|
+
|
|
153
|
+
IF v_new.notes IS DISTINCT FROM v_old.notes THEN
|
|
154
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
155
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'notes', to_jsonb(v_old.notes), to_jsonb(v_new.notes), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
156
|
+
END IF;
|
|
157
|
+
|
|
158
|
+
IF v_new.department_id IS DISTINCT FROM v_old.department_id THEN
|
|
159
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
160
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'department_id', to_jsonb(v_old.department_id), to_jsonb(v_new.department_id), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
161
|
+
END IF;
|
|
162
|
+
|
|
163
|
+
IF v_new.due_date IS DISTINCT FROM v_old.due_date THEN
|
|
164
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
165
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'due_date', to_jsonb(v_old.due_date), to_jsonb(v_new.due_date), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
166
|
+
END IF;
|
|
167
|
+
|
|
168
|
+
IF v_new.status IS DISTINCT FROM v_old.status THEN
|
|
169
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
170
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'status', to_jsonb(v_old.status::text), to_jsonb(v_new.status::text), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
171
|
+
END IF;
|
|
172
|
+
|
|
173
|
+
RETURN v_new;
|
|
174
|
+
END;
|
|
175
|
+
$$;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- Per-user rate limiting for the human dashboard.
|
|
2
|
+
-- Separate from agent rate_limits which has FK to agent_keys.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE user_rate_limits (
|
|
5
|
+
user_id uuid NOT NULL,
|
|
6
|
+
window_start timestamptz NOT NULL,
|
|
7
|
+
request_count integer NOT NULL DEFAULT 1,
|
|
8
|
+
UNIQUE (user_id, window_start)
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
-- Atomic upsert: increment per-minute counter for a user
|
|
12
|
+
CREATE OR REPLACE FUNCTION increment_user_rate_limit(p_user_id uuid)
|
|
13
|
+
RETURNS integer
|
|
14
|
+
LANGUAGE sql
|
|
15
|
+
VOLATILE
|
|
16
|
+
SECURITY DEFINER
|
|
17
|
+
SET search_path = public
|
|
18
|
+
AS $$
|
|
19
|
+
INSERT INTO user_rate_limits (user_id, window_start, request_count)
|
|
20
|
+
VALUES (p_user_id, date_trunc('minute', now()), 1)
|
|
21
|
+
ON CONFLICT (user_id, window_start)
|
|
22
|
+
DO UPDATE SET request_count = user_rate_limits.request_count + 1
|
|
23
|
+
RETURNING request_count;
|
|
24
|
+
$$;
|
|
25
|
+
|
|
26
|
+
-- Update cleanup function to also clean user_rate_limits
|
|
27
|
+
CREATE OR REPLACE FUNCTION cleanup_rate_limits()
|
|
28
|
+
RETURNS integer
|
|
29
|
+
LANGUAGE plpgsql
|
|
30
|
+
VOLATILE
|
|
31
|
+
SECURITY DEFINER
|
|
32
|
+
SET search_path = public
|
|
33
|
+
AS $$
|
|
34
|
+
DECLARE
|
|
35
|
+
v_count integer;
|
|
36
|
+
BEGIN
|
|
37
|
+
WITH deleted_agent AS (
|
|
38
|
+
DELETE FROM rate_limits
|
|
39
|
+
WHERE window_start < now() - interval '5 minutes'
|
|
40
|
+
RETURNING 1
|
|
41
|
+
),
|
|
42
|
+
deleted_user AS (
|
|
43
|
+
DELETE FROM user_rate_limits
|
|
44
|
+
WHERE window_start < now() - interval '5 minutes'
|
|
45
|
+
RETURNING 1
|
|
46
|
+
)
|
|
47
|
+
SELECT (SELECT count(*) FROM deleted_agent) + (SELECT count(*) FROM deleted_user)
|
|
48
|
+
INTO v_count;
|
|
49
|
+
|
|
50
|
+
RETURN v_count;
|
|
51
|
+
END;
|
|
52
|
+
$$;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Atomic permission update: delete + insert in a single transaction
|
|
2
|
+
-- Prevents race condition where agent has zero permissions between
|
|
3
|
+
-- separate DELETE and INSERT calls.
|
|
4
|
+
CREATE OR REPLACE FUNCTION update_agent_permissions(
|
|
5
|
+
p_key_id uuid,
|
|
6
|
+
p_rows jsonb
|
|
7
|
+
) RETURNS void
|
|
8
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
|
9
|
+
BEGIN
|
|
10
|
+
-- Enforce admin-only access (defense-in-depth alongside GRANT restrictions)
|
|
11
|
+
IF NOT EXISTS (SELECT 1 FROM admin_users WHERE user_id = auth.uid()) THEN
|
|
12
|
+
RAISE EXCEPTION 'unauthorized: admin access required';
|
|
13
|
+
END IF;
|
|
14
|
+
|
|
15
|
+
DELETE FROM agent_permissions WHERE agent_key_id = p_key_id;
|
|
16
|
+
|
|
17
|
+
INSERT INTO agent_permissions (agent_key_id, project_id, department_id, can_read, can_create, can_update)
|
|
18
|
+
SELECT p_key_id,
|
|
19
|
+
(r->>'project_id')::uuid,
|
|
20
|
+
(r->>'department_id')::uuid,
|
|
21
|
+
(r->>'can_read')::boolean,
|
|
22
|
+
(r->>'can_create')::boolean,
|
|
23
|
+
(r->>'can_update')::boolean
|
|
24
|
+
FROM jsonb_array_elements(p_rows) AS r;
|
|
25
|
+
END;
|
|
26
|
+
$$;
|
|
27
|
+
|
|
28
|
+
-- Restrict execution: revoke from public/anon, allow only authenticated users
|
|
29
|
+
REVOKE ALL ON FUNCTION update_agent_permissions(uuid, jsonb) FROM PUBLIC;
|
|
30
|
+
REVOKE ALL ON FUNCTION update_agent_permissions(uuid, jsonb) FROM anon;
|
|
31
|
+
GRANT EXECUTE ON FUNCTION update_agent_permissions(uuid, jsonb) TO authenticated;
|