@rocapine/community-core 0.1.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/lib/service.js ADDED
@@ -0,0 +1,454 @@
1
+ "use strict";
2
+ // Community backend service — every function is a thin wrapper around the injected
3
+ // Supabase client (`cfg.requireClient()`), so a host with `supabase: null` degrades to
4
+ // CommunityDisabledError instead of crashing when no backend is configured. No React
5
+ // imports here.
6
+ //
7
+ // Ported from the mold (`sdk/client/services/community.ts`) with a host app's newer
8
+ // feed/thread composition (search, poll batching via `poll_vote_counts`, reaction batching
9
+ // generalized from that host's prayer-style feature's summary RPC into `post_reaction_summary`).
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.buildFeedSelect = buildFeedSelect;
12
+ exports.fetchReactionSummaries = fetchReactionSummaries;
13
+ exports.fetchFeedPage = fetchFeedPage;
14
+ exports.countNewPosts = countNewPosts;
15
+ exports.fetchProfile = fetchProfile;
16
+ exports.fetchUserPosts = fetchUserPosts;
17
+ exports.searchPosts = searchPosts;
18
+ exports.fetchThread = fetchThread;
19
+ exports.createPost = createPost;
20
+ exports.votePoll = votePoll;
21
+ exports.createComment = createComment;
22
+ exports.moderateOne = moderateOne;
23
+ exports.setLike = setLike;
24
+ exports.setReaction = setReaction;
25
+ exports.reportContent = reportContent;
26
+ exports.blockUser = blockUser;
27
+ exports.deleteOwnPost = deleteOwnPost;
28
+ exports.deleteOwnComment = deleteOwnComment;
29
+ exports.updateProfile = updateProfile;
30
+ exports.uploadAvatar = uploadAvatar;
31
+ const supabase_js_1 = require("@supabase/supabase-js");
32
+ const identity_1 = require("./identity");
33
+ const models_1 = require("./models");
34
+ /** Avatar images live in this Storage bucket on every install (mold value, unchanged). */
35
+ const AVATAR_BUCKET = "avatars";
36
+ async function requireUid(cfg) {
37
+ const uid = await (0, identity_1.ensureIdentity)(cfg);
38
+ if (!uid)
39
+ throw new Error("You appear to be offline. Please try again.");
40
+ return uid;
41
+ }
42
+ // Embedded filter: comment counts only count visible comments, so the badge
43
+ // on a card always matches what the opened thread renders.
44
+ const FEED_SELECT = "id, author_id, topic, content, status, pinned_at, created_at, profiles!posts_author_id_fkey(username, is_official, handle, avatar_url), likes(count), comments(count), poll_options(id, idx, label)";
45
+ const PROFILE_SELECT = "id, username, handle, is_official, bio, avatar_url";
46
+ /** Minimal injection guard for `CommunityConfig.feed.extraPostColumns`: a bare
47
+ * column name only, no `.`, `(`, `:`, whitespace, etc. — those are how a
48
+ * PostgREST select embeds a relation or renames a column, and letting them
49
+ * through would extend the query in ways this extension point isn't meant to
50
+ * allow. */
51
+ const VALID_EXTRA_COLUMN = /^[a-z0-9_]+$/;
52
+ /**
53
+ * Appends `extraPostColumns` (if any) to the posts feed select. Pure and
54
+ * exported so it's unit-testable without a Supabase client. Invalid column
55
+ * names are dropped with a `console.warn` rather than failing the query.
56
+ */
57
+ function buildFeedSelect(extraPostColumns) {
58
+ if (!extraPostColumns || extraPostColumns.length === 0)
59
+ return FEED_SELECT;
60
+ const valid = extraPostColumns.filter((column) => {
61
+ if (VALID_EXTRA_COLUMN.test(column))
62
+ return true;
63
+ console.warn(`[@rocapine/community-core] ignoring invalid feed.extraPostColumns entry: ${JSON.stringify(column)}`);
64
+ return false;
65
+ });
66
+ if (valid.length === 0)
67
+ return FEED_SELECT;
68
+ return `${FEED_SELECT}, ${valid.join(", ")}`;
69
+ }
70
+ async function fetchMyLikes(client, postIds, uid) {
71
+ if (postIds.length === 0)
72
+ return new Set();
73
+ const { data, error } = await client
74
+ .from("likes")
75
+ .select("post_id")
76
+ .eq("user_id", uid)
77
+ .in("post_id", postIds);
78
+ if (error)
79
+ throw error;
80
+ return new Set((data ?? []).map((r) => r.post_id));
81
+ }
82
+ /**
83
+ * Batched poll state for the poll posts of one page: per-option counts via the
84
+ * security-definer RPC (poll_votes rows are private), my votes via RLS select.
85
+ * Gated on `cfg.modules.polls`: off (or no poll posts on the page) skips the
86
+ * RPC/select round trip entirely and returns the empty shape.
87
+ */
88
+ async function fetchPollData(cfg, postIds, uid) {
89
+ if (!cfg.modules.polls || postIds.length === 0)
90
+ return models_1.EMPTY_POLL_DATA;
91
+ const client = cfg.requireClient();
92
+ const [countsRes, myVotesRes] = await Promise.all([
93
+ client.rpc("poll_vote_counts", { p_post_ids: postIds }),
94
+ client
95
+ .from("poll_votes")
96
+ .select("post_id, option_id")
97
+ .eq("user_id", uid)
98
+ .in("post_id", postIds),
99
+ ]);
100
+ if (countsRes.error)
101
+ throw countsRes.error;
102
+ if (myVotesRes.error)
103
+ throw myVotesRes.error;
104
+ const counts = new Map();
105
+ for (const row of (countsRes.data ?? [])) {
106
+ counts.set(row.option_id, Number(row.votes));
107
+ }
108
+ const myVotes = new Map();
109
+ for (const row of (myVotesRes.data ?? [])) {
110
+ myVotes.set(row.post_id, row.option_id);
111
+ }
112
+ return { counts, myVotes };
113
+ }
114
+ /**
115
+ * Batched reaction state for one page: count + last reactor's display name + my-reaction
116
+ * flag, all from the single security-definer `post_reaction_summary` RPC (the backend
117
+ * already resolves "did I react" server-side, so unlike the mold's prayer batching this
118
+ * needs no second query). Gated on `cfg.modules.reaction`; a missing/erroring RPC (e.g.
119
+ * the reaction module's migration not installed yet) degrades to no reaction data instead
120
+ * of taking the whole feed down.
121
+ */
122
+ async function fetchReactionSummaries(cfg, postIds) {
123
+ if (!cfg.modules.reaction || postIds.length === 0)
124
+ return models_1.EMPTY_REACTION_DATA;
125
+ const client = cfg.requireClient();
126
+ const { data, error } = await client.rpc("post_reaction_summary", { p_post_ids: postIds });
127
+ if (error)
128
+ return models_1.EMPTY_REACTION_DATA;
129
+ const counts = new Map();
130
+ const mine = new Set();
131
+ const lastReactorName = new Map();
132
+ for (const row of (data ?? [])) {
133
+ counts.set(row.post_id, Number(row.reaction_count));
134
+ lastReactorName.set(row.post_id, row.last_reactor_name);
135
+ if (row.has_reacted)
136
+ mine.add(row.post_id);
137
+ }
138
+ return { counts, mine, lastReactorName };
139
+ }
140
+ /** Attach my-like state + poll state + reaction state to a batch of raw post
141
+ * rows and map to the UI model. */
142
+ async function toFeedPosts(cfg, client, rows, uid) {
143
+ const postIds = rows.map((r) => r.id);
144
+ const [liked, pollData, reactionData] = await Promise.all([
145
+ fetchMyLikes(client, postIds, uid),
146
+ fetchPollData(cfg, rows.filter((r) => r.poll_options.length > 0).map((r) => r.id), uid),
147
+ fetchReactionSummaries(cfg, postIds),
148
+ ]);
149
+ return rows.map((r) => (0, models_1.mapPostRow)(r, uid, cfg.anonymousAuthorFallback, cfg.topics, liked, pollData, reactionData, cfg.feed.transformPost));
150
+ }
151
+ /**
152
+ * One feed page, newest first. RLS already excludes other users' hidden and
153
+ * blocked-author content; we only exclude our own soft-deleted posts.
154
+ */
155
+ async function fetchFeedPage(cfg, opts = {}) {
156
+ const client = cfg.requireClient();
157
+ const uid = await requireUid(cfg);
158
+ const page = opts.cursor ?? 0;
159
+ let query = client
160
+ .from("posts")
161
+ .select(buildFeedSelect(cfg.feed.extraPostColumns))
162
+ .eq("comments.status", "visible")
163
+ // Others' rows are already limited to 'visible' by RLS; this also shows the
164
+ // author their own 'pending' posts (optimistic) while hiding their moderated-out
165
+ // ones.
166
+ .in("status", ["visible", "pending"])
167
+ .order("pinned_at", { ascending: false, nullsFirst: false })
168
+ .order("created_at", { ascending: false })
169
+ .range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
170
+ if (opts.topic)
171
+ query = query.eq("topic", opts.topic);
172
+ const { data, error } = await query;
173
+ if (error)
174
+ throw error;
175
+ return toFeedPosts(cfg, client, (data ?? []), uid);
176
+ }
177
+ /**
178
+ * Count of visible posts newer than `sinceIso`, excluding my own, for the
179
+ * given topic. RLS already excludes blocked authors and hidden/deleted rows.
180
+ * `topic` is beyond the brief's literal signature but kept (both source apps
181
+ * take it) so a topic-filtered feed's "N new posts" pill stays accurate.
182
+ */
183
+ async function countNewPosts(cfg, sinceIso, topic) {
184
+ const client = cfg.requireClient();
185
+ const uid = await requireUid(cfg);
186
+ let query = client
187
+ .from("posts")
188
+ .select("id", { count: "exact", head: true })
189
+ .eq("status", "visible")
190
+ .gt("created_at", sinceIso)
191
+ .neq("author_id", uid);
192
+ if (topic)
193
+ query = query.eq("topic", topic);
194
+ const { count, error } = await query;
195
+ if (error)
196
+ throw error;
197
+ return count ?? 0;
198
+ }
199
+ /** A single profile by id, e.g. for the profile page header. */
200
+ async function fetchProfile(cfg, userId) {
201
+ const client = cfg.requireClient();
202
+ const { data, error } = await client
203
+ .from("profiles")
204
+ .select(PROFILE_SELECT)
205
+ .eq("id", userId)
206
+ .single();
207
+ if (error)
208
+ throw error;
209
+ return (0, models_1.mapProfileRow)(data, cfg.anonymousAuthorFallback);
210
+ }
211
+ /**
212
+ * One user's posts, newest first (no pinned ordering — a profile page is
213
+ * strictly chronological). Same visibility rules as the main feed, same
214
+ * page-index cursor as `fetchFeedPage` (product decision, reversing the
215
+ * earlier single-page scope cut).
216
+ */
217
+ async function fetchUserPosts(cfg, userId, opts = {}) {
218
+ const client = cfg.requireClient();
219
+ const uid = await requireUid(cfg);
220
+ const page = opts.cursor ?? 0;
221
+ const { data, error } = await client
222
+ .from("posts")
223
+ .select(buildFeedSelect(cfg.feed.extraPostColumns))
224
+ .eq("author_id", userId)
225
+ .eq("comments.status", "visible")
226
+ .in("status", ["visible", "pending"])
227
+ .order("created_at", { ascending: false })
228
+ .range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
229
+ if (error)
230
+ throw error;
231
+ return toFeedPosts(cfg, client, (data ?? []), uid);
232
+ }
233
+ /**
234
+ * Full-text-ish search over visible post content (newest first), same
235
+ * page-index cursor as `fetchFeedPage` (product decision, reversing the
236
+ * earlier single-page scope cut).
237
+ */
238
+ async function searchPosts(cfg, query, opts = {}) {
239
+ const cleaned = query.trim();
240
+ if (cleaned.length === 0)
241
+ return [];
242
+ const client = cfg.requireClient();
243
+ const uid = await requireUid(cfg);
244
+ const page = opts.cursor ?? 0;
245
+ const { data, error } = await client
246
+ .from("posts")
247
+ .select(buildFeedSelect(cfg.feed.extraPostColumns))
248
+ .eq("comments.status", "visible")
249
+ .eq("status", "visible")
250
+ .ilike("content", `%${cleaned}%`)
251
+ .order("created_at", { ascending: false })
252
+ .range(page * models_1.FEED_PAGE_SIZE, page * models_1.FEED_PAGE_SIZE + models_1.FEED_PAGE_SIZE - 1);
253
+ if (error)
254
+ throw error;
255
+ return toFeedPosts(cfg, client, (data ?? []), uid);
256
+ }
257
+ /**
258
+ * Note: `cfg.feed.extraPostColumns`/`transformPost` do NOT apply here — this
259
+ * queries `comments`, not `posts` (the thread's parent post is passed in by
260
+ * the caller from an already-fetched, already-transformed `FeedPost`, not
261
+ * re-fetched).
262
+ */
263
+ async function fetchThread(cfg, postId) {
264
+ const client = cfg.requireClient();
265
+ const uid = await requireUid(cfg);
266
+ const { data, error } = await client
267
+ .from("comments")
268
+ .select("id, post_id, author_id, content, status, created_at, profiles(username, is_official, handle, avatar_url)")
269
+ .eq("post_id", postId)
270
+ .in("status", ["visible", "pending"])
271
+ .order("created_at", { ascending: true });
272
+ if (error)
273
+ throw error;
274
+ return (data ?? []).map((r) => (0, models_1.mapCommentRow)(r, uid, cfg.anonymousAuthorFallback));
275
+ }
276
+ /**
277
+ * Insert a post as 'pending' (invisible to others until moderated). Returns id.
278
+ * With pollOptions, the post and its options are created transactionally by
279
+ * the create_poll_post RPC (security invoker, same RLS as a plain insert);
280
+ * that branch is gated on `cfg.modules.polls`.
281
+ */
282
+ async function createPost(cfg, input) {
283
+ const { topic, body, pollOptions } = input;
284
+ const hasPoll = !!pollOptions && pollOptions.length > 0;
285
+ if (hasPoll && !cfg.modules.polls)
286
+ throw new Error("polls module is not enabled");
287
+ const client = cfg.requireClient();
288
+ const uid = await requireUid(cfg);
289
+ if (hasPoll) {
290
+ const { data, error } = await client.rpc("create_poll_post", {
291
+ p_topic: topic,
292
+ p_content: body,
293
+ p_options: pollOptions,
294
+ });
295
+ if (error)
296
+ throw error;
297
+ return data;
298
+ }
299
+ const { data, error } = await client
300
+ .from("posts")
301
+ .insert({ author_id: uid, topic, content: body })
302
+ .select("id")
303
+ .single();
304
+ if (error)
305
+ throw error;
306
+ return data.id;
307
+ }
308
+ /**
309
+ * Cast or change my vote: one row per (post, user), upsert swaps the option.
310
+ * `optionId` is the poll option's row id (matching `FeedPoll.myOptionId` /
311
+ * `applyPollVote`'s optimistic-update contract from Task 3), not a numeric
312
+ * index — see the task report for this naming call against the brief.
313
+ * Gated on `cfg.modules.polls` for consistent DX with the reaction module.
314
+ */
315
+ async function votePoll(cfg, postId, optionId) {
316
+ if (!cfg.modules.polls)
317
+ throw new Error("polls module is not enabled");
318
+ const client = cfg.requireClient();
319
+ const uid = await requireUid(cfg);
320
+ const { error } = await client
321
+ .from("poll_votes")
322
+ .upsert({ post_id: postId, option_id: optionId, user_id: uid }, { onConflict: "post_id,user_id" });
323
+ if (error)
324
+ throw error;
325
+ }
326
+ /** Insert a comment as 'pending'. Returns id. */
327
+ async function createComment(cfg, postId, body) {
328
+ const client = cfg.requireClient();
329
+ const uid = await requireUid(cfg);
330
+ const { data, error } = await client
331
+ .from("comments")
332
+ .insert({ post_id: postId, author_id: uid, content: body })
333
+ .select("id")
334
+ .single();
335
+ if (error)
336
+ throw error;
337
+ return data.id;
338
+ }
339
+ /** Run synchronous moderation on a just-created pending post/comment. */
340
+ async function moderateOne(cfg, input) {
341
+ const client = cfg.requireClient();
342
+ const { data, error } = await client.functions.invoke("moderate-one", { body: input });
343
+ if (error)
344
+ return { status: "pending", reason: "moderation unavailable" };
345
+ return data;
346
+ }
347
+ async function setLike(cfg, postId, on) {
348
+ const client = cfg.requireClient();
349
+ const uid = await requireUid(cfg);
350
+ if (on) {
351
+ const { error } = await client.from("likes").insert({ post_id: postId, user_id: uid });
352
+ // 23505 = already liked (double tap / retry): the desired state holds.
353
+ if (error && error.code !== "23505")
354
+ throw error;
355
+ }
356
+ else {
357
+ const { error } = await client.from("likes").delete().match({ post_id: postId, user_id: uid });
358
+ if (error)
359
+ throw error;
360
+ }
361
+ }
362
+ /**
363
+ * `setPrayed` generalized to `setReaction`: insert my private, non-retractable
364
+ * reaction row (rows are never deleted — see spec §6). Gated on `cfg.modules.reaction`.
365
+ */
366
+ async function setReaction(cfg, postId) {
367
+ if (!cfg.modules.reaction)
368
+ throw new Error("reaction module is not enabled");
369
+ const client = cfg.requireClient();
370
+ const uid = await requireUid(cfg);
371
+ const { error } = await client.from("post_reactions").insert({ post_id: postId, user_id: uid });
372
+ // 23505 = already reacted to this post: the desired state holds.
373
+ if (error && error.code !== "23505")
374
+ throw error;
375
+ }
376
+ async function reportContent(cfg, input) {
377
+ const client = cfg.requireClient();
378
+ const uid = await requireUid(cfg);
379
+ const { error } = await client.from("reports").insert({
380
+ reporter_id: uid,
381
+ reported_user_id: input.reportedUserId,
382
+ post_id: input.postId ?? null,
383
+ comment_id: input.commentId ?? null,
384
+ reason: input.reason,
385
+ details: input.details?.trim() || null,
386
+ });
387
+ if (error)
388
+ throw error;
389
+ }
390
+ async function blockUser(cfg, blockedId) {
391
+ const client = cfg.requireClient();
392
+ const uid = await requireUid(cfg);
393
+ const { error } = await client.from("blocks").insert({ blocker_id: uid, blocked_id: blockedId });
394
+ if (error && error.code !== "23505")
395
+ throw error; // already blocked is fine
396
+ }
397
+ async function deleteOwnPost(cfg, postId) {
398
+ const client = cfg.requireClient();
399
+ await requireUid(cfg);
400
+ const { error } = await client.from("posts").update({ status: "deleted" }).eq("id", postId);
401
+ if (error)
402
+ throw error;
403
+ }
404
+ async function deleteOwnComment(cfg, commentId) {
405
+ const client = cfg.requireClient();
406
+ await requireUid(cfg);
407
+ const { error } = await client.from("comments").update({ status: "deleted" }).eq("id", commentId);
408
+ if (error)
409
+ throw error;
410
+ }
411
+ /**
412
+ * The only write path for handle/bio/avatar — moderated server-side by the
413
+ * `update-profile` Edge Function. `invoke` treats any non-2xx as `error`; a
414
+ * `FunctionsHttpError` carries the typed JSON body (e.g. `handle_taken`) on
415
+ * its `context` response, other error kinds mean the request never landed.
416
+ */
417
+ async function updateProfile(cfg, input) {
418
+ const client = cfg.requireClient();
419
+ const { handle, bio, avatarUrl } = input;
420
+ const { data, error } = await client.functions.invoke("update-profile", {
421
+ body: { handle, bio, avatarPath: avatarUrl },
422
+ });
423
+ if (!error)
424
+ return data;
425
+ if (error instanceof supabase_js_1.FunctionsHttpError) {
426
+ // A platform-level 5xx can carry a non-JSON body: degrade to a network error.
427
+ try {
428
+ return (await error.context.json());
429
+ }
430
+ catch {
431
+ return { status: "error", error: "network" };
432
+ }
433
+ }
434
+ return { status: "error", error: "network" };
435
+ }
436
+ /**
437
+ * Upload a JPEG avatar to the `avatars` bucket from a local file URI. Returns
438
+ * the storage path. Takes a `fileUri` (not a base64 string, unlike the mold)
439
+ * so the core has no Hermes/`atob` dependency — `fetch` on a `file://` URI is
440
+ * the portable way to read local bytes in React Native.
441
+ */
442
+ async function uploadAvatar(cfg, fileUri) {
443
+ const client = cfg.requireClient();
444
+ const uid = await requireUid(cfg);
445
+ const path = `${uid}/${Date.now()}.jpg`;
446
+ const response = await fetch(fileUri);
447
+ const bytes = await response.arrayBuffer();
448
+ const { error } = await client.storage
449
+ .from(AVATAR_BUCKET)
450
+ .upload(path, bytes, { contentType: "image/jpeg" });
451
+ if (error)
452
+ throw error;
453
+ return path;
454
+ }
package/lib/time.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** Pure, language-free decomposition of an elapsed duration, for a consumer
2
+ * (e.g. `@rocapine/community-ui`'s `formatTimeAgo`) to localize into its own
3
+ * catalog. `unit: "now"` carries no `value` worth formatting. */
4
+ export type TimeAgoParts = {
5
+ unit: "now";
6
+ value: 0;
7
+ } | {
8
+ unit: "minute" | "hour" | "day";
9
+ value: number;
10
+ };
11
+ /** Decompose `iso` relative to `nowMs` into a unit + count, with no language
12
+ * baked in — see `timeAgo` for the hardcoded-English string this replaces
13
+ * core-internally, kept for back-compat. */
14
+ export declare function timeAgoParts(iso: string, nowMs: number): TimeAgoParts;
15
+ /**
16
+ * Compact relative label: "now", "12m", "5h", "3d". Hardcoded English —
17
+ * kept for back-compat, but `@rocapine/community-ui` no longer calls this
18
+ * itself; it localizes through `timeAgoParts` + its own catalog instead (see
19
+ * `formatTimeAgo` in the ui package).
20
+ */
21
+ export declare function timeAgo(iso: string, nowMs: number): string;
package/lib/time.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ // Display helpers for feeds. No React Native / Supabase imports: pure, exercised in node (vitest).
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.timeAgoParts = timeAgoParts;
5
+ exports.timeAgo = timeAgo;
6
+ const MIN = 60000;
7
+ const HOUR = 3600000;
8
+ /** Decompose `iso` relative to `nowMs` into a unit + count, with no language
9
+ * baked in — see `timeAgo` for the hardcoded-English string this replaces
10
+ * core-internally, kept for back-compat. */
11
+ function timeAgoParts(iso, nowMs) {
12
+ const elapsed = Math.max(0, nowMs - Date.parse(iso));
13
+ if (elapsed < MIN)
14
+ return { unit: "now", value: 0 };
15
+ if (elapsed < HOUR)
16
+ return { unit: "minute", value: Math.floor(elapsed / MIN) };
17
+ if (elapsed < 24 * HOUR)
18
+ return { unit: "hour", value: Math.floor(elapsed / HOUR) };
19
+ return { unit: "day", value: Math.floor(elapsed / (24 * HOUR)) };
20
+ }
21
+ /**
22
+ * Compact relative label: "now", "12m", "5h", "3d". Hardcoded English —
23
+ * kept for back-compat, but `@rocapine/community-ui` no longer calls this
24
+ * itself; it localizes through `timeAgoParts` + its own catalog instead (see
25
+ * `formatTimeAgo` in the ui package).
26
+ */
27
+ function timeAgo(iso, nowMs) {
28
+ const parts = timeAgoParts(iso, nowMs);
29
+ if (parts.unit === "now")
30
+ return "now";
31
+ return `${parts.value}${parts.unit === "minute" ? "m" : parts.unit === "hour" ? "h" : "d"}`;
32
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@rocapine/community-core",
3
+ "version": "0.1.0",
4
+ "description": "Headless Supabase-backed community backend: identity, feed/thread/moderation services, and React Query hooks.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Rocapine/community-sdk"
9
+ },
10
+ "main": "lib/index.js",
11
+ "types": "lib/index.d.ts",
12
+ "files": [
13
+ "lib",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "typecheck": "tsc --noEmit",
18
+ "build": "tsc -p tsconfig.json --outDir lib --noEmit false",
19
+ "test": "vitest run"
20
+ },
21
+ "peerDependencies": {
22
+ "react": ">=18",
23
+ "@supabase/supabase-js": "^2.45.0",
24
+ "@tanstack/react-query": "^5.50.0"
25
+ },
26
+ "devDependencies": {
27
+ "react": "19.2.0",
28
+ "@supabase/supabase-js": "^2.45.0",
29
+ "@tanstack/react-query": "^5.50.0",
30
+ "@types/react": "^19.2.0"
31
+ }
32
+ }