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.
- package/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +313 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3469
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +362 -563
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -252
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -352
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -685
- 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.
|
|
4
|
-
"description": "Letopis (летопись):
|
|
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
|
-
"
|
|
9
|
-
"
|
|
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": ">=
|
|
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
|
-
"
|
|
59
|
-
|
|
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
|
-
"@
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
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
|
}
|
package/sql/10-core.sql
ADDED
|
@@ -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@;
|