letopis 0.21.0 → 1.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 (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +313 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3469
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +362 -563
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -252
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -352
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -685
  89. package/sql/seed.auth.sql +0 -73
package/llms.txt ADDED
@@ -0,0 +1,26 @@
1
+ # letopis
2
+
3
+ > Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
4
+
5
+ Key facts for writing code with letopis 1.0:
6
+
7
+ - ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`.
8
+ - Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
9
+ - Classes are hubs (objects) and links (relations with ends), described with `hub()`/`link()` from `letopis/model` and written to the database with `syncModels()` or `letopis sync`. A class with a key gets a deterministic UUID v5 id (`db.idOf`); uniqueness is the key.
10
+ - Verbs return a chain; a terminal (`rows`, `first`, `ids`, `count`, …) executes the whole plan in one transaction. `create` only creates (a taken key is `exists`), `update` merges and never creates, `upsert` replaces the whole object (keyed classes only), `delete()` is a preview and `delete({ confirm: true })` deletes.
11
+ - Money and counters: `update({ balance: inc(n) })` adds inside the statement; `update(patch, { rev })` fails with `conflict` if the row changed; `forUpdate()` locks rows inside `db.begin()`.
12
+ - Access rules are always on and deny by default; `up({ tenant })` or `createTenant()` creates a tenant with the rule "authenticated users may do everything" and its first user.
13
+ - Errors carry a lowercase `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, `removed` (0.21 API with its replacement), …
14
+
15
+ ## Docs
16
+
17
+ - [Agent cheatsheet](AGENT-CHEATSHEET.en.md): one page for an AI agent writing code with letopis — working cycle and pitfalls
18
+ - [Guide](README.en.md): task-based guide, API reference, errors, limitations
19
+ - [Шпаргалка для агента](AGENT-CHEATSHEET.md): the cheatsheet in Russian (original)
20
+ - [Руководство](README.md): the guide in Russian (original)
21
+
22
+ ## Optional
23
+
24
+ - [Migration from 0.21](MIGRATION.md): data import with `letopis import` and the API replacement table (Russian)
25
+ - [Changelog](CHANGELOG.md): decisions by version, BREAKING changes of 1.0 (Russian)
26
+ - Model of a concrete installation for an agent: `letopis describe --format text --out llms.txt` or `describeText(db.describe())` — classes, keys, fields and ends of that schema
package/package.json CHANGED
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "0.21.0",
4
- "description": "Letopis (летопись): append-only versioned entity store on TimescaleDB with dot-notation chains — every change is a new row, history is first-class (asOf, versions, watch, cascade tombstones)",
3
+ "version": "1.0.0",
4
+ "description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
5
5
  "keywords": [
6
- "timescaledb",
7
6
  "postgres",
8
- "append-only",
9
- "immutable",
7
+ "postgresql",
8
+ "row-level-security",
10
9
  "versioning",
11
10
  "audit",
12
11
  "temporal",
13
12
  "asof",
14
- "event-sourcing",
15
- "booking",
16
13
  "graph",
17
- "jsonb"
14
+ "jsonb",
15
+ "multi-tenant",
16
+ "acl",
17
+ "uuidv5"
18
18
  ],
19
19
  "license": "MIT",
20
20
  "repository": {
@@ -31,37 +31,67 @@
31
31
  ".": {
32
32
  "types": "./dist/index.d.ts",
33
33
  "import": "./dist/index.js"
34
+ },
35
+ "./model": {
36
+ "types": "./dist/model.d.ts",
37
+ "import": "./dist/model.js"
34
38
  }
35
39
  },
36
40
  "engines": {
37
- "node": ">=20"
41
+ "node": ">=22"
42
+ },
43
+ "bin": {
44
+ "letopis": "dist/cli.js"
38
45
  },
39
46
  "files": [
40
47
  "dist",
41
- "scripts",
42
48
  "sql",
43
- "docker",
44
49
  "README.md",
50
+ "README.en.md",
51
+ "AGENT-CHEATSHEET.md",
52
+ "AGENT-CHEATSHEET.en.md",
53
+ "llms.txt",
45
54
  "CHANGELOG.md",
55
+ "MIGRATION.md",
46
56
  "LICENSE"
47
57
  ],
48
58
  "scripts": {
49
- "build": "tsc",
59
+ "build": "tsc -p tsconfig.build.json",
50
60
  "typecheck": "tsc --noEmit",
51
61
  "test": "tsx --test --test-concurrency=1 --test-force-exit test/*.test.ts",
52
62
  "check:docs": "node scripts/check-docs.mjs",
63
+ "check:package": "node scripts/check-package.mjs",
64
+ "seed:booking": "tsx scripts/gen-seed-booking.mjs",
65
+ "test:import-salon": "tsx bench/import-salon.mjs",
53
66
  "api:contract": "node scripts/gen-api-contract.mjs",
54
- "bench": "tsx bench/history.bench.mjs",
55
67
  "prepublishOnly": "npm run typecheck && npm run check:docs && npm run build"
56
68
  },
57
69
  "dependencies": {
58
- "fastest-validator": "^1.19.0",
59
- "postgres": "^3.4.5"
70
+ "postgres": "^3.4.9"
71
+ },
72
+ "peerDependencies": {
73
+ "zod": "^4",
74
+ "@electric-sql/pglite": "^0.5.8",
75
+ "@electric-sql/pglite-socket": "^0.2.11"
76
+ },
77
+ "peerDependenciesMeta": {
78
+ "zod": {
79
+ "optional": true
80
+ },
81
+ "@electric-sql/pglite": {
82
+ "optional": true
83
+ },
84
+ "@electric-sql/pglite-socket": {
85
+ "optional": true
86
+ }
60
87
  },
61
88
  "devDependencies": {
62
- "@types/node": "^22.10.0",
63
- "ioredis": "^5.11.1",
64
- "tsx": "^4.19.0",
65
- "typescript": "^5.7.0"
89
+ "@electric-sql/pglite": "^0.5.8",
90
+ "@electric-sql/pglite-socket": "^0.2.11",
91
+ "@types/node": "^22.20.5",
92
+ "letopis-0": "npm:letopis@0.21.0",
93
+ "tsx": "^4.23.15",
94
+ "typescript": "~5.9.3",
95
+ "zod": "4.6.5"
66
96
  }
67
97
  }
