@team-semicolon/semicolony-cli 4.18.123 → 4.18.124

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,117 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 220: a worker credential may be issued without an expiry.
3
+ --
4
+ -- Why
5
+ --
6
+ -- The 30-day default is the only automatic revocation a worker credential has.
7
+ -- It is also why worker devices die silently: nothing rotates the key, nothing
8
+ -- alerts, and the device simply answers `401 invalid or expired credential` on
9
+ -- every route until somebody complains. Measured 2026-09-07 -- both
10
+ -- non-operator human devices were dead this way, `jay` since 2026-08-22 and
11
+ -- `roki` since 2026-08-26, the second unnoticed for twelve days because only
12
+ -- `jay` happened to ask.
13
+ --
14
+ -- The runtime has always supported a credential that does not expire. Every
15
+ -- resolution path spells it `coalesce(expires_at, 'infinity'::timestamptz)`,
16
+ -- and the TypeScript resolver agrees:
17
+ -- `packages/kb-gateway/src/lib/tenant-credentials.ts` reads
18
+ -- `row.expires_at != null && Date.parse(row.expires_at) <= now`. Tenant
19
+ -- issuance has accepted an omitted expiry as indefinite since it was written
20
+ -- (`issueGatewayCredentialForTenant`; its `--expires-days` is documented as
21
+ -- "미지정 시 무기한"). The worker entry point was the lone holdout, and only
22
+ -- because of the guard rewritten below.
23
+ --
24
+ -- What changes
25
+ --
26
+ -- `p_expires_days` becomes optional instead of mandatory. A value still has to
27
+ -- be an integer in 1..90, so every previously accepted call behaves exactly as
28
+ -- before; NULL now stores `expires_at = NULL`, which every reader already
29
+ -- treats as "does not expire". The contract widens by exactly one case and
30
+ -- narrows nothing.
31
+ --
32
+ -- What does NOT change
33
+ --
34
+ -- Revocation. `revoke_gateway_credential`, the automatic revoke-previous on
35
+ -- reissue inside this same function, and the device-liveness operator boundary
36
+ -- are all untouched, and they stay the way a credential is taken away. A
37
+ -- credential with no expiry is not unrevocable -- it is only unexpiring, and
38
+ -- that distinction is the entire safety argument for this migration.
39
+ --
40
+ -- How
41
+ --
42
+ -- The two edits rewrite the installed definition in place (the 187/194/205
43
+ -- pattern). Copying the ~10KB body into this file would silently revert any
44
+ -- correction it has received since the copy was taken.
45
+ --
46
+ -- Edit 1 relaxes the guard. Edit 2 makes the resulting NULL explicit at the
47
+ -- INSERT: `make_interval` is STRICT, so `clock_timestamp() + make_interval(days
48
+ -- => NULL)` already evaluates to NULL, but a security boundary should not
49
+ -- depend on the reader knowing that.
50
+ -- ---------------------------------------------------------------------------
51
+
52
+ DO $worker_optional_expiry$
53
+ DECLARE
54
+ v_target constant text :=
55
+ 'semicolony.issue_worker_gateway_credential(uuid,text,text,text,text,text[],text,integer,jsonb)';
56
+
57
+ v_guard_old constant text :=
58
+ E' IF p_expires_days IS NULL OR p_expires_days < 1 OR p_expires_days > 90 THEN\n'
59
+ || E' RAISE EXCEPTION ''worker credential expiry must be between 1 and 90 days'' USING ERRCODE = ''22023'';';
60
+ v_guard_new constant text :=
61
+ E' IF p_expires_days IS NOT NULL AND (p_expires_days < 1 OR p_expires_days > 90) THEN\n'
62
+ || E' RAISE EXCEPTION ''worker credential expiry must be between 1 and 90 days when given'' USING ERRCODE = ''22023'';';
63
+
64
+ v_insert_old constant text := E' clock_timestamp() + make_interval(days => p_expires_days),';
65
+ v_insert_new constant text :=
66
+ E' CASE WHEN p_expires_days IS NULL THEN NULL\n'
67
+ || E' ELSE clock_timestamp() + make_interval(days => p_expires_days) END,';
68
+
69
+ v_definition text;
70
+ v_guard_hits integer;
71
+ v_insert_hits integer;
72
+ BEGIN
73
+ SELECT pg_get_functiondef(v_target::regprocedure::oid) INTO STRICT v_definition;
74
+
75
+ v_guard_hits :=
76
+ (char_length(v_definition) - char_length(replace(v_definition, v_guard_old, '')))
77
+ / char_length(v_guard_old);
78
+ v_insert_hits :=
79
+ (char_length(v_definition) - char_length(replace(v_definition, v_insert_old, '')))
80
+ / char_length(v_insert_old);
81
+
82
+ -- Already relaxed: re-running must be a no-op, not an exception.
83
+ IF v_guard_hits = 0
84
+ AND v_insert_hits = 0
85
+ AND position(v_guard_new IN v_definition) > 0
86
+ AND position(v_insert_new IN v_definition) > 0
87
+ THEN
88
+ RETURN;
89
+ END IF;
90
+
91
+ IF v_guard_hits <> 1 THEN
92
+ RAISE EXCEPTION 'unexpected worker credential expiry guard in % (% occurrences)',
93
+ v_target, v_guard_hits;
94
+ END IF;
95
+ IF v_insert_hits <> 1 THEN
96
+ RAISE EXCEPTION 'unexpected worker credential expires_at expression in % (% occurrences)',
97
+ v_target, v_insert_hits;
98
+ END IF;
99
+
100
+ v_definition := replace(v_definition, v_guard_old, v_guard_new);
101
+ v_definition := replace(v_definition, v_insert_old, v_insert_new);
102
+ EXECUTE v_definition;
103
+ END;
104
+ $worker_optional_expiry$;
105
+
106
+ -- ---------------------------------------------------------------------------
107
+ -- `issue_worker_asset_gateway_credential` needs no edit: it carries no expiry
108
+ -- guard of its own and forwards `p_expires_days` to the function rewritten
109
+ -- above, so it inherits the relaxation. Verified against the installed
110
+ -- definition on 2026-09-07 -- `p_expires_days` appears there exactly once, as
111
+ -- a call argument, and `make_interval` not at all.
112
+ --
113
+ -- The worker-agent and dashboard issuers keep their mandatory 1..90 expiry.
114
+ -- They are unattended service credentials with their own rotation paths; this
115
+ -- migration is scoped to the human worker lane that actually stalls on a
116
+ -- refresh.
117
+ -- ---------------------------------------------------------------------------
@@ -0,0 +1,154 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 221: stop gating worker credential issuance on device liveness, and stop
3
+ -- revoking the previous credential before the new one has ever worked.
4
+ --
5
+ -- Why
6
+ --
7
+ -- Two rules in `issue_worker_gateway_credential` turned an expired credential
8
+ -- into an outage that needed an operator to type its way out.
9
+ --
10
+ -- 1. The liveness gate (`last_doctor_status = 'pass'` and `last_seen_at`
11
+ -- within 24 hours) cannot be satisfied by the device that needs a
12
+ -- credential most. `last_seen_at` is written by the Gateway when a worker
13
+ -- reports, and a worker with a dead credential cannot report. The only way
14
+ -- through is `semo worker register`, which sets both fields directly. A
15
+ -- precondition an operator satisfies by typing a flag is not a control; it
16
+ -- is a detour, and on 2026-09-10 that detour overwrote `roki-pc`'s
17
+ -- hostname and OS with the operator's own machine because `register`
18
+ -- defaults those from the current host.
19
+ --
20
+ -- `last_doctor_status` is worse than useless as a gate: `worker doctor`
21
+ -- does not report it: an operator records it by hand. On 2026-09-10
22
+ -- `roki-pc` carried `pass` in the registry while its actual doctor run
23
+ -- reported two failures. The gate cannot see the difference.
24
+ --
25
+ -- 2. The automatic revoke-previous makes issuance destructive before the new
26
+ -- credential is known to work. Plaintext prints once and delivery is a
27
+ -- manual copy into the team canvas, so any gap between issuance and
28
+ -- delivery is an outage. Measured: `roki` was left with zero valid
29
+ -- credentials for three days after a rotation whose replacement was never
30
+ -- issued, and `jay` spent that period holding a credential revoked on
31
+ -- 2026-09-07 while the canvas still advertised it.
32
+ --
33
+ -- What changes
34
+ --
35
+ -- The device binding still has to exist, still has to be `active`, and still
36
+ -- has to be `worker-gateway`. Those are deliberate operator decisions. The two
37
+ -- liveness conditions are removed from the gate; they remain valuable as
38
+ -- *reporting*, which is where this change moves them.
39
+ --
40
+ -- Issuance no longer revokes anything, and no longer repoints the device at
41
+ -- the new credential. Both move to first use: when the new credential
42
+ -- authenticates for the first time, the Gateway repoints
43
+ -- `worker_devices.gateway_credential_id` and revokes its predecessors
44
+ -- (`tenant-credentials.ts`).
45
+ --
46
+ -- The repoint has to move with the revoke, not stay behind. `getActiveRelease`
47
+ -- requires `wd.gateway_credential_id = <the calling credential>`, so a device
48
+ -- repointed at an undelivered credential cannot fetch a release even though
49
+ -- its old credential still authenticates. Deferring only the revoke would
50
+ -- trade a total outage for a silent half one.
51
+ --
52
+ -- The invariant "one active credential per worker device" is preserved; what
53
+ -- changes is that it is reached by success rather than by hope.
54
+ --
55
+ -- What does NOT change
56
+ --
57
+ -- `revoke_gateway_credential` still exists and is still how a credential is
58
+ -- taken away deliberately, which is what a suspected leak calls for. The
59
+ -- `FOR UPDATE` row lock on the device stays: it serializes concurrent
60
+ -- issuance for the same device. Expiry, scopes, workspace grants, and the
61
+ -- worker-agent and dashboard issuers are untouched.
62
+ --
63
+ -- How
64
+ --
65
+ -- Two in-place edits of the installed definition, the 187/194/205/220 pattern.
66
+ -- Copying the body into this file would silently revert corrections it has
67
+ -- received since the copy was taken.
68
+ -- ---------------------------------------------------------------------------
69
+
70
+ DO $worker_liveness_and_rotation$
71
+ DECLARE
72
+ v_target constant text :=
73
+ 'semicolony.issue_worker_gateway_credential(uuid,text,text,text,text,text[],text,integer,jsonb)';
74
+
75
+ v_gate_old constant text :=
76
+ E' AND wd.kb_access_mode = ''worker-gateway''\n'
77
+ || E' AND wd.last_doctor_status = ''pass''\n'
78
+ || E' AND wd.last_seen_at >= clock_timestamp() - interval ''24 hours''\n'
79
+ || E' FOR UPDATE;';
80
+ v_gate_new constant text :=
81
+ E' AND wd.kb_access_mode = ''worker-gateway''\n'
82
+ || E' FOR UPDATE;';
83
+
84
+ v_revoke_old constant text :=
85
+ E' IF v_previous_credential_id IS NOT NULL THEN\n'
86
+ || E' UPDATE semicolony.gateway_credentials\n'
87
+ || E' SET status = ''revoked'',\n'
88
+ || E' revoked_at = clock_timestamp(),\n'
89
+ || E' revoked_reason = ''worker credential rotated''\n'
90
+ || E' WHERE id = v_previous_credential_id\n'
91
+ || E' AND status = ''active'';\n'
92
+ || E' END IF;\n\n';
93
+ v_revoke_new constant text := '';
94
+
95
+ v_pointer_old constant text :=
96
+ E' UPDATE semicolony.worker_devices\n'
97
+ || E' SET gateway_credential_id = v_credential_id\n'
98
+ || E' WHERE device_id = p_device_id\n'
99
+ || E' AND worker_id = p_worker_id;\n\n';
100
+ v_pointer_new constant text := '';
101
+
102
+ v_definition text;
103
+ v_gate_hits integer;
104
+ v_revoke_hits integer;
105
+ v_pointer_hits integer;
106
+ BEGIN
107
+ SELECT pg_get_functiondef(v_target::regprocedure::oid) INTO STRICT v_definition;
108
+
109
+ v_gate_hits :=
110
+ (char_length(v_definition) - char_length(replace(v_definition, v_gate_old, '')))
111
+ / char_length(v_gate_old);
112
+ v_revoke_hits :=
113
+ (char_length(v_definition) - char_length(replace(v_definition, v_revoke_old, '')))
114
+ / char_length(v_revoke_old);
115
+ v_pointer_hits :=
116
+ (char_length(v_definition) - char_length(replace(v_definition, v_pointer_old, '')))
117
+ / char_length(v_pointer_old);
118
+
119
+ -- Already applied: re-running must be a no-op, not an exception.
120
+ IF v_gate_hits = 0
121
+ AND v_revoke_hits = 0
122
+ AND v_pointer_hits = 0
123
+ AND position(v_gate_new IN v_definition) > 0
124
+ THEN
125
+ RETURN;
126
+ END IF;
127
+
128
+ IF v_gate_hits <> 1 THEN
129
+ RAISE EXCEPTION 'unexpected worker device liveness gate in % (% occurrences)',
130
+ v_target, v_gate_hits;
131
+ END IF;
132
+ IF v_revoke_hits <> 1 THEN
133
+ RAISE EXCEPTION 'unexpected revoke-previous block in % (% occurrences)',
134
+ v_target, v_revoke_hits;
135
+ END IF;
136
+ IF v_pointer_hits <> 1 THEN
137
+ RAISE EXCEPTION 'unexpected device repoint block in % (% occurrences)',
138
+ v_target, v_pointer_hits;
139
+ END IF;
140
+
141
+ v_definition := replace(v_definition, v_gate_old, v_gate_new);
142
+ v_definition := replace(v_definition, v_revoke_old, v_revoke_new);
143
+ v_definition := replace(v_definition, v_pointer_old, v_pointer_new);
144
+ EXECUTE v_definition;
145
+ END;
146
+ $worker_liveness_and_rotation$;
147
+
148
+ -- ---------------------------------------------------------------------------
149
+ -- `issue_worker_asset_gateway_credential` needs no edit. It forwards to the
150
+ -- function rewritten above and carries neither the liveness gate nor a
151
+ -- revoke-previous block of its own. Verified against the installed definition
152
+ -- on 2026-09-10: `last_seen_at`, `last_doctor_status`, and `revoked_reason`
153
+ -- appear there zero times.
154
+ -- ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semicolony-cli",
3
- "version": "4.18.123",
3
+ "version": "4.18.124",
4
4
  "description": "SemiColony CLI - AI operations and agent orchestration installer",
5
5
  "main": "dist/bundle.js",
6
6
  "bin": {