feat: complete device onboarding and mobile binding workflows
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-12 08:57:29 +08:00
parent 7846f30d0b
commit 98fb2965d0
116 changed files with 15669 additions and 64 deletions
@@ -0,0 +1,3 @@
-- New onboarding records are durable ownership and external-call evidence.
-- Do not silently destroy them during an application rollback.
DO $$ BEGIN RAISE EXCEPTION 'Migration 53 is forward-only; roll back the application to the compatibility release and retain onboarding records'; END $$;
+259
View File
@@ -0,0 +1,259 @@
BEGIN;
ALTER TABLE device
ADD COLUMN enrollment_mode text NOT NULL DEFAULT 'legacy' CHECK (enrollment_mode IN ('legacy','physical_confirm')),
ADD COLUMN factory_device_id varchar(32) UNIQUE,
ADD COLUMN factory_mac char(12) UNIQUE,
ADD COLUMN binding_generation bigint NOT NULL DEFAULT 0 CHECK (binding_generation >= 0),
ADD COLUMN onboarding_revision bigint NOT NULL DEFAULT 1 CHECK (onboarding_revision > 0),
ADD COLUMN onboarding_released_at timestamptz,
ADD COLUMN onboarding_boot_id uuid,
ADD COLUMN onboarding_last_seen_at timestamptz;
ALTER TABLE device_binding ADD COLUMN generation bigint;
WITH history AS (
SELECT id, 2 * row_number() OVER (PARTITION BY device_id ORDER BY bound_at,id) - 1 AS generation
FROM device_binding
) UPDATE device_binding b SET generation=h.generation FROM history h WHERE b.id=h.id;
UPDATE device d SET binding_generation=COALESCE((SELECT max(b.generation + CASE WHEN b.unbound_at IS NULL THEN 0 ELSE 1 END) FROM device_binding b WHERE b.device_id=d.id),0);
CREATE UNIQUE INDEX device_binding_generation_key ON device_binding(device_id,generation);
CREATE TABLE device_onboarding_key (
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
key_version bigint NOT NULL CHECK (key_version > 0),
ciphertext bytea NOT NULL,
status text NOT NULL CHECK (status IN ('PENDING','ACTIVE','REVOKED')),
created_at timestamptz NOT NULL DEFAULT now(),
activated_at timestamptz,
PRIMARY KEY(device_id,key_version),
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id)
);
CREATE UNIQUE INDEX device_onboarding_one_active_key ON device_onboarding_key(device_id) WHERE status='ACTIVE';
CREATE TABLE device_enrollment_job (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
device_id uuid NOT NULL,
actor_id uuid NOT NULL REFERENCES user_account(id),
request_key varchar(160) NOT NULL,
request_digest char(64) NOT NULL,
batch_no varchar(120) NOT NULL,
key_version bigint NOT NULL DEFAULT 1,
status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','DELIVERED','RELEASED','CANCELLED')),
receiver_key text,
delivery_ciphertext bytea,
delivery_expires_at timestamptz,
evidence_ref text,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id),
UNIQUE(tenant_id,actor_id,request_key)
);
CREATE TABLE device_time_challenge (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL REFERENCES device(id),
key_version bigint NOT NULL,
boot_id uuid NOT NULL,
client_nonce varchar(32) NOT NULL,
server_time_ms bigint NOT NULL,
expires_at timestamptz NOT NULL,
consumed_by uuid,
UNIQUE(device_id,key_version,boot_id,client_nonce)
);
CREATE TABLE device_setup_session (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
boot_id uuid NOT NULL,
local_setup_id uuid NOT NULL,
binding_generation bigint NOT NULL,
mode text NOT NULL CHECK(mode IN ('ENROLL','RECOVERY')),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
closed_at timestamptz,
close_reason text,
web_ack_binding_id uuid,
web_ack_generation bigint,
web_ack_revision bigint,
web_ack_at timestamptz,
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id)
);
CREATE INDEX device_setup_device_idx ON device_setup_session(device_id,created_at DESC);
CREATE TABLE device_handoff (
token_hash char(64) PRIMARY KEY,
setup_id uuid NOT NULL REFERENCES device_setup_session(id),
code_challenge varchar(64) NOT NULL,
expires_at timestamptz NOT NULL,
consumed_at timestamptz
);
CREATE TABLE onboarding_web_session (
token_hash char(64) PRIMARY KEY,
setup_id uuid NOT NULL REFERENCES device_setup_session(id),
csrf_hash char(64) NOT NULL,
user_id uuid REFERENCES user_account(id),
issuer text,
subject text,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
);
CREATE TABLE device_binding_claim (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
setup_id uuid NOT NULL REFERENCES device_setup_session(id),
user_id uuid NOT NULL REFERENCES user_account(id),
household_id uuid NOT NULL,
subject_id uuid NOT NULL,
expected_generation bigint NOT NULL,
challenge_hash char(64) NOT NULL,
challenge_ciphertext bytea NOT NULL,
binding_digest char(64) NOT NULL,
status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','CONFIRMED','CANCELLED','EXPIRED')),
expires_at timestamptz NOT NULL,
press_event_id uuid,
binding_id uuid REFERENCES device_binding(id),
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id),
FOREIGN KEY(household_id,tenant_id) REFERENCES household(id,tenant_id),
FOREIGN KEY(subject_id,tenant_id) REFERENCES subject_profile(id,tenant_id),
UNIQUE(device_id,press_event_id)
);
CREATE UNIQUE INDEX device_binding_claim_one_pending ON device_binding_claim(device_id) WHERE status='PENDING';
CREATE TABLE device_onboarding_request (
actor_key text NOT NULL,
scope text NOT NULL,
request_id uuid NOT NULL,
request_digest char(64) NOT NULL,
device_id uuid NOT NULL REFERENCES device(id),
binding_generation bigint NOT NULL,
response_ciphertext bytea NOT NULL,
expires_at timestamptz NOT NULL,
PRIMARY KEY(actor_key,scope,request_id)
);
CREATE TABLE device_state_snapshot (
device_id uuid NOT NULL REFERENCES device(id),
revision bigint NOT NULL,
binding_generation bigint NOT NULL,
state_hash char(64) NOT NULL,
state jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(device_id,revision)
);
CREATE TABLE device_state_ack (
device_id uuid PRIMARY KEY REFERENCES device(id),
revision bigint NOT NULL,
binding_generation bigint NOT NULL,
state_hash char(64) NOT NULL,
boot_id uuid NOT NULL,
local_generation bigint NOT NULL CHECK(local_generation >= 0),
accepted_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE device_activation_job (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL UNIQUE,
tenant_id uuid NOT NULL,
provider_device_name varchar(128) NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'NOT_STARTED' CHECK(status IN ('NOT_STARTED','RESERVED','CONTACT_POSSIBLE','REGISTERED_OBSERVED','READY','RECONCILE_REQUIRED','BLOCKED')),
binding_generation bigint NOT NULL,
config_version bigint NOT NULL DEFAULT 1,
config_ciphertext bytea,
config_digest char(64),
config_expires_at timestamptz,
config_ack_at timestamptz,
engine_ready_at timestamptz,
current_attempt_id uuid,
last_error_code text,
reconciliation_evidence text,
version bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id)
);
CREATE TABLE device_activation_attempt (
id uuid PRIMARY KEY,
activation_id uuid NOT NULL REFERENCES device_activation_job(id),
executor text NOT NULL CHECK(executor IN ('SERVER','DEVICE')),
stage text NOT NULL CHECK(stage IN ('SERVER_REGISTER','ENGINE_INIT','CONVERSATION_TEST')),
binding_generation bigint NOT NULL,
config_version bigint NOT NULL,
status text NOT NULL CHECK(status IN ('CONTACT_POSSIBLE','SUCCESS_OBSERVED','DEFINITE_FAILURE','UNKNOWN')),
permit_expires_at timestamptz NOT NULL,
permit_hash char(64),
conversation_id uuid,
sdk_result integer,
evidence jsonb,
result_digest char(64),
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE TABLE onboarding_sms_challenge (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
session_hash char(64) NOT NULL REFERENCES onboarding_web_session(token_hash),
phone_digest char(64) NOT NULL,
phone_ciphertext bytea NOT NULL,
code_hash char(64) NOT NULL,
attempts integer NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'SENDING' CHECK(status IN ('SENDING','SENT','FAILED','CONSUMED')),
request_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
UNIQUE(session_hash,request_id)
);
CREATE INDEX onboarding_sms_phone_time ON onboarding_sms_challenge(phone_digest,created_at DESC);
-- Every binding writer, including privacy deletion, advances the same generation.
-- Inserting ownership for a new-protocol device additionally needs a pending claim.
CREATE FUNCTION maintain_device_binding_generation() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE d device%ROWTYPE; claim_setting text; is_changed boolean;
BEGIN
SELECT * INTO d FROM device WHERE id=COALESCE(NEW.device_id,OLD.device_id) FOR UPDATE;
IF TG_OP='INSERT' THEN
IF d.enrollment_mode='physical_confirm' THEN
claim_setting := current_setting('app.onboarding_claim',true);
IF NOT EXISTS(SELECT 1 FROM device_binding_claim c WHERE c.id::text=claim_setting
AND c.device_id=NEW.device_id AND c.status='PENDING' AND c.expires_at>now()
AND c.expected_generation=d.binding_generation AND c.user_id=NEW.bound_by
AND c.household_id=NEW.household_id AND c.subject_id=NEW.subject_id) THEN
RAISE EXCEPTION 'DEVICE_CONFIRMATION_REQUIRED' USING ERRCODE='P0001';
END IF;
END IF;
NEW.generation := d.binding_generation+1;
is_changed := true;
ELSE
IF NEW.device_id<>OLD.device_id OR NEW.household_id<>OLD.household_id OR NEW.subject_id<>OLD.subject_id OR NEW.generation IS DISTINCT FROM OLD.generation THEN
RAISE EXCEPTION 'binding identity is immutable' USING ERRCODE='P0001';
END IF;
IF OLD.unbound_at IS NOT NULL AND NEW.unbound_at IS NULL THEN
RAISE EXCEPTION 'closed binding cannot be restored' USING ERRCODE='P0001';
END IF;
is_changed := OLD.unbound_at IS NULL AND NEW.unbound_at IS NOT NULL;
END IF;
IF is_changed THEN
UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1 WHERE id=d.id;
UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=d.id AND status='PENDING'
AND id::text IS DISTINCT FROM current_setting('app.onboarding_claim',true);
IF TG_OP='UPDATE' THEN
UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='BINDING_CHANGED' WHERE device_id=d.id;
UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=d.id);
UPDATE device_activation_job SET config_ack_at=NULL,engine_ready_at=NULL,config_ciphertext=NULL,config_digest=NULL,
status=CASE WHEN status IN ('CONTACT_POSSIBLE','RECONCILE_REQUIRED') THEN 'RECONCILE_REQUIRED' ELSE 'BLOCKED' END,
last_error_code='BINDING_CHANGED',version=version+1,updated_at=now() WHERE device_id=d.id;
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER device_binding_generation_guard BEFORE INSERT OR UPDATE ON device_binding FOR EACH ROW EXECUTE FUNCTION maintain_device_binding_generation();
COMMIT;
@@ -0,0 +1 @@
DO $$ BEGIN RAISE EXCEPTION 'Onboarding sessions and provider evidence require a reviewed forward migration'; END $$;
@@ -0,0 +1,21 @@
BEGIN;
CREATE TABLE onboarding_account_session (
token_hash char(64) PRIMARY KEY,
csrf_hash char(64) NOT NULL,
user_id uuid NOT NULL REFERENCES user_account(id),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
);
ALTER TABLE device_activation_job
ADD COLUMN provider_instance_id varchar(200),
ADD COLUMN provider_product_key varchar(200),
ADD COLUMN provider_secret_ciphertext bytea,
ADD COLUMN provider_rtc_app_id varchar(200),
ADD COLUMN provider_bot_id varchar(200),
ADD COLUMN provider_transport varchar(32),
ADD COLUMN provider_minimum_sdk varchar(80);
ALTER TABLE device_activation_attempt ADD COLUMN permit_ciphertext bytea;
CREATE UNIQUE INDEX onboarding_one_device_attempt ON device_activation_attempt(activation_id)
WHERE executor='DEVICE' AND status IN ('CONTACT_POSSIBLE','UNKNOWN');
COMMIT;
@@ -0,0 +1 @@
DO $$ BEGIN RAISE EXCEPTION 'Onboarding identity fences must remain in place; use a reviewed forward migration'; END $$;
@@ -0,0 +1,43 @@
BEGIN;
ALTER TABLE device_activation_job ADD COLUMN config_local_generation bigint, ADD COLUMN config_ack_boot_id uuid;
ALTER TABLE device_binding_claim ADD CONSTRAINT onboarding_claim_user_tenant_fk FOREIGN KEY(user_id,tenant_id) REFERENCES user_account(id,tenant_id);
CREATE FUNCTION project_onboarding_unbind() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE d device%ROWTYPE;
BEGIN
IF OLD.unbound_at IS NULL AND NEW.unbound_at IS NOT NULL THEN
SELECT * INTO d FROM device WHERE id=NEW.device_id FOR UPDATE;
IF d.enrollment_mode='physical_confirm' THEN
UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=d.id AND status='ACTIVE';
UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=now() WHERE device_id=d.id AND status='ACTIVE';
UPDATE device_activation_job SET config_local_generation=NULL,config_ack_boot_id=NULL WHERE device_id=d.id;
IF d.activation_state NOT IN ('FACTORY','CLAIMABLE','LOST','REVOKED') THEN
UPDATE device SET activation_state='CLAIMABLE',activation_revision=activation_revision+1,activation_updated_at=now(),activation_reason='binding-closed',version=version+1,updated_at=now() WHERE id=d.id;
INSERT INTO device_activation_transition(tenant_id,device_id,from_state,to_state,reason,source,actor_type,activation_revision)
VALUES(d.tenant_id,d.id,d.activation_state,'CLAIMABLE','归属已由授权业务关闭','binding-generation-trigger','system',d.activation_revision+1);
END IF;
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER onboarding_unbind_projection AFTER UPDATE ON device_binding FOR EACH ROW EXECUTE FUNCTION project_onboarding_unbind();
CREATE FUNCTION guard_onboarding_identity() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF OLD.enrollment_mode='physical_confirm' THEN
IF NEW.enrollment_mode<>OLD.enrollment_mode OR NEW.factory_device_id IS DISTINCT FROM OLD.factory_device_id OR NEW.factory_mac IS DISTINCT FROM OLD.factory_mac THEN
RAISE EXCEPTION 'ONBOARDING_IDENTITY_IMMUTABLE' USING ERRCODE='P0001';
END IF;
IF NEW.status<>OLD.status OR NEW.activation_state IN ('LOST','REVOKED') AND NEW.activation_state<>OLD.activation_state THEN
NEW.onboarding_revision:=NEW.onboarding_revision+1;
IF NEW.status<>'registered' OR NEW.activation_state IN ('LOST','REVOKED') THEN
UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=OLD.id AND status='PENDING';
UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='DEVICE_DISABLED' WHERE device_id=OLD.id;
UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=OLD.id);
END IF;
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER onboarding_identity_guard BEFORE UPDATE ON device FOR EACH ROW EXECUTE FUNCTION guard_onboarding_identity();
COMMIT;
@@ -0,0 +1 @@
DO $$ BEGIN RAISE EXCEPTION 'Runtime generation and test evidence must be retained'; END $$;
@@ -0,0 +1,4 @@
BEGIN;
ALTER TABLE conversation ADD COLUMN onboarding_generation bigint, ADD COLUMN activation_attempt_id uuid REFERENCES device_activation_attempt(id), ADD COLUMN activation_test_deadline timestamptz;
CREATE UNIQUE INDEX conversation_activation_attempt_unique ON conversation(activation_attempt_id) WHERE activation_attempt_id IS NOT NULL;
COMMIT;
@@ -0,0 +1 @@
DO $$ BEGIN RAISE EXCEPTION 'Retain readiness evidence with a forward migration'; END $$;
@@ -0,0 +1,3 @@
BEGIN;
ALTER TABLE device_activation_job ADD COLUMN ready_binding_generation bigint;
COMMIT;
@@ -0,0 +1,11 @@
BEGIN;
DO $$ BEGIN
IF EXISTS(SELECT 1 FROM device_activation_attempt WHERE stage='SDK_REGISTER') THEN
RAISE EXCEPTION 'SDK_REGISTER history exists; use application rollback without removing migration 58';
END IF;
END $$;
ALTER TABLE device_activation_job DROP COLUMN sdk_registered_at;
ALTER TABLE device_activation_attempt DROP CONSTRAINT device_activation_attempt_stage_check;
ALTER TABLE device_activation_attempt ADD CONSTRAINT device_activation_attempt_stage_check
CHECK(stage IN ('SERVER_REGISTER','ENGINE_INIT','CONVERSATION_TEST'));
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
ALTER TABLE device_activation_attempt DROP CONSTRAINT device_activation_attempt_stage_check;
ALTER TABLE device_activation_attempt ADD CONSTRAINT device_activation_attempt_stage_check
CHECK(stage IN ('SERVER_REGISTER','ENGINE_INIT','SDK_REGISTER','CONVERSATION_TEST'));
-- Registration is a physical-device fact and survives changes of owner.
ALTER TABLE device_activation_job ADD COLUMN sdk_registered_at timestamptz;
UPDATE device_activation_job SET sdk_registered_at=updated_at WHERE provider_secret_ciphertext IS NOT NULL;
-- Old config packets use a different contract. Never replay them as 1.1.
UPDATE device_activation_job SET config_ciphertext=NULL,config_digest=NULL,config_expires_at=NULL,
config_ack_at=NULL,config_version=config_version+1,version=version+1,
status=CASE WHEN status='READY' THEN 'REGISTERED_OBSERVED' ELSE status END;
DELETE FROM device_onboarding_request WHERE actor_key LIKE 'device:%';
COMMIT;