@team-semicolon/semicolony-cli 4.18.72 → 4.18.73

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.
@@ -0,0 +1,137 @@
1
+ -- 178_feedback_capture_capability.sql
2
+ --
3
+ -- Give the Gateway one narrowly-scoped write primitive for SEMO Ops feedback.
4
+ -- sc_app receives EXECUTE only: it still has no general DML on knowledge_base
5
+ -- or action_items. The function fixes domain/key/source values, validates the
6
+ -- remaining input, serializes same-slug writes, and creates the KB record and
7
+ -- its open action-item handle in the caller's transaction.
8
+
9
+ CREATE OR REPLACE FUNCTION semicolony.record_feedback(
10
+ p_slug text,
11
+ p_summary text,
12
+ p_content text,
13
+ p_metadata jsonb,
14
+ p_embedding text,
15
+ p_worker_id text,
16
+ p_device_id text
17
+ )
18
+ RETURNS TABLE(kb_id bigint, action_item_id uuid, duplicate boolean)
19
+ LANGUAGE plpgsql
20
+ SECURITY DEFINER
21
+ SET search_path = pg_catalog
22
+ AS $function$
23
+ DECLARE
24
+ v_kb_id bigint;
25
+ v_action_item_id uuid;
26
+ v_duplicate boolean := false;
27
+ v_metadata jsonb;
28
+ v_priority text;
29
+ BEGIN
30
+ IF p_slug IS NULL
31
+ OR char_length(p_slug) < 3
32
+ OR char_length(p_slug) > 60
33
+ OR p_slug !~ '^[[:alnum:]가-힣][[:alnum:]가-힣-]*$' THEN
34
+ RAISE EXCEPTION 'invalid feedback slug' USING ERRCODE = '22023';
35
+ END IF;
36
+ IF p_summary IS NULL OR char_length(btrim(p_summary)) < 8 OR char_length(p_summary) > 500 THEN
37
+ RAISE EXCEPTION 'invalid feedback summary' USING ERRCODE = '22023';
38
+ END IF;
39
+ IF p_content IS NULL OR char_length(p_content) < 30 OR char_length(p_content) > 30000 THEN
40
+ RAISE EXCEPTION 'invalid feedback content' USING ERRCODE = '22023';
41
+ END IF;
42
+ IF p_metadata IS NULL OR jsonb_typeof(p_metadata) <> 'object' THEN
43
+ RAISE EXCEPTION 'invalid feedback metadata' USING ERRCODE = '22023';
44
+ END IF;
45
+ IF p_metadata ->> 'category' NOT IN ('context-failure', 'env-drift', 'policy-gap', 'tooling') THEN
46
+ RAISE EXCEPTION 'invalid feedback category' USING ERRCODE = '22023';
47
+ END IF;
48
+ v_priority := coalesce(p_metadata ->> 'severity', 'normal');
49
+ IF v_priority NOT IN ('low', 'normal', 'high', 'urgent') THEN
50
+ RAISE EXCEPTION 'invalid feedback severity' USING ERRCODE = '22023';
51
+ END IF;
52
+ IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
53
+ OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$' THEN
54
+ RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
55
+ END IF;
56
+
57
+ -- The cast also validates dimensionality against the target vector column.
58
+ PERFORM p_embedding::public.vector;
59
+ PERFORM pg_advisory_xact_lock(hashtextextended('feedback:' || p_slug, 0));
60
+
61
+ v_metadata := p_metadata || jsonb_build_object(
62
+ 'status', 'open',
63
+ 'source', 'session-hook',
64
+ 'worker_id', p_worker_id,
65
+ 'device_id', p_device_id
66
+ );
67
+
68
+ INSERT INTO semicolony.knowledge_base AS kb (
69
+ domain, key, sub_key, content, created_by, embedding, metadata
70
+ ) VALUES (
71
+ 'semicolon',
72
+ 'feedback',
73
+ p_slug,
74
+ p_content,
75
+ 'feedback-pipeline:' || p_worker_id,
76
+ p_embedding::public.vector,
77
+ v_metadata
78
+ )
79
+ ON CONFLICT (domain, key, sub_key) DO UPDATE SET
80
+ content = EXCLUDED.content,
81
+ embedding = EXCLUDED.embedding,
82
+ metadata = coalesce(kb.metadata, '{}'::jsonb) || EXCLUDED.metadata,
83
+ archived = false,
84
+ updated_at = clock_timestamp()
85
+ RETURNING kb.kb_id INTO v_kb_id;
86
+
87
+ SELECT ai.action_item_id
88
+ INTO v_action_item_id
89
+ FROM semicolony.action_items AS ai
90
+ WHERE ai.owner_domain = 'semicolon'
91
+ AND ai.source = 'feedback'
92
+ AND ai.status = 'open'
93
+ AND ai.metadata ->> 'feedback_slug' = p_slug
94
+ ORDER BY ai.created_at DESC
95
+ LIMIT 1
96
+ FOR UPDATE;
97
+
98
+ IF FOUND THEN
99
+ v_duplicate := true;
100
+ ELSE
101
+ INSERT INTO semicolony.action_items (
102
+ owner_domain,
103
+ target_domain,
104
+ description,
105
+ priority,
106
+ category,
107
+ source,
108
+ related_url,
109
+ metadata
110
+ ) VALUES (
111
+ 'semicolon',
112
+ 'semicolon',
113
+ '[SEMO Ops feedback] ' || btrim(p_summary),
114
+ v_priority,
115
+ 'feedback',
116
+ 'feedback',
117
+ 'semo kb get semicolon feedback ' || p_slug,
118
+ jsonb_build_object(
119
+ 'feedback_slug', p_slug,
120
+ 'failed_rule', coalesce(p_metadata ->> 'failed_rule', 'general'),
121
+ 'worker_id', p_worker_id,
122
+ 'device_id', p_device_id
123
+ )
124
+ )
125
+ RETURNING action_items.action_item_id INTO v_action_item_id;
126
+ END IF;
127
+
128
+ RETURN QUERY SELECT v_kb_id, v_action_item_id, v_duplicate;
129
+ END;
130
+ $function$;
131
+
132
+ REVOKE ALL ON FUNCTION semicolony.record_feedback(
133
+ text, text, text, jsonb, text, text, text
134
+ ) FROM PUBLIC, sc_app, sc_provider;
135
+ GRANT EXECUTE ON FUNCTION semicolony.record_feedback(
136
+ text, text, text, jsonb, text, text, text
137
+ ) TO sc_app;
@@ -0,0 +1,209 @@
1
+ -- 179_worker_feedback_credential_scope.sql
2
+ --
3
+ -- Allow the dedicated feedback:write capability in future worker credential
4
+ -- rotations, then add it to currently device-bound active credentials. This
5
+ -- does not add kb:write and does not change any table DML grant.
6
+
7
+ CREATE OR REPLACE FUNCTION semicolony.issue_worker_gateway_credential(
8
+ p_team_tenant_id uuid,
9
+ p_worker_id text,
10
+ p_device_id text,
11
+ p_token_hash text,
12
+ p_token_prefix text,
13
+ p_scopes text[],
14
+ p_issued_by text,
15
+ p_expires_days integer,
16
+ p_workspace_grants jsonb
17
+ )
18
+ RETURNS uuid
19
+ LANGUAGE plpgsql
20
+ SECURITY DEFINER
21
+ SET search_path = pg_catalog
22
+ AS $function$
23
+ DECLARE
24
+ v_credential_id uuid;
25
+ v_previous_credential_id uuid;
26
+ v_workspace_grants jsonb := coalesce(p_workspace_grants, '[]'::jsonb);
27
+ v_grant jsonb;
28
+ v_project_id text;
29
+ v_capability text;
30
+ v_environments text[];
31
+ v_grant_key text;
32
+ v_seen_grant_keys text[] := ARRAY[]::text[];
33
+ v_has_credential_grant boolean := false;
34
+ v_has_asset_grant boolean := false;
35
+ BEGIN
36
+ IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
37
+ OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$' THEN
38
+ RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
39
+ END IF;
40
+ IF p_token_hash IS NULL OR p_token_hash !~ '^[0-9a-f]{64}$' THEN
41
+ RAISE EXCEPTION 'invalid worker credential token hash' USING ERRCODE = '22023';
42
+ END IF;
43
+ IF p_token_prefix IS NULL
44
+ OR p_token_prefix !~ ('^wck_' || p_worker_id || '_[A-Za-z0-9_-]{6}$') THEN
45
+ RAISE EXCEPTION 'invalid worker credential token prefix' USING ERRCODE = '22023';
46
+ END IF;
47
+ IF p_issued_by IS NULL OR p_issued_by <> btrim(p_issued_by)
48
+ OR btrim(p_issued_by) = '' OR char_length(p_issued_by) > 128
49
+ OR p_issued_by ~ '[[:cntrl:]]' THEN
50
+ RAISE EXCEPTION 'invalid credential issuer' USING ERRCODE = '22023';
51
+ END IF;
52
+ IF p_expires_days IS NULL OR p_expires_days < 1 OR p_expires_days > 90 THEN
53
+ RAISE EXCEPTION 'worker credential expiry must be between 1 and 90 days' USING ERRCODE = '22023';
54
+ END IF;
55
+ IF p_scopes IS NULL OR cardinality(p_scopes) = 0
56
+ OR EXISTS (
57
+ SELECT 1
58
+ FROM unnest(p_scopes) AS requested(scope)
59
+ WHERE requested.scope IS NULL
60
+ OR requested.scope <> ALL (ARRAY[
61
+ 'kb:read',
62
+ 'feedback:write',
63
+ 'skills:read',
64
+ 'skills:write',
65
+ 'workspace:credentials-read',
66
+ 'workspace:assets-read',
67
+ 'worker:release:read',
68
+ 'worker:release:report'
69
+ ]::text[])
70
+ )
71
+ OR cardinality(p_scopes) <> (
72
+ SELECT count(DISTINCT requested.scope)::integer FROM unnest(p_scopes) AS requested(scope)
73
+ )
74
+ OR ('skills:write' = ANY(p_scopes) AND NOT ('skills:read' = ANY(p_scopes))) THEN
75
+ RAISE EXCEPTION 'worker credential scopes are not permitted' USING ERRCODE = '22023';
76
+ END IF;
77
+ IF jsonb_typeof(v_workspace_grants) <> 'array' THEN
78
+ RAISE EXCEPTION 'workspace grants must be a JSON array' USING ERRCODE = '22023';
79
+ END IF;
80
+
81
+ FOR v_grant IN SELECT item.value FROM jsonb_array_elements(v_workspace_grants) AS item(value) LOOP
82
+ IF jsonb_typeof(v_grant) <> 'object'
83
+ OR EXISTS (
84
+ SELECT 1
85
+ FROM jsonb_object_keys(v_grant) AS object_key(key)
86
+ WHERE object_key.key NOT IN ('projectId', 'capability', 'environments')
87
+ ) THEN
88
+ RAISE EXCEPTION 'invalid workspace grant shape' USING ERRCODE = '22023';
89
+ END IF;
90
+ v_project_id := v_grant ->> 'projectId';
91
+ v_capability := v_grant ->> 'capability';
92
+ IF v_project_id IS NULL OR v_project_id !~ '^[a-z][a-z0-9-]{0,99}$'
93
+ OR v_capability NOT IN ('credentials-read', 'assets-read')
94
+ OR jsonb_typeof(v_grant -> 'environments') <> 'array' THEN
95
+ RAISE EXCEPTION 'invalid workspace grant' USING ERRCODE = '22023';
96
+ END IF;
97
+ v_environments := ARRAY(
98
+ SELECT jsonb_array_elements_text(v_grant -> 'environments')
99
+ );
100
+ IF cardinality(v_environments) <> (
101
+ SELECT count(DISTINCT environment.value)::integer
102
+ FROM unnest(v_environments) AS environment(value)
103
+ ) OR NOT (v_environments <@ ARRAY['dev', 'e2e']::text[])
104
+ OR (v_capability = 'credentials-read' AND cardinality(v_environments) = 0)
105
+ OR (v_capability = 'assets-read' AND cardinality(v_environments) <> 0) THEN
106
+ RAISE EXCEPTION 'invalid workspace grant environments' USING ERRCODE = '22023';
107
+ END IF;
108
+ v_grant_key := v_project_id || ':' || v_capability;
109
+ IF v_grant_key = ANY(v_seen_grant_keys) THEN
110
+ RAISE EXCEPTION 'duplicate workspace grant' USING ERRCODE = '22023';
111
+ END IF;
112
+ v_seen_grant_keys := array_append(v_seen_grant_keys, v_grant_key);
113
+ v_has_credential_grant := v_has_credential_grant OR v_capability = 'credentials-read';
114
+ v_has_asset_grant := v_has_asset_grant OR v_capability = 'assets-read';
115
+ END LOOP;
116
+ IF v_has_credential_grant <> ('workspace:credentials-read' = ANY(p_scopes))
117
+ OR v_has_asset_grant <> ('workspace:assets-read' = ANY(p_scopes)) THEN
118
+ RAISE EXCEPTION 'workspace scopes must exactly match workspace grants' USING ERRCODE = '22023';
119
+ END IF;
120
+
121
+ PERFORM t.id
122
+ FROM public.tenants AS t
123
+ WHERE t.id = p_team_tenant_id
124
+ AND t.slug = 'team-semicolon'
125
+ AND t.tenant_type = 'team'
126
+ AND coalesce(t.metadata ->> 'status', 'active') = 'active';
127
+ IF NOT FOUND THEN
128
+ RAISE EXCEPTION 'active team-semicolon tenant not found' USING ERRCODE = 'P0002';
129
+ END IF;
130
+
131
+ SELECT wd.gateway_credential_id
132
+ INTO v_previous_credential_id
133
+ FROM semicolony.worker_devices AS wd
134
+ WHERE wd.device_id = p_device_id
135
+ AND wd.worker_id = p_worker_id
136
+ AND wd.status = 'active'
137
+ AND wd.kb_access_mode = 'worker-gateway'
138
+ AND wd.last_doctor_status = 'pass'
139
+ AND wd.last_seen_at >= clock_timestamp() - interval '24 hours'
140
+ FOR UPDATE;
141
+ IF NOT FOUND THEN
142
+ RAISE EXCEPTION 'active worker device binding not found' USING ERRCODE = 'P0002';
143
+ END IF;
144
+
145
+ INSERT INTO semicolony.gateway_credentials (
146
+ tenant_id, tenant_slug, token_hash, token_prefix, scopes, issued_by, expires_at, metadata
147
+ ) VALUES (
148
+ p_team_tenant_id,
149
+ 'team-semicolon',
150
+ p_token_hash,
151
+ p_token_prefix,
152
+ p_scopes,
153
+ p_issued_by,
154
+ clock_timestamp() + make_interval(days => p_expires_days),
155
+ jsonb_build_object(
156
+ 'credential_kind', 'worker',
157
+ 'worker_id', p_worker_id,
158
+ 'device_id', p_device_id,
159
+ 'tenant_anchor', 'team-semicolon',
160
+ 'lineage_status', 'active'
161
+ )
162
+ )
163
+ RETURNING id INTO v_credential_id;
164
+
165
+ INSERT INTO semicolony.workspace_projection_grants (
166
+ gateway_credential_id, project_id, capability, environments
167
+ )
168
+ SELECT
169
+ v_credential_id,
170
+ item.value ->> 'projectId',
171
+ item.value ->> 'capability',
172
+ ARRAY(SELECT jsonb_array_elements_text(item.value -> 'environments'))
173
+ FROM jsonb_array_elements(v_workspace_grants) AS item(value);
174
+
175
+ IF v_previous_credential_id IS NOT NULL THEN
176
+ UPDATE semicolony.gateway_credentials
177
+ SET status = 'revoked',
178
+ revoked_at = clock_timestamp(),
179
+ revoked_reason = 'worker credential rotated'
180
+ WHERE id = v_previous_credential_id
181
+ AND status = 'active';
182
+ END IF;
183
+
184
+ UPDATE semicolony.worker_devices
185
+ SET gateway_credential_id = v_credential_id
186
+ WHERE device_id = p_device_id
187
+ AND worker_id = p_worker_id;
188
+
189
+ RETURN v_credential_id;
190
+ END;
191
+ $function$;
192
+
193
+ REVOKE ALL ON FUNCTION semicolony.issue_worker_gateway_credential(
194
+ uuid, text, text, text, text, text[], text, integer, jsonb
195
+ ) FROM PUBLIC, sc_app, sc_provider;
196
+ GRANT EXECUTE ON FUNCTION semicolony.issue_worker_gateway_credential(
197
+ uuid, text, text, text, text, text[], text, integer, jsonb
198
+ ) TO sc_app;
199
+
200
+ UPDATE semicolony.gateway_credentials AS gc
201
+ SET scopes = array_append(gc.scopes, 'feedback:write')
202
+ FROM semicolony.worker_devices AS wd
203
+ WHERE wd.gateway_credential_id = gc.id
204
+ AND wd.status = 'active'
205
+ AND wd.kb_access_mode = 'worker-gateway'
206
+ AND gc.status = 'active'
207
+ AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
208
+ AND gc.metadata ->> 'credential_kind' = 'worker'
209
+ AND NOT ('feedback:write' = ANY(gc.scopes));
@@ -0,0 +1,264 @@
1
+ -- 180_worker_dashboard_owner_credentials.sql
2
+ --
3
+ -- SEMO Ops dashboard self-service worker registration and credential access.
4
+ --
5
+ -- Policy:
6
+ -- - Approved admins manage every worker.
7
+ -- - Approved members manage only devices bound to their Supabase auth user id.
8
+ -- - New dashboard-issued worker keys remain viewable until credential sealing lands.
9
+ --
10
+ -- Existing credentials only contain a SHA-256 hash and cannot be recovered. They are
11
+ -- intentionally left without a secret row and must be rotated from the dashboard.
12
+ -- Safety: additive except for extending the fleet audit CHECK constraint.
13
+
14
+ ALTER TABLE semo_ops.worker_devices
15
+ ADD COLUMN IF NOT EXISTS owner_auth_user_id UUID;
16
+
17
+ COMMENT ON COLUMN semo_ops.worker_devices.owner_auth_user_id IS
18
+ 'Supabase auth user that owns this device. NULL means admin-only legacy/unclaimed device.';
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_worker_devices_owner
21
+ ON semo_ops.worker_devices (owner_auth_user_id, status, device_id);
22
+
23
+ CREATE TABLE IF NOT EXISTS semo_ops.worker_credential_secrets (
24
+ credential_id UUID PRIMARY KEY
25
+ REFERENCES semo_ops.gateway_credentials(id) ON DELETE CASCADE,
26
+ device_id TEXT NOT NULL
27
+ REFERENCES semo_ops.worker_devices(device_id) ON DELETE CASCADE,
28
+ plaintext_token TEXT NOT NULL CHECK (plaintext_token ~ '^wck_[a-z][a-z0-9-]{0,63}_[A-Za-z0-9_-]{32,64}$'),
29
+ created_by TEXT NOT NULL,
30
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
31
+ sealed_at TIMESTAMPTZ
32
+ );
33
+
34
+ COMMENT ON TABLE semo_ops.worker_credential_secrets IS
35
+ 'Temporary unsealed dashboard worker-key store. Replace plaintext_token with sealed material in the planned sealing migration.';
36
+ COMMENT ON COLUMN semo_ops.worker_credential_secrets.plaintext_token IS
37
+ 'Explicitly viewable to the authenticated owner/admin until credential sealing is implemented.';
38
+
39
+ CREATE INDEX IF NOT EXISTS idx_worker_credential_secrets_device
40
+ ON semo_ops.worker_credential_secrets (device_id, created_at DESC);
41
+
42
+ -- Extend the append-only audit vocabulary for the new dashboard operations.
43
+ ALTER TABLE semo_ops.worker_fleet_audits
44
+ DROP CONSTRAINT IF EXISTS worker_fleet_audits_action_check;
45
+ ALTER TABLE semo_ops.worker_fleet_audits
46
+ ADD CONSTRAINT worker_fleet_audits_action_check CHECK (
47
+ action IN (
48
+ 'device.status', 'local_hive.status', 'local_hive.expiry', 'release.activate',
49
+ 'worker.register', 'worker.owner', 'credential.issue'
50
+ )
51
+ );
52
+
53
+ ALTER TABLE semo_ops.worker_fleet_audits
54
+ DROP CONSTRAINT IF EXISTS worker_fleet_audits_target_kind_check;
55
+ ALTER TABLE semo_ops.worker_fleet_audits
56
+ ADD CONSTRAINT worker_fleet_audits_target_kind_check CHECK (
57
+ target_kind IN ('device', 'local_hive', 'profile', 'credential')
58
+ );
59
+
60
+ CREATE OR REPLACE FUNCTION semo_ops.register_dashboard_worker(
61
+ p_actor_user_id UUID,
62
+ p_actor_is_admin BOOLEAN,
63
+ p_actor_email TEXT,
64
+ p_owner_auth_user_id UUID,
65
+ p_worker_id TEXT,
66
+ p_device_id TEXT,
67
+ p_display_name TEXT,
68
+ p_hostname TEXT,
69
+ p_os TEXT
70
+ )
71
+ RETURNS VOID
72
+ LANGUAGE plpgsql
73
+ SECURITY DEFINER
74
+ SET search_path = pg_catalog
75
+ AS $function$
76
+ BEGIN
77
+ IF p_actor_user_id IS NULL OR p_owner_auth_user_id IS NULL
78
+ OR (NOT p_actor_is_admin AND p_actor_user_id <> p_owner_auth_user_id) THEN
79
+ RAISE EXCEPTION 'worker owner is outside the actor scope' USING ERRCODE = '42501';
80
+ END IF;
81
+ IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
82
+ OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$' THEN
83
+ RAISE EXCEPTION 'invalid worker/device id' USING ERRCODE = '22023';
84
+ END IF;
85
+ IF p_actor_email IS NULL OR btrim(p_actor_email) = '' OR char_length(p_actor_email) > 320 THEN
86
+ RAISE EXCEPTION 'invalid actor email' USING ERRCODE = '22023';
87
+ END IF;
88
+
89
+ INSERT INTO semo_ops.worker_devices (
90
+ device_id, worker_id, display_name, hostname, os, owner_auth_user_id,
91
+ installed_profile_id, status, last_doctor_status, metadata
92
+ ) VALUES (
93
+ p_device_id, p_worker_id, nullif(btrim(p_display_name), ''),
94
+ nullif(btrim(p_hostname), ''), nullif(btrim(p_os), ''), p_owner_auth_user_id,
95
+ 'semo-ops-worker', 'pending-setup', 'pending',
96
+ jsonb_build_object('registered_via', 'dashboard')
97
+ );
98
+
99
+ INSERT INTO semo_ops.worker_fleet_audits
100
+ (actor_email, action, target_kind, target_id, before, after)
101
+ VALUES (
102
+ p_actor_email, 'worker.register', 'device', p_device_id, '{}'::jsonb,
103
+ jsonb_build_object('worker_id', p_worker_id, 'owner_auth_user_id', p_owner_auth_user_id)
104
+ );
105
+ END;
106
+ $function$;
107
+
108
+ CREATE OR REPLACE FUNCTION semo_ops.set_dashboard_worker_owner(
109
+ p_actor_is_admin BOOLEAN,
110
+ p_actor_email TEXT,
111
+ p_device_id TEXT,
112
+ p_owner_auth_user_id UUID
113
+ )
114
+ RETURNS VOID
115
+ LANGUAGE plpgsql
116
+ SECURITY DEFINER
117
+ SET search_path = pg_catalog
118
+ AS $function$
119
+ DECLARE
120
+ v_previous_owner UUID;
121
+ BEGIN
122
+ IF NOT p_actor_is_admin THEN
123
+ RAISE EXCEPTION 'admin role required' USING ERRCODE = '42501';
124
+ END IF;
125
+ SELECT owner_auth_user_id INTO v_previous_owner
126
+ FROM semo_ops.worker_devices
127
+ WHERE device_id = p_device_id
128
+ FOR UPDATE;
129
+ IF NOT FOUND THEN
130
+ RAISE EXCEPTION 'device not found' USING ERRCODE = 'P0002';
131
+ END IF;
132
+
133
+ UPDATE semo_ops.worker_devices
134
+ SET owner_auth_user_id = p_owner_auth_user_id
135
+ WHERE device_id = p_device_id;
136
+
137
+ INSERT INTO semo_ops.worker_fleet_audits
138
+ (actor_email, action, target_kind, target_id, before, after)
139
+ VALUES (
140
+ p_actor_email, 'worker.owner', 'device', p_device_id,
141
+ jsonb_build_object('owner_auth_user_id', v_previous_owner),
142
+ jsonb_build_object('owner_auth_user_id', p_owner_auth_user_id)
143
+ );
144
+ END;
145
+ $function$;
146
+
147
+ CREATE OR REPLACE FUNCTION semo_ops.issue_dashboard_worker_credential(
148
+ p_actor_user_id UUID,
149
+ p_actor_is_admin BOOLEAN,
150
+ p_actor_email TEXT,
151
+ p_device_id TEXT,
152
+ p_plaintext_token TEXT,
153
+ p_token_hash TEXT,
154
+ p_token_prefix TEXT,
155
+ p_expires_days INTEGER
156
+ )
157
+ RETURNS UUID
158
+ LANGUAGE plpgsql
159
+ SECURITY DEFINER
160
+ SET search_path = pg_catalog
161
+ AS $function$
162
+ DECLARE
163
+ v_worker_id TEXT;
164
+ v_previous_credential_id UUID;
165
+ v_credential_id UUID;
166
+ v_tenant_id UUID;
167
+ v_scopes TEXT[] := ARRAY[
168
+ 'kb:read', 'feedback:write', 'skills:read', 'worker:release:read', 'worker:release:report'
169
+ ]::TEXT[];
170
+ BEGIN
171
+ IF p_token_hash IS NULL OR p_token_hash !~ '^[0-9a-f]{64}$'
172
+ OR p_plaintext_token IS NULL
173
+ OR p_plaintext_token !~ '^wck_[a-z][a-z0-9-]{0,63}_[A-Za-z0-9_-]{32,64}$'
174
+ OR p_expires_days IS NULL OR p_expires_days < 1 OR p_expires_days > 90 THEN
175
+ RAISE EXCEPTION 'invalid worker credential material' USING ERRCODE = '22023';
176
+ END IF;
177
+
178
+ SELECT worker_id, gateway_credential_id
179
+ INTO v_worker_id, v_previous_credential_id
180
+ FROM semo_ops.worker_devices
181
+ WHERE device_id = p_device_id
182
+ AND status NOT IN ('revoked', 'retired')
183
+ AND (p_actor_is_admin OR owner_auth_user_id = p_actor_user_id)
184
+ FOR UPDATE;
185
+ IF NOT FOUND THEN
186
+ RAISE EXCEPTION 'manageable worker device not found' USING ERRCODE = '42501';
187
+ END IF;
188
+
189
+ IF p_token_prefix !~ ('^wck_' || v_worker_id || '_[A-Za-z0-9_-]{6}$')
190
+ OR p_plaintext_token !~ ('^wck_' || v_worker_id || '_[A-Za-z0-9_-]{32,64}$') THEN
191
+ RAISE EXCEPTION 'credential is not bound to the worker' USING ERRCODE = '22023';
192
+ END IF;
193
+
194
+ SELECT id INTO v_tenant_id
195
+ FROM public.tenants
196
+ WHERE slug = 'team-semicolon'
197
+ AND tenant_type = 'team'
198
+ AND coalesce(metadata ->> 'status', 'active') = 'active';
199
+ IF NOT FOUND THEN
200
+ RAISE EXCEPTION 'active team-semicolon tenant not found' USING ERRCODE = 'P0002';
201
+ END IF;
202
+
203
+ INSERT INTO semo_ops.gateway_credentials (
204
+ tenant_id, tenant_slug, token_hash, token_prefix, scopes, issued_by, expires_at, metadata
205
+ ) VALUES (
206
+ v_tenant_id, 'team-semicolon', p_token_hash, p_token_prefix, v_scopes,
207
+ p_actor_email, clock_timestamp() + make_interval(days => p_expires_days),
208
+ jsonb_build_object(
209
+ 'credential_kind', 'worker', 'worker_id', v_worker_id,
210
+ 'device_id', p_device_id, 'tenant_anchor', 'team-semicolon',
211
+ 'lineage_status', 'active', 'issued_via', 'dashboard'
212
+ )
213
+ ) RETURNING id INTO v_credential_id;
214
+
215
+ INSERT INTO semo_ops.worker_credential_secrets
216
+ (credential_id, device_id, plaintext_token, created_by)
217
+ VALUES (v_credential_id, p_device_id, p_plaintext_token, p_actor_email);
218
+
219
+ IF v_previous_credential_id IS NOT NULL THEN
220
+ UPDATE semo_ops.gateway_credentials
221
+ SET status = 'revoked', revoked_at = clock_timestamp(),
222
+ revoked_reason = 'worker credential rotated from dashboard'
223
+ WHERE id = v_previous_credential_id AND status = 'active';
224
+ END IF;
225
+
226
+ UPDATE semo_ops.worker_devices
227
+ SET gateway_credential_id = v_credential_id,
228
+ status = CASE WHEN status = 'pending-auth' THEN 'pending-setup' ELSE status END
229
+ WHERE device_id = p_device_id;
230
+
231
+ INSERT INTO semo_ops.worker_fleet_audits
232
+ (actor_email, action, target_kind, target_id, before, after)
233
+ VALUES (
234
+ p_actor_email, 'credential.issue', 'credential', v_credential_id::TEXT,
235
+ jsonb_build_object('previous_credential_id', v_previous_credential_id),
236
+ jsonb_build_object('device_id', p_device_id, 'token_prefix', p_token_prefix)
237
+ );
238
+
239
+ RETURN v_credential_id;
240
+ END;
241
+ $function$;
242
+
243
+ REVOKE ALL ON TABLE semo_ops.worker_credential_secrets FROM PUBLIC, sc_app, sc_provider;
244
+ GRANT SELECT ON TABLE semo_ops.worker_credential_secrets TO sc_fleet_board;
245
+
246
+ REVOKE ALL ON FUNCTION semo_ops.register_dashboard_worker(
247
+ UUID, BOOLEAN, TEXT, UUID, TEXT, TEXT, TEXT, TEXT, TEXT
248
+ ) FROM PUBLIC, sc_app, sc_provider;
249
+ REVOKE ALL ON FUNCTION semo_ops.set_dashboard_worker_owner(
250
+ BOOLEAN, TEXT, TEXT, UUID
251
+ ) FROM PUBLIC, sc_app, sc_provider;
252
+ REVOKE ALL ON FUNCTION semo_ops.issue_dashboard_worker_credential(
253
+ UUID, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER
254
+ ) FROM PUBLIC, sc_app, sc_provider;
255
+
256
+ GRANT EXECUTE ON FUNCTION semo_ops.register_dashboard_worker(
257
+ UUID, BOOLEAN, TEXT, UUID, TEXT, TEXT, TEXT, TEXT, TEXT
258
+ ) TO sc_fleet_board;
259
+ GRANT EXECUTE ON FUNCTION semo_ops.set_dashboard_worker_owner(
260
+ BOOLEAN, TEXT, TEXT, UUID
261
+ ) TO sc_fleet_board;
262
+ GRANT EXECUTE ON FUNCTION semo_ops.issue_dashboard_worker_credential(
263
+ UUID, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER
264
+ ) TO sc_fleet_board;
@@ -0,0 +1,57 @@
1
+ -- @schema-retarget: off
2
+ -- Make activated immutable DB releases the authoring authority for SEMO Ops
3
+ -- common skills while preserving existing Git-pinned releases as readable
4
+ -- legacy provenance. The migration runner owns the transaction boundary.
5
+
6
+ ALTER TABLE semicolony.skill_releases
7
+ ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'git-legacy',
8
+ ADD COLUMN base_release_id UUID,
9
+ ADD COLUMN authoring_task_id TEXT;
10
+
11
+ ALTER TABLE semicolony.skill_releases
12
+ ALTER COLUMN source_repository DROP NOT NULL,
13
+ ALTER COLUMN git_commit_sha DROP NOT NULL,
14
+ ALTER COLUMN git_tree_oid DROP NOT NULL;
15
+
16
+ ALTER TABLE semicolony.skill_releases
17
+ ADD CONSTRAINT skill_releases_source_kind_check
18
+ CHECK (source_kind IN ('db', 'git-legacy')),
19
+ ADD CONSTRAINT skill_releases_base_release_fk
20
+ FOREIGN KEY (base_release_id)
21
+ REFERENCES semicolony.skill_releases (release_id),
22
+ ADD CONSTRAINT skill_releases_not_own_base_check
23
+ CHECK (base_release_id IS NULL OR base_release_id <> release_id),
24
+ ADD CONSTRAINT skill_releases_provenance_check
25
+ CHECK (
26
+ (
27
+ source_kind = 'db'
28
+ AND source_repository IS NULL
29
+ AND git_commit_sha IS NULL
30
+ AND git_tree_oid IS NULL
31
+ AND NULLIF(btrim(authoring_task_id), '') IS NOT NULL
32
+ )
33
+ OR
34
+ (
35
+ source_kind = 'git-legacy'
36
+ AND NULLIF(btrim(source_repository), '') IS NOT NULL
37
+ AND git_commit_sha IS NOT NULL
38
+ AND git_tree_oid IS NOT NULL
39
+ AND base_release_id IS NULL
40
+ AND authoring_task_id IS NULL
41
+ )
42
+ );
43
+
44
+ CREATE UNIQUE INDEX skill_releases_db_authoring_task_unique
45
+ ON semicolony.skill_releases (channel, skill_name, authoring_task_id)
46
+ WHERE source_kind = 'db';
47
+
48
+ CREATE INDEX skill_releases_base_release_idx
49
+ ON semicolony.skill_releases (base_release_id)
50
+ WHERE base_release_id IS NOT NULL;
51
+
52
+ COMMENT ON COLUMN semicolony.skill_releases.source_kind IS
53
+ 'git-legacy for pre-DB authoring provenance; db for immutable release-to-release authoring.';
54
+ COMMENT ON COLUMN semicolony.skill_releases.base_release_id IS
55
+ 'Exact active DB release used as the edit base; NULL only for a DB-native create.';
56
+ COMMENT ON COLUMN semicolony.skill_releases.authoring_task_id IS
57
+ 'Task identity that produced a DB-native common-skill release.';