@@ -0,0 +1,136 @@
1
+ -- letopis 1.0 — хранение и права (план, §2.2, этап 1, п. 1).
2
+ -- Ровно три таблицы: entity (текущее состояние), log (журнал версий), secret (креды и сессии).
3
+ -- Повторный накат ничего не меняет: всё создаётся с проверкой существования.
4
+
5
+ -- Схему создаёт установщик (create schema … authorization владелец): у владельца нет права CREATE в базе.
6
+ revoke all on schema @SCHEMA@ from public;
7
+ grant usage on schema @SCHEMA@ to @APP@;
8
+
9
+ -- Все цели концов плоским списком: {роль: id} или {роль: [id, …]}.
10
+ create or replace function @SCHEMA@.link_ids(links jsonb) returns uuid[]
11
+ language sql immutable parallel safe
12
+ return (
13
+ -- lax $.*[*]: значения концов, массивы разворачиваются, одиночная строка остаётся собой
14
+ select coalesce(array_agg(distinct (v #>> '{}')::uuid order by (v #>> '{}')::uuid), '{}'::uuid[])
15
+ from jsonb_path_query(coalesce(links, '{}'::jsonb), 'lax $.*[*]') as v
16
+ );
17
+
18
+ -- Снимок версии для хэша (§2.2). Не зависит от настроек сеанса: время — числом микросекунд,
19
+ -- остальное — через текстовый вид jsonb, который от настроек не зависит.
20
+ create or replace function @SCHEMA@.snapshot(
21
+ id uuid, rev int, class text, tenant uuid, owner uuid, links jsonb, data jsonb, tags text[],
22
+ at timestamptz, author uuid, agent text, op text, reason text, moved uuid
23
+ ) returns bytea
24
+ language sql immutable parallel safe
25
+ return convert_to(
26
+ jsonb_build_array(
27
+ id, rev, class, tenant, owner, links, data, to_jsonb(tags),
28
+ (extract(epoch from at) * 1000000)::bigint, author, agent, op, reason, moved
29
+ )::text,
30
+ 'UTF8'
31
+ );
32
+
33
+ create or replace function @SCHEMA@.version_hash(prev_hash bytea, snap bytea) returns bytea
34
+ language sql immutable parallel safe
35
+ return sha256(coalesce(prev_hash, ''::bytea) || snap);
36
+
37
+ create table if not exists @SCHEMA@.entity (
38
+ id uuid primary key,
39
+ rev int not null,
40
+ class text not null,
41
+ cv int not null default 0,
42
+ tenant uuid not null,
43
+ owner uuid not null,
44
+ links jsonb not null default '{}'::jsonb,
45
+ ends uuid[] generated always as (@SCHEMA@.link_ids(links)) stored,
46
+ data jsonb not null default '{}'::jsonb,
47
+ tags text[] not null default '{}',
48
+ at timestamptz not null,
49
+ created_at timestamptz not null,
50
+ author uuid not null,
51
+ agent text,
52
+ op text not null,
53
+ reason text,
54
+ moved uuid,
55
+ prev_hash bytea,
56
+ hash bytea generated always as (@SCHEMA@.version_hash(prev_hash,
57
+ @SCHEMA@.snapshot(id, rev, class, tenant, owner, links, data, tags, at, author, agent, op, reason, moved))) stored,
58
+ validator jsonpath
59
+ );
60
+ create index if not exists entity_class_tenant on @SCHEMA@.entity (class, tenant);
61
+ create index if not exists entity_ends on @SCHEMA@.entity using gin (ends) with (fastupdate = off);
62
+ create index if not exists entity_data on @SCHEMA@.entity using gin (data jsonb_path_ops);
63
+ -- описание класса по имени (class_row): без вычисления v5 на каждый поиск
64
+ create index if not exists entity_class_name on @SCHEMA@.entity ((data->>'name'), tenant) where class = 'Class';
65
+ -- строки с владельцем-пользователем: переход владения арендатору при выходе из членства (этап 5)
66
+ create index if not exists entity_owner on @SCHEMA@.entity (owner, tenant) where owner <> tenant;
67
+
68
+ create table if not exists @SCHEMA@.log (
69
+ seq bigint generated always as identity,
70
+ tx xid8 not null default pg_current_xact_id(),
71
+ id uuid not null,
72
+ rev int not null,
73
+ class text not null,
74
+ cv int not null default 0,
75
+ tenant uuid not null,
76
+ owner uuid not null,
77
+ links jsonb,
78
+ data jsonb,
79
+ tags text[],
80
+ at timestamptz not null,
81
+ author uuid not null,
82
+ agent text,
83
+ op text not null,
84
+ reason text,
85
+ moved uuid,
86
+ prev_hash bytea,
87
+ hash bytea generated always as (@SCHEMA@.version_hash(prev_hash,
88
+ @SCHEMA@.snapshot(id, rev, class, tenant, owner, links, data, tags, at, author, agent, op, reason, moved))) stored,
89
+ constraint log_id_rev unique (id, rev)
90
+ );
91
+ create index if not exists log_class_at on @SCHEMA@.log (class, at);
92
+ create index if not exists log_tx_seq on @SCHEMA@.log (tx, seq);
93
+ -- versions({ follow: true }): переход по связи старого и нового id при reclass и rekey (этап 6)
94
+ create index if not exists log_moved on @SCHEMA@.log (moved) where moved is not null;
95
+ -- срез asOf и withDeleted по переходу и deep (ревизия 5): версии, ссылавшиеся на id, — как entity_ends у
96
+ -- текущего состояния; без него поиск в журнале перебирал все версии класса (бенч этапа 7)
97
+ create index if not exists log_ends on @SCHEMA@.log using gin (@SCHEMA@.link_ids(links));
98
+ -- журнал часто прореживается (§2.11): чаще чистим
99
+ alter table @SCHEMA@.log set (autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.02);
100
+
101
+ create table if not exists @SCHEMA@.secret (
102
+ id uuid primary key default gen_random_uuid(),
103
+ account uuid not null,
104
+ kind text not null, -- PASSWORD, APIKEY, KEYSECRET, OTP, TOTP, IDENTITY, SESSION
105
+ ident text, -- email, телефон, префикс ключа, внешний id
106
+ material bytea, -- хэш пароля, ключа, кода или токена; секрет TOTP
107
+ meta jsonb not null default '{}'::jsonb,
108
+ expires_at timestamptz,
109
+ created_at timestamptz not null default clock_timestamp(),
110
+ deleted_at timestamptz
111
+ );
112
+ create unique index if not exists secret_kind_ident on @SCHEMA@.secret (kind, casefold(ident) collate pg_unicode_fast)
113
+ where deleted_at is null and ident is not null;
114
+ create index if not exists secret_material on @SCHEMA@.secret (material) where kind = 'SESSION' and deleted_at is null;
115
+
116
+ alter table @SCHEMA@.entity owner to @OWNER@;
117
+ alter table @SCHEMA@.log owner to @OWNER@;
118
+ alter table @SCHEMA@.secret owner to @OWNER@;
119
+ alter function @SCHEMA@.link_ids(jsonb) owner to @OWNER@;
120
+ alter function @SCHEMA@.snapshot(uuid, int, text, uuid, uuid, jsonb, jsonb, text[], timestamptz, uuid, text, text, text, uuid) owner to @OWNER@;
121
+ alter function @SCHEMA@.version_hash(bytea, bytea) owner to @OWNER@;
122
+
123
+ -- Права приложения (§2.7, этап 1, п. 1): только пользовательские колонки.
124
+ revoke all on @SCHEMA@.entity, @SCHEMA@.log, @SCHEMA@.secret from public, @APP@;
125
+ grant select, delete on @SCHEMA@.entity to @APP@;
126
+ grant insert (id, tenant, class, links, data, tags, owner) on @SCHEMA@.entity to @APP@;
127
+ grant update (class, links, data, tags) on @SCHEMA@.entity to @APP@;
128
+ grant select on @SCHEMA@.log to @APP@;
129
+ alter table @SCHEMA@.entity enable row level security;
130
+ alter table @SCHEMA@.log enable row level security;
131
+ alter table @SCHEMA@.secret enable row level security;
132
+ revoke all on all functions in schema @SCHEMA@ from public;
133
+ -- генерируемые колонки ends и hash вычисляются с правами пишущей роли: функции чистые, без побочных эффектов
134
+ grant execute on function @SCHEMA@.link_ids(jsonb),
135
+ @SCHEMA@.snapshot(uuid, int, text, uuid, uuid, jsonb, jsonb, text[], timestamptz, uuid, text, text, text, uuid),
136
+ @SCHEMA@.version_hash(bytea, bytea) to @APP@;
@@ -0,0 +1,60 @@
1
+ -- letopis 1.0 — каталог ошибок базы (план, §7.1, этап 1, п. 7).
2
+ -- У ошибок letopis собственный класс кодов SQLSTATE «LT»; текст — «letopis: <имя>: <сообщение>»,
3
+ -- детали — JSON в поле DETAIL. Текст «цель не найдена» одинаков для несуществующей и чужой цели.
4
+
5
+ create or replace function @SCHEMA@.error_catalog() returns table (code text, name text, message text)
6
+ language sql immutable parallel safe
7
+ as $$
8
+ values
9
+ ('LT001', 'no_session', 'letopis: no_session: нет действующей сессии'),
10
+ ('LT002', 'target_not_found', 'letopis: target_not_found: цель не найдена'),
11
+ ('LT003', 'invalid_data', 'letopis: invalid_data: данные не проходят описание класса'),
12
+ ('LT004', 'undeclared_end', 'letopis: undeclared_end: конец не объявлен в классе'),
13
+ ('LT005', 'tenant_denied', 'letopis: tenant_denied: чужой арендатор'),
14
+ ('LT006', 'acl_denied', 'letopis: acl_denied: отказ по правилам доступа'),
15
+ ('LT007', 'id_mismatch', 'letopis: id_mismatch: id не равен v5 от ключа'),
16
+ ('LT008', 'id_from_db', 'letopis: id_from_db: id класса без ключа выдаёт база'),
17
+ ('LT009', 'immutable_key', 'letopis: immutable_key: ключевое поле менять нельзя'),
18
+ ('LT010', 'invalid_class', 'letopis: invalid_class: описание класса отклонено'),
19
+ ('LT011', 'use_reclass', 'letopis: use_reclass: класс с ключом меняется функцией reclass()'),
20
+ ('LT012', 'reclass_denied', 'letopis: reclass_denied: смена класса отклонена'),
21
+ ('LT013', 'tightening_conflict', 'letopis: tightening_conflict: ужесточению описания мешают строки'),
22
+ ('LT014', 'isolation_level', 'letopis: isolation_level: операция разрешена только в read committed'),
23
+ ('LT015', 'recreated_in_statement', 'letopis: recreated_in_statement: id удалён и создан заново одним оператором'),
24
+ ('LT016', 'cursor_expired', 'letopis: cursor_expired: курсор подписки старше яруса all'),
25
+ ('LT017', 'delete_restricted', 'letopis: delete_restricted: удалению мешают ссылающиеся строки'),
26
+ ('LT018', 'not_deleted', 'letopis: not_deleted: объект не удалён — restore и purge только для удалённых'),
27
+ ('LT019', 'reset_disabled', 'letopis: reset_disabled: reset выключен в этой установке'),
28
+ ('LT020', 'confirm_mismatch', 'letopis: confirm_mismatch: подтверждение не совпадает с именем схемы')
29
+ $$;
30
+
31
+ create or replace function @SCHEMA@.lt_raise(p_code text, p_name text, p_msg text, p_detail jsonb default null)
32
+ returns void
33
+ language plpgsql
34
+ as $$
35
+ begin
36
+ raise exception using errcode = p_code, message = 'letopis: ' || p_name || ': ' || p_msg,
37
+ detail = coalesce(p_detail, '{}'::jsonb)::text;
38
+ end $$;
39
+
40
+ -- Ошибка по имени из каталога.
41
+ create or replace function @SCHEMA@.lt_fail(p_name text, p_msg text, p_detail jsonb default null)
42
+ returns void
43
+ language plpgsql
44
+ as $$
45
+ declare
46
+ c text;
47
+ begin
48
+ select e.code into c from @SCHEMA@.error_catalog() e where e.name = p_name;
49
+ perform @SCHEMA@.lt_raise(coalesce(c, 'LT000'), p_name, p_msg, p_detail);
50
+ end $$;
51
+
52
+ do $$
53
+ declare f text;
54
+ begin
55
+ foreach f in array array['error_catalog()', 'lt_raise(text, text, text, jsonb)', 'lt_fail(text, text, jsonb)'] loop
56
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
57
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
58
+ end loop;
59
+ end $$;
60
+ grant execute on function @SCHEMA@.error_catalog() to @APP@;
@@ -0,0 +1,153 @@
1
+ -- letopis 1.0 — сессии и контекст запроса (план, §2.9, этап 1, п. 3, этап 5, п. 2–4).
2
+ -- Каждая операция библиотеки — транзакция, первые операторы которой выставляют letopis.token,
3
+ -- при имперсонации letopis.account и при выборе арендатора letopis.tenant через set_config(…, true).
4
+ -- Без настоящего токена настройки ничего не дают: сессия ищется по sha256 токена, а
5
+ -- letopis.account действует только для сессии с правом auth.impersonate (правила System).
6
+
7
+ -- Контекст запроса: актор (аккаунт, от имени которого идёт работа), рабочий арендатор, автор и агент
8
+ -- версии, категории актора и его роли в арендаторе, сессия и сервис (при имперсонации).
9
+ -- Только читает. Нет токена или сессии — пустой контекст; запрещённая имперсонация и арендатор без
10
+ -- членства — ошибка: молча работать в другом арендаторе или от другого имени нельзя.
11
+ create or replace function @SCHEMA@.ctx(out account uuid, out tenant uuid, out author uuid, out agent text,
12
+ out categories text[], out roles text[], out session uuid, out service uuid)
13
+ language plpgsql stable security definer
14
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
15
+ as $$
16
+ #variable_conflict use_variable
17
+ declare
18
+ tok text := nullif(current_setting('letopis.token', true), '');
19
+ imp text := nullif(current_setting('letopis.account', true), '');
20
+ want text := nullif(current_setting('letopis.tenant', true), '');
21
+ s record;
22
+ a record;
23
+ t record;
24
+ m record;
25
+ scats text[];
26
+ sroles text[];
27
+ stenant uuid;
28
+ begin
29
+ if tok is null then
30
+ return;
31
+ end if;
32
+ select x.id, x.account, x.meta into s
33
+ from @SCHEMA@.secret x
34
+ where x.kind = 'SESSION' and x.deleted_at is null
35
+ and x.material = sha256(convert_to(tok, 'UTF8'))
36
+ and (x.expires_at is null or x.expires_at > clock_timestamp());
37
+ if s.id is null then
38
+ return;
39
+ end if;
40
+ select e.id, e.data into a from @SCHEMA@.entity e
41
+ where e.id = s.account and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid and coalesce((e.data->>'enabled')::boolean, true);
42
+ if a.id is null then
43
+ return;
44
+ end if;
45
+ session := s.id;
46
+ account := s.account;
47
+ tenant := coalesce((s.meta->>'tenant')::uuid, s.account);
48
+ if imp is not null and imp is distinct from s.account::text then
49
+ -- имперсонация (этап 5, п. 3): право сервиса — по правилам System, в его собственном арендаторе
50
+ if not pg_input_is_valid(imp, 'uuid') then
51
+ perform @SCHEMA@.lt_fail('acl_denied', 'letopis.account — не id аккаунта');
52
+ end if;
53
+ scats := array(select jsonb_array_elements_text(coalesce(a.data->'categories', '[]'::jsonb)));
54
+ stenant := tenant;
55
+ sroles := case when stenant = s.account then array['owner'] else array(
56
+ select jsonb_array_elements_text(coalesce(x.data->'roles', '[]'::jsonb)) from @SCHEMA@.entity x
57
+ where x.id = @SCHEMA@.v5_id(stenant, 'member', array[s.account::text, stenant::text]) and x.class = 'member') end;
58
+ if not coalesce((@SCHEMA@.acl_api_for(stenant, scats, sroles, 'auth.impersonate', true)->>'allow')::boolean, false) then
59
+ perform @SCHEMA@.lt_fail('acl_denied', 'нет права auth.impersonate');
60
+ end if;
61
+ select e.id, e.data into a from @SCHEMA@.entity e where e.id = imp::uuid and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid;
62
+ -- от имени System, сервисов, системных администраторов и отключённых аккаунтов действовать нельзя
63
+ if a.id is null or not coalesce((a.data->>'enabled')::boolean, true) or a.id = '@SYSTEM@'::uuid
64
+ or coalesce(a.data->'categories' ?| array['System', 'Service'], false)
65
+ or exists (select 1 from @SCHEMA@.entity x
66
+ where x.id = @SCHEMA@.v5_id('@SYSTEM@'::uuid, 'member', array[a.id::text, '@SYSTEM@']) and x.class = 'member') then
67
+ perform @SCHEMA@.lt_fail('acl_denied', 'действовать от имени этого аккаунта нельзя');
68
+ end if;
69
+ service := s.account;
70
+ agent := s.account::text;
71
+ account := a.id;
72
+ tenant := a.id;
73
+ end if;
74
+ if want is not null then
75
+ if not pg_input_is_valid(want, 'uuid') then
76
+ perform @SCHEMA@.lt_fail('tenant_denied', 'letopis.tenant — не id арендатора');
77
+ end if;
78
+ tenant := want::uuid;
79
+ end if;
80
+ if tenant = account then
81
+ roles := array['owner'];
82
+ else
83
+ -- работа в чужом арендаторе — по членству; членство проверяется при каждом вызове
84
+ select x.data into m from @SCHEMA@.entity x
85
+ where x.id = @SCHEMA@.v5_id(tenant, 'member', array[account::text, tenant::text]) and x.class = 'member' and x.tenant = tenant;
86
+ select e.id into t from @SCHEMA@.entity e
87
+ where e.id = tenant and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid and coalesce((e.data->>'enabled')::boolean, true);
88
+ if m is null or t.id is null then
89
+ perform @SCHEMA@.lt_fail('tenant_denied', 'нет членства в арендаторе');
90
+ end if;
91
+ roles := array(select jsonb_array_elements_text(coalesce(m.data->'roles', '[]'::jsonb)));
92
+ end if;
93
+ categories := array(select jsonb_array_elements_text(coalesce(a.data->'categories', '[]'::jsonb)));
94
+ author := account;
95
+ end $$;
96
+
97
+ create or replace function @SCHEMA@.ctx_tenant() returns uuid
98
+ language sql stable
99
+ return (select c.tenant from @SCHEMA@.ctx() c);
100
+
101
+ create or replace function @SCHEMA@.ctx_account() returns uuid
102
+ language sql stable
103
+ return (select c.account from @SCHEMA@.ctx() c);
104
+
105
+ -- Новая сессия: 256 случайных бит, в базе — только sha256 токена. В meta — время и способы входа
106
+ -- (без них не проверить «недавний вход»), рабочий арендатор после auth.switch, выпустивший сервис.
107
+ create or replace function @SCHEMA@.new_session(p_account uuid, p_ttl interval, p_meta jsonb) returns text
108
+ language plpgsql volatile security definer
109
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
110
+ as $$
111
+ declare
112
+ tok text := encode(@PGCRYPTO@.gen_random_bytes(32), 'hex');
113
+ begin
114
+ insert into @SCHEMA@.secret (account, kind, material, meta, expires_at)
115
+ values (p_account, 'SESSION', sha256(convert_to(tok, 'UTF8')),
116
+ coalesce(p_meta, '{}'::jsonb) || jsonb_build_object('login_at', clock_timestamp(), 'ttl', coalesce(p_ttl, interval '7 days')::text),
117
+ clock_timestamp() + coalesce(p_ttl, interval '7 days'));
118
+ return tok;
119
+ end $$;
120
+
121
+ -- Вход по API-ключу (APIKEY): префикс ищется в ident, ключ сверяется по sha256. Сессия помнит ключ
122
+ -- (meta.cred): отзыв ключа гасит только его сессии.
123
+ create or replace function @SCHEMA@.login_apikey(p_key text) returns text
124
+ language plpgsql volatile security definer
125
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
126
+ as $$
127
+ declare
128
+ k record;
129
+ begin
130
+ select s.id, s.account into k
131
+ from @SCHEMA@.secret s
132
+ join @SCHEMA@.entity a on a.id = s.account and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid
133
+ where s.kind = 'APIKEY' and s.deleted_at is null
134
+ and casefold(s.ident) collate pg_unicode_fast = casefold(left(p_key, 12)) collate pg_unicode_fast
135
+ and s.material = sha256(convert_to(p_key, 'UTF8'))
136
+ and (s.expires_at is null or s.expires_at > clock_timestamp())
137
+ and coalesce((s.meta->>'enabled')::boolean, true)
138
+ and coalesce((a.data->>'enabled')::boolean, true);
139
+ if k.account is null then
140
+ raise exception using errcode = 'LT001', message = 'letopis: no_session: неверный ключ';
141
+ end if;
142
+ return @SCHEMA@.new_session(k.account, interval '7 days', jsonb_build_object('methods', jsonb_build_array('APIKEY'), 'cred', k.id));
143
+ end $$;
144
+
145
+ alter function @SCHEMA@.ctx() owner to @OWNER@;
146
+ alter function @SCHEMA@.ctx_tenant() owner to @OWNER@;
147
+ alter function @SCHEMA@.ctx_account() owner to @OWNER@;
148
+ alter function @SCHEMA@.new_session(uuid, interval, jsonb) owner to @OWNER@;
149
+ alter function @SCHEMA@.login_apikey(text) owner to @OWNER@;
150
+ revoke all on function @SCHEMA@.ctx(), @SCHEMA@.ctx_tenant(), @SCHEMA@.ctx_account(),
151
+ @SCHEMA@.new_session(uuid, interval, jsonb), @SCHEMA@.login_apikey(text) from public;
152
+ grant execute on function @SCHEMA@.ctx(), @SCHEMA@.ctx_tenant(), @SCHEMA@.ctx_account(),
153
+ @SCHEMA@.login_apikey(text) to @APP@;