@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.
@@ -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;