@acornly/adapter-supabase 0.1.0 → 0.2.1
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/README.md +25 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/supabase/install.sql +2295 -0
- package/supabase/migrations/20261011080000_acornly_delete_node_grants.sql +10 -0
- package/supabase/migrations/20261011150000_acornly_parent_ids.sql +53 -0
- package/supabase/migrations/20261011170000_acornly_pdf_document_keys.sql +185 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Only signed-in callers (and the service role) may run `acornly_delete_node`,
|
|
2
|
+
-- as the threads migration meant.
|
|
3
|
+
--
|
|
4
|
+
-- That migration revoked it from `public` and granted it to `authenticated`
|
|
5
|
+
-- and `service_role`, but Supabase's default privileges give `anon` its own
|
|
6
|
+
-- grant on every function created in `public`, and revoking from `public`
|
|
7
|
+
-- leaves that grant in place. A visitor could not delete anything with it,
|
|
8
|
+
-- since only a note's creator may and every note has one, but the function
|
|
9
|
+
-- should refuse them before it looks a note up.
|
|
10
|
+
revoke execute on function public.acornly_delete_node(text, integer, text) from anon;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- A reply's target may name its parent under `urn:acornly:annotation:` as well as
|
|
2
|
+
-- `urn:contexthive:annotation:` (docs/design/acornly-rename.md, phase 3). Without this, a reply
|
|
3
|
+
-- written by a version of core that uses the new prefix is refused (CH001: thread fields on a note
|
|
4
|
+
-- that targets a page).
|
|
5
|
+
--
|
|
6
|
+
-- create or replace keeps the function's owner and grants.
|
|
7
|
+
create or replace function acornly_private.parent_id_of(source text)
|
|
8
|
+
returns text
|
|
9
|
+
language plpgsql
|
|
10
|
+
stable
|
|
11
|
+
set search_path = ''
|
|
12
|
+
as $$
|
|
13
|
+
declare
|
|
14
|
+
prefix text;
|
|
15
|
+
encoded text;
|
|
16
|
+
decoded text;
|
|
17
|
+
begin
|
|
18
|
+
prefix := case
|
|
19
|
+
when lower(left(source, 23)) = 'urn:acornly:annotation:' then 'urn:acornly:annotation:'
|
|
20
|
+
when lower(left(source, 27)) = 'urn:contexthive:annotation:' then 'urn:contexthive:annotation:'
|
|
21
|
+
end;
|
|
22
|
+
if prefix is null then
|
|
23
|
+
return null;
|
|
24
|
+
end if;
|
|
25
|
+
encoded := substr(source, length(prefix) + 1);
|
|
26
|
+
if encoded !~ '^([^%]|%[0-9A-Fa-f]{2})*$' then
|
|
27
|
+
raise exception 'target % is badly escaped', source using errcode = 'CH001';
|
|
28
|
+
end if;
|
|
29
|
+
begin
|
|
30
|
+
select convert_from(
|
|
31
|
+
coalesce(
|
|
32
|
+
string_agg(
|
|
33
|
+
case when m[1] is not null
|
|
34
|
+
then decode(substr(m[1], 2), 'hex')
|
|
35
|
+
else convert_to(m[2], 'UTF8')
|
|
36
|
+
end,
|
|
37
|
+
''::bytea order by n
|
|
38
|
+
),
|
|
39
|
+
''::bytea
|
|
40
|
+
),
|
|
41
|
+
'UTF8'
|
|
42
|
+
)
|
|
43
|
+
into decoded
|
|
44
|
+
from regexp_matches(encoded, '(%[0-9A-Fa-f]{2})|([^%]+)', 'g') with ordinality as t (m, n);
|
|
45
|
+
exception when character_not_in_repertoire or untranslatable_character then
|
|
46
|
+
raise exception 'target % is badly escaped', source using errcode = 'CH001';
|
|
47
|
+
end;
|
|
48
|
+
if decoded = '' then
|
|
49
|
+
raise exception 'target % names an empty id', source using errcode = 'CH001';
|
|
50
|
+
end if;
|
|
51
|
+
return decoded;
|
|
52
|
+
end
|
|
53
|
+
$$;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
-- Notes on PDFs are listed by the file's key (#403).
|
|
2
|
+
--
|
|
3
|
+
-- A note on a PDF carries `ext.documentKey`, `urn:x-pdf:<fingerprint>` (see
|
|
4
|
+
-- `pdfDocumentKey` in @acornly/core), and `target.source` keeps the URL it was
|
|
5
|
+
-- made on, so the same file opened from another URL shows its notes. The
|
|
6
|
+
-- `document` column now holds that key, as `documentKeyOf` reads it: the key
|
|
7
|
+
-- when it is a valid PDF key, else `target.source`. Notes stored before keep
|
|
8
|
+
-- their source as their document.
|
|
9
|
+
--
|
|
10
|
+
-- The key is set when a note is made and never changes: an update that leaves
|
|
11
|
+
-- it out, or names another, gets the stored one back, and a note's
|
|
12
|
+
-- `target.source` stays as before. A reply's document is still its target
|
|
13
|
+
-- (whatever key it carries), and its parent is still read from it. Notes stored
|
|
14
|
+
-- before with a key already in their annotation move to it below.
|
|
15
|
+
|
|
16
|
+
-- `documentKeyOf` for a stored annotation. The trigger runs as its caller, so
|
|
17
|
+
-- every role that writes notes may call it, as the other helpers it uses.
|
|
18
|
+
create or replace function acornly_private.document_of(a jsonb)
|
|
19
|
+
returns text
|
|
20
|
+
language sql
|
|
21
|
+
immutable
|
|
22
|
+
set search_path = ''
|
|
23
|
+
as $$
|
|
24
|
+
select case
|
|
25
|
+
when jsonb_typeof(a #> '{ext,documentKey}') = 'string'
|
|
26
|
+
and (a #>> '{ext,documentKey}') ~ '^urn:x-pdf:[0-9a-f]{2,128}$'
|
|
27
|
+
and (a #>> '{target,source}') !~* '^urn:(acornly|contexthive):annotation:'
|
|
28
|
+
then a #>> '{ext,documentKey}'
|
|
29
|
+
else a #>> '{target,source}'
|
|
30
|
+
end
|
|
31
|
+
$$;
|
|
32
|
+
|
|
33
|
+
revoke all on function acornly_private.document_of(jsonb) from public;
|
|
34
|
+
grant execute on function acornly_private.document_of(jsonb)
|
|
35
|
+
to anon, authenticated, service_role;
|
|
36
|
+
|
|
37
|
+
-- The write trigger on notes, from 20261010070000_acornly_names.sql with the
|
|
38
|
+
-- document key.
|
|
39
|
+
create or replace function acornly_private.prepare_annotation()
|
|
40
|
+
returns trigger
|
|
41
|
+
language plpgsql
|
|
42
|
+
set search_path = ''
|
|
43
|
+
as $$
|
|
44
|
+
declare
|
|
45
|
+
a jsonb := new.annotation;
|
|
46
|
+
audience_kind text := a #>> '{ext,audience,kind}';
|
|
47
|
+
-- The site's own server code writing on a user's behalf.
|
|
48
|
+
trusted boolean := acornly_private.trusted_caller();
|
|
49
|
+
begin
|
|
50
|
+
if jsonb_typeof(a -> 'id') is distinct from 'string'
|
|
51
|
+
or jsonb_typeof(a #> '{ext,scope}') is distinct from 'string'
|
|
52
|
+
or jsonb_typeof(a #> '{target,source}') is distinct from 'string'
|
|
53
|
+
or jsonb_typeof(a -> 'modified') is distinct from 'string'
|
|
54
|
+
-- ISO 8601 only, so no `infinity`, `now` or other special values.
|
|
55
|
+
or (a ->> 'modified') !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$'
|
|
56
|
+
or coalesce(jsonb_typeof(a #> '{ext,threadRoot}'), 'null') not in ('string', 'null')
|
|
57
|
+
then
|
|
58
|
+
raise exception 'id, ext.scope and target.source must be strings, modified an ISO 8601 time, and ext.threadRoot a string or null'
|
|
59
|
+
using errcode = '23514';
|
|
60
|
+
end if;
|
|
61
|
+
if coalesce(jsonb_typeof(a #> '{ext,depth}'), 'null') not in ('number', 'null')
|
|
62
|
+
or coalesce(jsonb_typeof(a #> '{ext,parentRev}'), 'null') not in ('number', 'null')
|
|
63
|
+
or coalesce(jsonb_typeof(a #> '{ext,deleted}'), 'null') not in ('boolean', 'null')
|
|
64
|
+
then
|
|
65
|
+
raise exception 'ext.depth and ext.parentRev must be numbers and ext.deleted a boolean'
|
|
66
|
+
using errcode = '23514';
|
|
67
|
+
end if;
|
|
68
|
+
|
|
69
|
+
if tg_op = 'INSERT' then
|
|
70
|
+
if not trusted then
|
|
71
|
+
-- Clients cannot write this column; the author is always the caller.
|
|
72
|
+
new.creator_id := auth.uid();
|
|
73
|
+
if new.creator_id is null then
|
|
74
|
+
raise exception 'sign in to write notes' using errcode = '42501';
|
|
75
|
+
end if;
|
|
76
|
+
elsif new.creator_id is null then
|
|
77
|
+
raise exception 'creator_id is required' using errcode = '23502';
|
|
78
|
+
end if;
|
|
79
|
+
new.rev := 1;
|
|
80
|
+
else
|
|
81
|
+
new.creator_id := old.creator_id;
|
|
82
|
+
new.rev := old.rev + 1;
|
|
83
|
+
-- A note's document key never changes: the stored one, or none, stays.
|
|
84
|
+
a := case
|
|
85
|
+
when old.annotation #> '{ext,documentKey}' is null then a #- '{ext,documentKey}'
|
|
86
|
+
else jsonb_set(a, '{ext,documentKey}', old.annotation #> '{ext,documentKey}')
|
|
87
|
+
end;
|
|
88
|
+
end if;
|
|
89
|
+
|
|
90
|
+
if audience_kind is null or audience_kind not in ('private', 'public') then
|
|
91
|
+
raise exception 'audience "%" is not supported here; use private or public', audience_kind
|
|
92
|
+
using errcode = '23514';
|
|
93
|
+
end if;
|
|
94
|
+
|
|
95
|
+
new.id := a ->> 'id';
|
|
96
|
+
new.scope := a #>> '{ext,scope}';
|
|
97
|
+
new.document := acornly_private.document_of(a);
|
|
98
|
+
new.audience := audience_kind;
|
|
99
|
+
new.thread_root_id := a #>> '{ext,threadRoot}';
|
|
100
|
+
-- The document never changes, so neither does the parent; notes stored
|
|
101
|
+
-- before this migration keep theirs (none) when edited.
|
|
102
|
+
new.parent_id := case
|
|
103
|
+
when tg_op = 'UPDATE' then old.parent_id
|
|
104
|
+
else acornly_private.parent_id_of(a #>> '{target,source}')
|
|
105
|
+
end;
|
|
106
|
+
new.modified := (a ->> 'modified')::timestamptz;
|
|
107
|
+
new.annotation := jsonb_set(a, '{ext,rev}', to_jsonb(new.rev));
|
|
108
|
+
|
|
109
|
+
if tg_op = 'INSERT' then
|
|
110
|
+
if acornly_private.is_deleted(a) then
|
|
111
|
+
raise exception 'a new annotation cannot be deleted' using errcode = 'CH001';
|
|
112
|
+
end if;
|
|
113
|
+
if new.parent_id is null then
|
|
114
|
+
if new.thread_root_id is not null
|
|
115
|
+
or a #> '{ext,depth}' is not null
|
|
116
|
+
or a #> '{ext,parentRev}' is not null
|
|
117
|
+
then
|
|
118
|
+
raise exception 'annotation % has thread fields but targets a page', new.id
|
|
119
|
+
using errcode = 'CH001';
|
|
120
|
+
end if;
|
|
121
|
+
else
|
|
122
|
+
perform acornly_private.check_child(
|
|
123
|
+
new.parent_id, a, new.scope, new.thread_root_id, new.audience
|
|
124
|
+
);
|
|
125
|
+
end if;
|
|
126
|
+
return new;
|
|
127
|
+
end if;
|
|
128
|
+
|
|
129
|
+
if new.id is distinct from old.id then
|
|
130
|
+
raise exception 'id cannot change' using errcode = '23514';
|
|
131
|
+
end if;
|
|
132
|
+
-- `acornly_delete_node` writing a placeholder.
|
|
133
|
+
if acornly_private.in_delete_node() then
|
|
134
|
+
return new;
|
|
135
|
+
end if;
|
|
136
|
+
if acornly_private.is_deleted(a) is distinct from acornly_private.is_deleted(old.annotation) then
|
|
137
|
+
raise exception 'annotation % is deleted or restored only by deleteNode', new.id
|
|
138
|
+
using errcode = 'CH001';
|
|
139
|
+
end if;
|
|
140
|
+
if acornly_private.is_deleted(old.annotation) then
|
|
141
|
+
raise exception 'annotation % is deleted', new.id using errcode = 'CH001';
|
|
142
|
+
end if;
|
|
143
|
+
if new.scope is distinct from old.scope
|
|
144
|
+
or new.document is distinct from old.document
|
|
145
|
+
or new.thread_root_id is distinct from old.thread_root_id
|
|
146
|
+
or a #> '{ext,depth}' is distinct from old.annotation #> '{ext,depth}'
|
|
147
|
+
or a #> '{ext,parentRev}' is distinct from old.annotation #> '{ext,parentRev}'
|
|
148
|
+
or (old.parent_id is not null and a -> 'target' is distinct from old.annotation -> 'target')
|
|
149
|
+
-- The document no longer says whether the source moved: a note on a PDF keeps its URL too.
|
|
150
|
+
or a #> '{target,source}' is distinct from old.annotation #> '{target,source}'
|
|
151
|
+
then
|
|
152
|
+
raise exception 'annotation % cannot move: id, scope, target.source and threadRoot cannot change',
|
|
153
|
+
new.id using errcode = 'CH001';
|
|
154
|
+
end if;
|
|
155
|
+
if a #> '{creator,id}' is distinct from old.annotation #> '{creator,id}'
|
|
156
|
+
and acornly_private.has_children(new.id)
|
|
157
|
+
then
|
|
158
|
+
raise exception 'annotation % has replies; its author stays', new.id using errcode = 'CH001';
|
|
159
|
+
end if;
|
|
160
|
+
-- An unchanged or narrower audience is fine even if the parent was narrowed
|
|
161
|
+
-- meanwhile. A wider one is checked against the parent, locked so a
|
|
162
|
+
-- concurrent narrowing cannot slip past; a reply from before threads nested
|
|
163
|
+
-- is checked against its root.
|
|
164
|
+
if new.thread_root_id is not null
|
|
165
|
+
and not acornly_private.audience_within(new.audience, old.audience)
|
|
166
|
+
and not acornly_private.audience_within(
|
|
167
|
+
new.audience,
|
|
168
|
+
acornly_private.locked_audience(coalesce(old.parent_id, old.thread_root_id))
|
|
169
|
+
)
|
|
170
|
+
then
|
|
171
|
+
raise exception 'annotation % cannot widen its parent''s audience', new.id
|
|
172
|
+
using errcode = 'CH002';
|
|
173
|
+
end if;
|
|
174
|
+
return new;
|
|
175
|
+
end
|
|
176
|
+
$$;
|
|
177
|
+
|
|
178
|
+
-- Notes stored before this migration with a PDF key in their annotation (written
|
|
179
|
+
-- after core gave notes keys) move to it. The write trigger would refuse the
|
|
180
|
+
-- move, so it is off for this one statement.
|
|
181
|
+
alter table public.acornly_annotations disable trigger acornly_annotations_prepare;
|
|
182
|
+
update public.acornly_annotations
|
|
183
|
+
set document = acornly_private.document_of(annotation)
|
|
184
|
+
where document is distinct from acornly_private.document_of(annotation);
|
|
185
|
+
alter table public.acornly_annotations enable trigger acornly_annotations_prepare;
|