295 Commits

Author SHA1 Message Date
Arjun Patel ba0fbcb4ba unused var 2026-06-13 11:36:21 -07:00
Arjun Patel 3175b04d3c cleanup unnecessary logic and complexity 2026-06-13 11:33:12 -07:00
Arjun Patel 368922a2c1 cleanup unnecessary logic 2026-06-13 11:31:28 -07:00
Arjun Patel 3381ff0b02 fix avatar change affordance 2026-06-13 11:25:50 -07:00
Arjun Patel 03ec264c93 fix awkward stream list experience 2026-06-13 11:18:09 -07:00
Arjun Patel 9249cb4107 chore: bump desktop to v1.7.0 (#281) 2026-06-12 12:52:05 -07:00
Arjun Patel 095b9876f9 feat: stream list view and tasks (#279)
* first attempt at stream sidebar, tasks, and events

* fix folder from root

* cleanup folders and events, and condense changes

* cleanup and add toggle for sidebar

* cleanup

* fix nits
2026-06-12 12:26:49 -07:00
Arjun Patel a358774106 ci: fix stuck on migration runs (#278) 2026-06-11 16:52:05 -07:00
Arjun Patel d24947d5ce fix: remove settings option from stream-view (#277)
* remove settings option

* remove unused var
2026-06-11 16:51:37 -07:00
Arjun Patel 9e3a30f74e bump version (#276) 2026-06-11 15:38:05 -07:00
Arjun Patel 4facc6b371 feat: avatars for humans (#273)
* implement avatar backend functionality

* add avatar endpoints

* typo

* implement client side avatar upload and handling

* fixes

* Update go/internal/handler/handler.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update js/desktop/src/lib/avatar-image.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* bug in order

* remove unused component

* fix invalid migration

* fix syntax errors

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 15:30:21 -07:00
Arjun Patel ca3bbf204e fix: text compose papercuts (#270)
* make fixes

* cleanup
2026-06-11 12:08:34 -07:00
Arjun Patel 66d53497c5 update claude.md (#269) 2026-06-11 10:58:36 -07:00
Arjun Patel b9a8529e72 ci: split pr quality gates for mobile and desktop (#268) 2026-06-11 10:57:03 -07:00
Arjun Patel 95f2362947 feat: allow clicking keyboard hints throughout (#266)
* allow clicking keyboard hints

* Update js/desktop/src/components/key-hint.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* nits

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 10:48:59 -07:00
Arjun Patel 9fd7e611f3 feat: organize network settings into tabs (#264)
* implement

* fix: apply CodeRabbit auto-fixes

Fixed 2 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

* nits

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-06-11 10:21:19 -07:00
Arjun Patel 849d41fa06 feat: use sexier audio waveform (#265)
* use sexier audio waveform

* format
2026-06-11 10:07:25 -07:00
Arjun Patel cf94003b3f chore: bump desktop to v1.5.2 (#257) 2026-06-10 16:38:22 -07:00
Arjun Patel aa2b2f7432 show private indicator for appropriate streams (#255)
* show private for sterams with no other members

* invalid calculation

* refactor: more readable code

* fix type error

* format
2026-06-09 12:38:52 -07:00
Arjun Patel e886c56dd9 fix: prebundle deps before loading electron app (#254)
Electron Forge restarts the renderer process without fully restarting the Vite dev server, so the browser gets a fresh page with a new hash but the Vite server still has the old pre-bundle cache — mismatch causes the 504 outdated error every other time we launch.
2026-06-09 11:11:01 -07:00
Arjun Patel 946d141897 infra: cleanup tsconfig and add typecheck (#253) 2026-06-09 10:47:48 -07:00
Arjun Patel 92894c4af8 paginate open streams (#252)
* paginate for open and closed streams

* fix: add platform abstraction for deeplink (#251)

* fix: add platform abstraction for deeplink

This was crashing App.tsx due to the deep link listener calling methods on window which do not exist.

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

* log error

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

* cleanup

* fix: import

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-06-09 10:43:27 -07:00
Arjun Patel f38835ceee fix: add platform abstraction for deeplink (#251)
* fix: add platform abstraction for deeplink

This was crashing App.tsx due to the deep link listener calling methods on window which do not exist.

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

* log error

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-06-09 10:01:22 -07:00
Arjun Patel a94a986fbf fix: invalid dock badge count (#250) 2026-06-09 09:28:55 -07:00
Arjun Patel 1aceab582c docs typo (#248) 2026-06-09 09:12:03 -07:00
Arjun Patel c1f5b6c8c1 Implement membership notifications and deep-link handling for Electron desktop app (#246)
* security: add cors for desktop app scheme

* Revert "security: add cors for desktop app scheme"

This reverts commit d450fced75.

* ignore tags

* add commands for windows dev

* cleanup unnecessary parts of main desktop process

* resolve lint errors

* add format command for go

* fix: send email on new member joining

Closes #228

* add proper deeplinking on desktop

The desktop app was merely focusing before, but now it will navigate to the proper route

* honor email notifications setting

* prevent sending email when no recipients
2026-06-09 08:04:18 -07:00
Arjun Patel 9184d002d3 update copy for sign in views (#238)
* update copy for sign in views

This disambiguates whether the view is for sign up or sign in.

* format

* escape character

* format

* update workflow names
2026-06-02 09:39:11 -07:00
Arjun Patel 0439c51c2e docs (#237) 2026-06-02 08:56:29 -07:00
Arjun Patel 628c191137 ci: add pr quality gate for golang (#236)
* ci: add pr quality gate for golang

* test formatting error

* fix job

* format
2026-06-02 08:46:01 -07:00
Arjun Patel 79cc14490a chore: bump mobile version to v0.6.1 (#235) 2026-06-02 08:40:36 -07:00
Arjun Patel 99622743c9 chore: bump desktop version to v1.5.1 (#234)
* create runbook for deployments

* create runbook for deployments
2026-06-02 08:40:10 -07:00
Arjun Patel fa3c8268b2 infra: add pr quality gate for client applications (#233)
* add gh action for quality gate

* test quality gate

* fix job

* rename gh action jobs

* format
2026-06-02 08:04:58 -07:00
Arjun Patel a8a0b7db1b infra: add linting and formatting for js projects (#230)
* wip

* wip

* wip

* format

* wip(mobile): lint and format

* cleanup

* nits

* nits

* idiomatic react

* nit

* format all root files
2026-06-02 07:44:24 -07:00
Arjun Patel 2fe562ce2b fix: editing text particle not updating immediately
Closes #229
2026-06-01 10:03:34 -07:00
Arjun Patel fb771a7408 update email notification cta 2026-05-31 12:39:38 -07:00
Arjun Patel 189d5e296b ci: remove artifact uploads during windows build
Resolves issues with artifact storage being full
2026-05-30 12:04:02 -07:00
Arjun Patel 613a350292 chore: bump versions 2026-05-30 11:12:08 -07:00
Arjun Patel 2eba834838 feat: use inline markdown editor (#227)
* Use inline WYSIWYG markdown editor for long text messages

Desktop: replace the Write/Preview tab toggle in the compose card with
MDXEditor, an inline WYSIWYG that renders markdown as you type (Obsidian/
Notion feel) and round-trips plain markdown, matching how particle content
is stored. Immersive mode is unchanged — short, plain notes still get the
centered large-type textarea, and ⌘+M still drops into the rich editor.
Shortcut handling moves to the capture phase so ⌘+Enter / Esc win over the
editor's own key handling.

Mobile: render markdown on the display side via react-native-marked's
useMarkdown hook (no FlatList, so it nests cleanly in the existing
ScrollView). Mirrors desktop's immersive-vs-card logic: short plain notes
stay centered; anything with markdown renders formatted instead of showing
raw syntax. Composer stays plain text.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* cleanup desktop app description

* Fix markdown editor rendering and pasted-link duplication

- Restore heading/list typography in the editor: MDXEditor relies on the
  browser's default styles for <h1>/<ul> sizing, which Tailwind's Preflight
  resets. Markdown shortcuts fired but the block looked unchanged. Style the
  editor content explicitly, mirroring the rendered display.
- Keep pasted/typed URLs as plain text (linkPlugin disableAutoLink) so they
  don't become `[url](url)`, which duplicated both the URL and its preview
  card. Also dedupe extractUrls defensively so a repeated URL yields one card.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* Complete GFM support: tables, task lists, strikethrough

Editor: add tablePlugin so markdown tables round-trip and render. Task
lists (`- [ ]`), strikethrough, code (inline + fenced), headings, lists,
quotes, and links already work via listsPlugin/core/markdownShortcut. CSS:
suppress the disc bullet on task-list items (they draw their own checkbox)
and style tables.

Display: render task-list checkboxes (no stray bullet), GFM tables, and
strikethrough so the read view matches what the editor produces.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* Unify markdown on one engine (Milkdown Crepe)

Replace the two-engine setup (MDXEditor for editing + react-markdown for
display) with a single Crepe-based component used for both. Editing is inline
WYSIWYG with full GFM that actually works — task lists, tables, and code
blocks via type-to-create / the slash menu — and read-only mode renders the
exact same way, so write and read can no longer drift (the root cause of the
heading/list/table/checkbox bugs).

- markdown-editor.tsx: Crepe wrapper supporting edit + readOnly, themed to the
  glass card via --crepe-* variable overrides (scoped to .milkdown so they win
  over frame-dark's own definitions).
- text-particle-view.tsx: render the message card with the read-only editor;
  drop the react-markdown component map.
- Remove now-unused deps: @mdxeditor/editor, react-markdown, remark-gfm,
  rehype-highlight, highlight.js.

The compose immersive mode (short, centered plain text) is unchanged.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* Fix Crepe slash-menu transparency and match the app font

The floating menus (slash menu, toolbar, link tooltip) portal to <body>,
outside .milkdown, so the --crepe-* overrides never reached them and their
background fell back to transparent — unreadable over the content. Declare the
palette on those selectors too, keep --crepe-color-surface (near-)opaque so the
menus read clearly, and add a backdrop blur.

Also set --crepe-font-default/title to inherit (and a monospace stack for code)
so the editor uses the application font instead of Crepe's bundled Noto faces.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* Give the slash menu the full card by padding the editor, not the card

Crepe appends the slash menu to .milkdown and (in this version) doesn't expose
a way to portal it to <body>, so it's clipped by the editor's scroll box. That
box sat inside the card's p-5 padding, so the menu cut off at the padding edge.
Move the padding onto the ProseMirror content instead and drop the redundant
inner scroll container, so the menu's clipping bounds become the full card.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* tweaks

* mobile: render markdown text consistent with desktop

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-30 11:10:49 -07:00
Arjun Patel 7b000429e9 mobile: follow a just-sent particle when at the end of a stream (#226)
When the local user sends a particle while positioned on the last one in a
stream, advance playback to the newly created particle so they stay caught up
and the stream isn't left in an unseen state. If they're browsing earlier
particles, their position is left untouched.

The mobile reducer only auto-advanced on PARTICLE_ADDED when status was
already "ended", so sending while sitting on (but not finished with) the last
particle left it unseen. This threads an onParticleCreated callback from
ComposeDock's default send paths up to StreamView, which follows the particle
via goToParticle only when currentIndex is the last slot — mirroring desktop's
StreamView.handleParticleCreated.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-30 11:09:44 -07:00
Arjun Patel 0cd126818a adjust email invitation copy 2026-05-29 11:21:34 -07:00
Arjun Patel 832ebe9376 chore: bump version 2026-05-28 13:08:04 -07:00
Arjun Patel 9645391f6a chore: bump version 2026-05-28 12:46:03 -07:00
Arjun Patel 0ae3ab5505 cleanup desktop and mobile assets and icons (#223)
This fixes quirks for different platforms
2026-05-28 12:45:25 -07:00
Arjun Patel 3bfc15945d infra: add cross-platform tooling with Taskfile 2026-05-28 11:21:57 -07:00
talksik 91abacc2de chore: bump version 2026-05-28 10:30:44 -07:00
Arjun Patel e5d1349ad7 feat: paginate stream page indicator
Closes #221
2026-05-28 10:12:41 -07:00
Arjun Patel 563c91e7d5 infra: add logging wrapping
Resolves issues with gcp cloud logging quirks such as field names
2026-05-27 15:49:27 -07:00
Arjun Patel e804cee229 refactor: use slog instead of logrus 2026-05-27 15:20:53 -07:00
Arjun Patel 72bc41733f chore: bump version 2026-05-26 17:22:38 -07:00
Arjun Patel 96f5a727d6 feat(mobile): add support for huddles to mobile app (#219)
* feat(mobile): add huddles with LiveKit

Mirrors the desktop huddle window: tap the headphones button on a stream
to fetch a LiveKit token from Orion and join a video room. Active huddles
show a red badge on the stream card (participant count) and a red Join
pill in the stream top bar — both driven by huddle_active_participants
synced from the LiveKit webhook.

The HuddleScreen lives at its own route (not a modal/window): video tile
grid with placeholders for camera-off, mic/camera toggles, and a leave
button. Adds the LiveKit RN SDK plus the WebRTC + LiveKit Expo config
plugins for prebuild.

https://claude.ai/code/session_01Po5tAD17MXhnqGQ9hKh6PC

* fix(mobile): exiting from huddle

* fix: pause stream during huddle

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-26 16:37:37 -07:00
Arjun Patel 746e52158d ci: fix build dep 2026-05-26 15:41:54 -07:00
Arjun Patel 64f02d1c3b feat: ship a web app (#218)
* feat(orion): add endpoint for link metadata

* refactor: cleanup comments

* wip: plumbing for building a web app

* setup deployment materials for web app

* fix(web): favicon
2026-05-26 15:37:35 -07:00
Arjun Patel 173c63508a fix(mobile): unregister push token on invalid session 2026-05-26 10:43:15 -07:00
Arjun Patel 54bc77444b chore: bump version 2026-05-26 10:27:09 -07:00
Arjun Patel 636ef484ad feat(mobile): allow rear camera recordings
Closes #211
2026-05-26 10:24:54 -07:00
Arjun Patel 0eddf4639a fix(mobile): properly pause and resume other audio during recording 2026-05-26 10:21:42 -07:00
Arjun Patel e7fed5f037 feat(mobile): edit text messages 2026-05-26 10:10:49 -07:00
Arjun Patel ec01ca77e4 fix(mobile): sign in race condition
Closes #217
2026-05-26 10:00:52 -07:00
Arjun Patel d262f734f0 Mobile notifications for iOS (#210)
* mobile: wire notification registration and listener

* implement backend components for push notifications

* refactor: agentic comment cleanup

* docs: use proper module name for particle processor

* set required env variables for push notifications

* bump version

* fix: always upsert push token on mobile start

* Revert "fix: always upsert push token on mobile start"

This reverts commit 90ff18a788.

* send push notifications regardless of online status
2026-05-18 12:44:31 -07:00
talksik a564ea819b bump version 2026-05-17 16:52:23 -07:00
talksik 9fd28e26a0 bump version 2026-05-17 16:52:09 -07:00
talksik 2492188c50 fix: prevent nesting of formatted text reactions
Usage of map was causing issues if the text reaction had special
characters.

Closes #205
2026-05-17 16:51:37 -07:00
talksik 4b468ed1ae chore: bump version 2026-05-01 09:24:07 -07:00
talksik ff2ed72e22 perf: reduce mobile video size
Improves upload and playback speed
2026-05-01 09:23:52 -07:00
talksik 127c57fa7c chore: bump version 2026-04-30 18:21:14 -07:00
talksik 1a08bfac8c chore: bump version 2026-04-30 17:57:14 -07:00
talksik 5b8aefd994 mobile: improve usability for exiting stream 2026-04-30 17:52:37 -07:00
talksik 43ad4d3b62 mobile: show transcript preview for media particles in stream list 2026-04-30 17:49:13 -07:00
talksik 13d3df2b51 mobile: consistent dark theme as desktop 2026-04-30 17:46:09 -07:00
talksik 424f27737b feat: improve usability with clickable keyboard hints
This allows people to use the app even if they aren't used to their
keyboard.
Closes #186
2026-04-30 17:45:03 -07:00
talksik aaf9ad4518 show progress during upload
More explicit feedback of the state of their upload. Also prevents
recording more media at the same time or interacting with the stream
which would feel stale to the human since they just created something
and it's yet to be present in the stream.
2026-04-30 17:23:17 -07:00
talksik 3051b1558f feat: captions on mobile
Closes #199
2026-04-30 17:14:15 -07:00
talksik a6a4757a8e fix: stream actions not working
Multiple sheets compete with each other and cause silent failure on ios.
2026-04-30 16:53:48 -07:00
talksik 6832c273cd fix: prevent restart playback after transcoding completion
Closes #201
2026-04-30 16:39:23 -07:00
talksik 39ac3ed290 create backfill for transcoding media particles
Closes #192
2026-04-30 08:20:24 -07:00
talksik 89906dba7c refactor: abstraction for transcoding 2026-04-30 07:30:55 -07:00
talksik 91a2030f9b fix: worker utilizing incorrect bucket 2026-04-30 07:04:50 -07:00
talksik ef7d5bfa59 fix desktop playback aspect ratio for mobile 2026-04-29 18:17:45 -07:00
Arjun Patel e3461dd5cd mobile v0.1 with deployment for ios (#191)
* stage 1: project init

* stage 2: skeleton with navigation

* step 2.5: streams list

* step 4: stream playback experience

* step 5-6: compose experience

* fix: broken record

* transcode media particles to mp4

* build: reproducible go generate

* build: rename skaffold module for particle processor worker

* infra: increase particle processor worker resources

Was dealing with OOM errors

* tweaks to mobile

* log transcode work

* view on desktop placeholder

* tweak padding

* cap video resolution to save on memory

* infra: bump memory limits as insurance

* ux improvements

* update bundle id for mobile

* config for mobile
2026-04-29 17:39:11 -07:00
Arjun Patel 3a11a82cd3 refactor: organize desktop vs. mobile into separate folders 2026-04-29 08:42:56 -07:00
Arjun Patel 3d9fe67936 chore: bump version 2026-04-29 07:55:38 -07:00
Arjun Patel aacea01c1f feat: text reactions
Closes #180
2026-04-29 07:44:26 -07:00
Arjun Patel eb1e77b4c1 fix: allow next / prev particle during pause 2026-04-29 07:16:39 -07:00
Arjun Patel 1f2ab9b752 show keyboard hints for space pause 2026-04-29 07:16:16 -07:00
Arjun Patel a216b0722f fix: go to new local particle after creation
If human is on the last particle of the stream and creates a new one, this would navigate them there, as it makes sense to consider them caught up.
2026-04-29 06:49:13 -07:00
Arjun Patel f642d13ba1 feat: allow toggle stream-view play/pause 2026-04-29 06:16:24 -07:00
Arjun Patel 70087cf4e5 refactor: precondition for record toggle 2026-04-29 06:11:57 -07:00
Arjun Patel 0f73d685c2 refactor: narrow auth service to smaller units
Easier for testing, and omits need for full dep injection for pusher service, where we passed in nil for deps dangerously.
2026-04-27 16:14:37 -07:00
Arjun Patel 48d6d5cb07 refactor: unit testable units and cleaner dep injection 2026-04-27 16:07:28 -07:00
talksik 013c453dd3 chore: bump version 2026-04-25 14:25:42 -07:00
talksik 6b755a426b feat: add sound effects for hud actions
Closes #174
2026-04-25 14:25:24 -07:00
talksik 13c6bbd0fb refactor: re-structure stream playback control 2026-04-25 13:48:37 -07:00
talksik cf143f232d fix: prevent exit countdown during editing text 2026-04-25 13:10:32 -07:00
talksik a686d85e96 fix: papercuts around last particle in stream
Pause when holding space during close countdown. Prevent arrow keys
starting exit countdown when on last particle
2026-04-25 13:06:26 -07:00
talksik 03ced6d180 fix: pause playback during delete particle overlay 2026-04-25 12:49:01 -07:00
talksik 1c3b4447cf infra: hook up sentry 2026-04-25 12:43:30 -07:00
talksik 1e7a14d703 fix: set proper cluster name 2026-04-24 16:14:57 -07:00
talksik 0ac395ceb6 refactor: unused imports 2026-04-22 09:13:49 -07:00
talksik 110f4594be refactor: use shared mocks for tests 2026-04-22 09:06:10 -07:00
talksik 2e9ff742a0 send admin alert when new network created 2026-04-22 08:50:03 -07:00
Arjun Patel 64a21198b3 Add comprehensive error handling and recovery UI (#173)
* feat(electron): error handling foundation

Adds the infrastructure for a coherent client-side error story:

- `lib/errors.ts`: canonical ApiError + QuotaExceededError, `toUserMessage`
  (friendly strings for ApiError/ZodError/network errors, strips Electron
  IPC message prefixes), `logError` (expected), `reportError` (unexpected).
- `lib/query-client.ts`: QueryClient factory with sane retry defaults (no
  retry on 4xx except 408/429, 2 retries otherwise; 0 mutation retries),
  `QueryCache` onError logs + opts in via `meta.toastOnError`, and
  `MutationCache` onError toasts `toUserMessage(err)` by default with
  `meta.suppressToast` as the opt-out.
- `components/app-error-boundary.tsx` + `error-fallback.tsx`: two boundaries
  (top-level outside the router, route-level inside) with a Card-based
  fallback offering 'Go home' + 'Try again'. Route boundary resets on
  pathname change and clears React Query error cache on retry.
- `main/ipc-utils.ts` + main.ts migration: `safeHandle` wraps ipcMain.handle
  so main-process failures log with full stack and surface a sanitized
  message to the renderer. `link:fetch-metadata` keeps its null contract
  but now logs.
- `useCreateParticle` opts out of the global toast (compose-overlay renders
  its own quota UX) so nothing double-toasts.

Render crashes now have a recovery UI, every mutation gets a free error
toast, and silent-catch cleanup + Sentry land in follow-up PRs.

* refactor(errors): defer mutation errors to global handler

Now that MutationCache toasts via toUserMessage by default, the per-hook
onError duplicates drop away. Also normalizes inline query-error UI and
surfaces a previously-silent failure.

Mutations — removed redundant onError toasts:
- network-selector: useAcceptInvitation, createNetwork (inline)
- network-settings: useInviteMembers, useRevokeInvitation, useRemoveMember
- network-billing: useCreateCheckoutSession, useCreatePortalSession
  (onSuccess toasts stay — they carry domain context like network name)

Queries — consistent inline error UX via toUserMessage:
- network-selector: failed-to-load state gets a "Try again" button
- network-billing: "Couldn't load billing" includes friendly reason
- network-settings: useNetworkInvitations failure now surfaces a hint
  (previously rendered as "0 pending" — silently wrong)

Trimmed noisy JSDoc from PR 1 files (errors.ts, query-client.ts,
app-error-boundary.tsx, error-fallback.tsx, ipc-utils.ts).

* refactor(errors): route silent catches through logError/reportError

Every catch now either surfaces, re-throws, or calls logError with a scope
tag. No more empty catches or bare console.error:

- auth-store: signInToFirebase / restoreSession / signOut paths gain
  logError context. Behavior is unchanged (best-effort local sign-out,
  fall back to login on restore failure).
- use-stream-autoplay: Audio.play() and download-URL fetches log their
  failures instead of dropping silently (both are nice-to-haves so UX
  stays silent — but we can now trace "why didn't autoplay trigger?").
- pusher-client: ws errors / parse failures / server errors / listener
  crashes all routed through logError, and listener bugs (which silently
  break user flows) now go through reportError so they're actually
  surfaced in observability.
- settings-page: email-notifications toggle now toasts on failure
  instead of silently reverting with no explanation.
- huddle-app: screen-share failures use logError.

* feat(errors): add Sentry observability behind a sinks facade

logError / reportError now route through a pair of sinks installed at
bootstrap by each process (main + every renderer entry). No call site
knows about Sentry — if sentryDsn is empty, the sinks simply aren't
installed and logError/reportError stay console-only.

- appConfig.sentryDsn: per-env string (empty for now — populate when
  ops creates the DSNs). Empty is the no-op mode for dev.
- lib/errors.ts: installErrorSinks({ capture, breadcrumb }) gates
  Sentry.captureException / Sentry.addBreadcrumb. Everything flows
  through toUserMessage and the two existing call types.
- lib/sentry.ts: initSentryRenderer() for the main window + autoplay,
  huddle, and screen-record renderers.
- main/sentry.ts: initSentryMain() runs before anything else in
  main.ts to catch bootstrap failures. Captures uncaught exceptions
  and the crash reporter automatically.
- main/ipc-utils.ts::safeHandle now routes through reportError.
- main.ts::fetchLinkMetadata logs via logError.

* chore: cleanup reexports

* fix(errors): use relative import so main-process bundle resolves

Rollup in vite.main.config.ts doesn't know about the `@/` alias (only the
renderer configs do). After PR 4 made `lib/errors.ts` reachable from main,
its `@/config/env` import broke the main build with:

  [vite]: Rollup failed to resolve import "@/config/env" from "lib/errors.ts"

Swap to a relative import — the module is now genuinely isomorphic.

* build(vite): resolve @/ alias in main + preload configs

The four renderer vite configs all resolve "@" to ./src; main and preload
didn't, so any main-reachable file using @/ imports would break the
bundle. Mirror the same alias block in both configs and revert
lib/errors.ts + src/main/* to the project's @/ convention.

* fix invalid tsconfig field

* comment

* feat(errors): fallback handlers for unhandled rejections and errors

Sentry's global integrations natively capture window.onerror /
unhandledrejection (renderer) and uncaughtException / unhandledRejection
(main). When a DSN is configured we let Sentry own those paths to avoid
double-capturing. When it isn't (dev, unconfigured prod), we attach
minimal listeners that route through reportError so stray promise
rejections and uncaught errors at least hit the console and the facade.

This closes the one gap where a bare click-handler promise rejection
(no try/catch, not a useMutation) would otherwise be invisible.

* Revert "feat(errors): fallback handlers for unhandled rejections and errors"

This reverts commit ed84b29c6b.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Arjun from Flowy Labs <arjun@flowy.live>
2026-04-20 19:22:02 -07:00
Arjun Patel bd7a0c4fb8 ci: remove unnecessary env variables 2026-04-20 17:41:02 -07:00
Arjun Patel 7bc06d1dc5 ci: add deployment workflows for different services 2026-04-20 17:29:33 -07:00
talksik 2deef1fe37 fix: prevent email notifications to removed members
Content will retain an older member in visible_to, even if that member
was removed from the network.
2026-04-18 07:37:07 -07:00
talksik 30469fda66 revert "fix: prevent writing to nonexistent firestore docs"
This reverts commit 46170eb5d3.
2026-04-16 18:00:51 -07:00
talksik 5e930d3c2e chore: bump version 2026-04-16 15:38:48 -07:00
Arjun Patel 98fdc98c76 feat: surface privacy policy and terms in sign-in and settings (#171)
Adds an implicit-consent disclaimer under the sign-in "Continue" button
and a new "Legal" section in the settings page. Both link out to the
policies hosted on flowylabs.ai via the existing openExternal bridge.

https://claude.ai/code/session_01U6gT7XFQ8Rtm3FStsHG63j

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-16 15:37:28 -07:00
Arjun Patel ef899ee5cd security: access control for particles (#169)
* setup firebase custom token

* docs

* docs

* feat: allow admin removing members from a network

* fix: properly handle fallback avatar and names

This is especially helpful in the case of members who were removed from
a network
2026-04-16 15:14:34 -07:00
talksik 28b1ff542b feat: add support email
This will help humans whether they have billing questions or otherwise.
2026-04-16 09:15:31 -07:00
talksik 61a6904cc2 add nav bar to sign in flow 2026-04-16 09:14:50 -07:00
Arjun Patel 567884433d fix: render correct meta key in keyboard hints on Windows/Linux (#168)
Hardcoded ⌘ glyphs in compose hints were misleading on non-mac
platforms. Add a tiny platform helper sourced from the existing
window.electronWindow.platform bridge and substitute "Ctrl" off mac.
Closes #167.

https://claude.ai/code/session_01YZY4wUqACT7mgibHr2Yncx

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-16 09:04:29 -07:00
talksik 7da3ec6ab6 build: set windows icons properly 2026-04-15 13:32:53 -07:00
talksik ae38234e17 chore: bump version 2026-04-15 13:24:32 -07:00
talksik b508499dbd ci: fix no metadata for azure 2026-04-15 12:41:36 -07:00
talksik 111acb445c ci: fix duplicate signtool flags 2026-04-15 12:25:13 -07:00
talksik 6755ab9a1b ci: use newer sign tool for windows
The electron forge one won't support azure trusted account signing
2026-04-15 12:12:13 -07:00
talksik 772d8cf3e1 ci: enable debug for windows sign 2026-04-15 11:46:57 -07:00
talksik a03200472f ci: log windows sign failure 2026-04-15 11:30:32 -07:00
talksik 46170eb5d3 fix: prevent writing to nonexistent firestore docs
Using the same livekit project means we may receive events for another
environment, though we should eventually separate the environments.
2026-04-15 11:02:56 -07:00
talksik 17f84d5425 ci: adjust scripts to use correct file 2026-04-15 10:11:47 -07:00
talksik dd8e7da911 ci: setup windows with signing and gcs publish 2026-04-15 10:04:29 -07:00
talksik 07f02ecfeb use universal window controls across platforms 2026-04-15 08:03:32 -07:00
talksik 637342cb92 prevent remote release 2026-04-14 20:39:51 -07:00
talksik cee7958e03 ci: add preliminary windows build setup 2026-04-14 20:03:16 -07:00
talksik c2d0ed254b fix: improve messaging for reset
Closes #163
2026-04-14 19:04:54 -07:00
talksik 226b284ad2 fix: plan summary not refreshing after purchase 2026-04-14 19:04:31 -07:00
talksik f987f04ff0 chore: bump version 2026-04-14 15:20:55 -07:00
Arjun Patel 67826b92c0 implement paywall (#161)
* implement core foundation

* inject deps

* fix incorrect migration

* tail migration

* use transaction for migration

* fix: inject deps for tests

* cleanup billing management for admin

* upgrade stripe sdk to v85

* set price env variables

* cleanup billing management

* allow multiple dev windows

* fix: settings scroll

* feat: show nice video thumbnail in listview

* feat: implement freemium restrictions

* remove unnecessary comments

* refactor

* docs

* format

* tweak network settings better hierarchy
2026-04-14 15:18:32 -07:00
talksik aff18d82db format 2026-04-14 10:59:53 -07:00
talksik fe2a003e94 fix: huddles not launching reliably
Closes #160
Race condition preventing proper initialization.
2026-04-14 07:58:45 -07:00
talksik 91a973b7fb chore: bump version 2026-04-13 10:44:40 -07:00
talksik 4e76ce902f fix: show proper app version 2026-04-13 10:41:19 -07:00
talksik ae91145762 feat: support url scheme to launch app
Closes #159
2026-04-13 10:41:11 -07:00
talksik f676baf8b3 cleanup video audio settings view 2026-04-13 10:39:58 -07:00
talksik 38bd098ca6 feat: select input devices for recordings
Closes #68
2026-04-13 10:29:10 -07:00
talksik 106254ef83 setup electron app for production 2026-04-13 10:03:23 -07:00
talksik 1579de8c6d feat: shift + arrows to seek track 2026-04-13 09:31:25 -07:00
talksik 3659863647 fix(orion): missing env variable 2026-04-13 09:29:44 -07:00
talksik 1cdaac66db show esc keybinding hint for recording 2026-04-13 09:14:22 -07:00
talksik f2b439cc86 fix: prevent stream exit during overlays 2026-04-13 09:11:46 -07:00
talksik 67091e9d13 fix: prevent exit countdown on playback blocked 2026-04-13 09:06:22 -07:00
talksik bf6fdd15b5 refactor: rename playback suspender 2026-04-13 09:03:24 -07:00
talksik 81a82eee38 fix: text message not showing attachments upon sending
Closes #154
The sender of the message wouldn't see the children particle since we
only fetch the children once. And perhaps the firestore local cache
doesn't have the children particles or some other race condition. But
now, we fetch the live children.

This should anyways use the same number of reads as before since
attachments do not change.
2026-04-13 08:59:34 -07:00
talksik bca45c5e89 chore: bump version 2026-04-12 14:16:01 -07:00
Arjun Patel 5b6924f7ca feat: support image lightbox viewer and file download (#151)
* feat: add lightbox overlay with nice mechanics

* ui tweak for top bar in stream view

* support locally downloading attachments
2026-04-12 14:14:23 -07:00
talksik dbe61e447a fix: persist selected streams tab
This used to navigate me back to the open streams tab even if I click a
stream in the closed list
2026-04-12 13:44:35 -07:00
talksik af0a319399 docs: adjust claude.md 2026-04-12 13:34:49 -07:00
talksik 5a6db9bd48 fix: harden production electron app behavior
- No developer tools or zoom
- No refresh window
- Open links externally
2026-04-12 13:30:18 -07:00
Arjun Patel e1d2bf11da feat: soft-delete particles within a stream (#149)
Lets a particle's creator delete their own message from the TopBar
dropdown. Other viewers see a "This particle was deleted" tombstone in
place and playback auto-advances after ~2s, keeping indices stable for
concurrent watchers.

- Add optional deleted_at / deleted_by_human_id to non-container
  particle variants and isParticleDeleted helper.
- Add softDeleteParticle Firestore helper.
- New DeleteParticleOverlay confirmation and DeletedParticleView
  tombstone.
- Hide reactions (bar + 1-7 keybinding) on tombstoned particles.
- Show "Deleted particle" + Trash2 icon in the stream list preview.

Closes #146

https://claude.ai/code/session_01M2ShnZPvWfQzzvuu3Xm8b9

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 13:18:10 -07:00
talksik 6b20fc2ee6 debug log 2026-04-12 13:16:46 -07:00
talksik be9cf6c5f0 fix: prevent stream click navigation during text edit 2026-04-12 12:15:59 -07:00
Arjun Patel 7568ceb80d feat: extract text editor and allow editing (#150) 2026-04-12 12:13:25 -07:00
Arjun Patel f03868e65b feat: paginate closed streams tab (#148)
Closed streams grow unbounded as teams age and can reach 1000s of items,
while open streams stay bounded by active work. Switch the streams list
to subscribe per-status so each tab only pulls what it renders, and cap
the closed tab to a 50-item window that grows via a Load more action.

The open tab remains unbounded so per-row realtime features — autoplay,
huddle indicators — keep full coverage of the streams that matter.
Closed streams are archived and don't need push behavior, which side-
steps the autoplay baseline-reset and huddle-on-page-2 risks that made
pagination complicated for the open tab.

Refs flowy-live/llink#138.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 11:55:23 -07:00
talksik bea723a90a refactor: extract topbar component into separate file 2026-04-12 11:25:35 -07:00
talksik f64380c421 feat: manage stream name and members
Closes #99
Closes #28
2026-04-12 11:20:05 -07:00
talksik e58edaddd3 feat: send email notifications for network invites
Closes #126
2026-04-12 10:45:24 -07:00
talksik bfa970d706 fix: simplify window / app close behavior
Closes #142
2026-04-12 10:22:21 -07:00
talksik 484ee9d4fb fix: longer markdown content hidden
Added scroll to longer content and padding to avoid conflicting with
stream view peripheral controls and panels
2026-04-12 10:12:06 -07:00
Arjun Patel 2bc4ce7060 fix: unreliable websocket functionality (#144)
* increases timeout

* refactor: reuse constants for redis db

* avoid closing connection on pong error

* fix: prevent electron from pausing timer executions

Our pings could be paused when our app is backgrounded.

* refactor: remove unnecessary refs

* fix: presence unreliable within same pod

It was broadcasting presence changes to other pods, but not notifying
clients connected to the same pod
2026-04-12 10:00:11 -07:00
talksik 4cb562e1ea chore: bump version 2026-04-09 17:05:45 -07:00
talksik 4d9a415981 fix: prevent playback marker going back in time
Closes #141
2026-04-09 17:02:50 -07:00
talksik 762c0ccf6e fix: emails sending despite online status
Closes #140
2026-04-09 16:30:07 -07:00
talksik 66aec4478f fix: websocket connections unreliable
Constant disconnection and reconnection every couple minutes or less.
2026-04-09 16:18:38 -07:00
talksik 5ea8476f54 debug logs for email notifier job 2026-04-09 15:31:45 -07:00
talksik 08f6b95b9b chore: bump version 2026-04-09 14:17:22 -07:00
talksik 51c4c6c822 feat: send email notifications for missed messages
- New cron job in cluster
- Handle presence and other concerns
- Toggle in app to disable email notifications
- Handles other edge cases such as cooldown period
- Simple html email with simple message

Closes #117
2026-04-09 14:15:34 -07:00
talksik 6fe5af8d8d fix: dock app badge shows incorrect number
Closes #122

We now only show number based on open streams
2026-04-09 12:31:14 -07:00
talksik d024751135 fix: avoid beep sound on text messages when autoplay disabled 2026-04-09 12:21:13 -07:00
talksik e9af91e0c3 fix: maintain consistent sort of stream members 2026-04-09 12:19:17 -07:00
talksik bea33d29d3 use green border for presence avatar 2026-04-09 12:17:55 -07:00
Arjun Patel 3d8fa79657 add real-time infrastructure (#137)
* setup infra for pusher service

* setup client sdk for pusher service

* fix: ping parse failure

* fix: send pong back to client

avoid disconnections every 2.5 minutes

* increase replicas

* feat: show presence and compose indicator
2026-04-09 12:08:18 -07:00
talksik ce368c9e6e fix: broken screen clip playback progress
It wasn't progressing, likely because the metadata for a screen
recording was not read by the video html element. So we fallback to our
own duration metadata.

Closes #135
2026-04-09 11:00:55 -07:00
talksik 47b0d320f6 chore: bump version 2026-04-09 09:50:10 -07:00
talksik 0ab2a58f10 refactor: unused import 2026-04-09 08:42:29 -07:00
talksik 9cda5a3aa3 fix: two components rendering with same key
This is due to using binding.description as the key in Keybindings
overlay
2026-04-09 08:42:05 -07:00
talksik 3995777965 feat: toggle video / audio with keybinding 2026-04-09 08:40:27 -07:00
talksik d88d28a94e refactor: unused import 2026-04-09 08:31:23 -07:00
talksik 070684deb5 show overlay during screen recording 2026-04-09 08:31:23 -07:00
talksik f91068a4c1 fix: freezes during longer screen recordings
Closes #131

Fundamentally, we cannot afford to composite webcam and screen on the
client. We should create two streams and then combine them with ffmpeg
processing in the backend / orion.
2026-04-09 08:31:23 -07:00
talksik 5fadc6c8fe chore: bump version 2026-04-08 17:34:12 -07:00
talksik aaa0b1891f fix: focus lost when transitioning from immersive to markdown 2026-04-08 17:32:47 -07:00
talksik fbce997180 feat: position reactions in better spot
This prevents overlap with captions. It also introduces the laughing
emoji.
2026-04-08 17:25:50 -07:00
Arjun Patel be7a42c483 feat: send screen recordings (#128)
* first pass implementation

* remove screenrecord shortcut hint

* refactor

* fix: prevent mirror review of screen recording

* feat: allow recording with webcam overlay

* fix: review screen recording without clipped content

* tidy keyboard hints consistent position

* refactor: re-use one component for screen picker

huddles and screen clips use same picker component now. We had to make
sure that tailwind works for both of them.

* fix: improve visibility of keyboard hints

During compose video or screen recording, the keyboard hints were
invisible if the content was super bright.
2026-04-08 17:14:39 -07:00
talksik bebd814c46 chore: bump version 2026-04-08 11:25:21 -07:00
talksik 382f195847 fix: autoplay sound overlapping with main window
This created some distortion because while we were hiding the autoplay
window, this would not stop it's media. It's better to stop the media
completely
2026-04-08 11:06:49 -07:00
talksik 5f80bd15d5 feat: allow reactions to particles in stream
Closes #111
2026-04-08 10:14:47 -07:00
talksik ac7336592a feat: support markdown formatting and rendering
Resolves #110
Goes the extra mile and supports markdown for sending code and formatted
text, almost document-like. It feels more like GitHub's comments and
text areas.
2026-04-08 09:38:21 -07:00
talksik bcd0fa37ef fix: autoplay window not showing on all desktops
Closes #108
2026-04-08 09:10:05 -07:00
talksik ead17ba45f remove skip controls with stream playback
Closes #125
2026-04-08 09:04:31 -07:00
talksik 4fc5ecb6fb perf: improve react memo performance 2026-04-08 08:59:52 -07:00
talksik 8cbc04fff7 fix: list of streams glitching to top
This was due to real-time changes to items leading to scrolling to top.

Closes #123
2026-04-08 08:56:12 -07:00
talksik 7e11d66dc2 enhance keyboard experience
Now we have network root and stream-view defining their own hints
instead of trying to re-use one component. And we also introduce an
overlay for additional commands for people.
2026-04-08 08:40:07 -07:00
talksik f95ccf134d remove grid view for streams
- Reduces surface area
- Reduces options for people (less cognitive load)
- One way to use the product
- List view works better for keyboard shortcuts
- List view has basics such as recency
2026-04-08 08:21:54 -07:00
talksik d8b4c488b5 fix: grid view context menu not triggering
Right click on cards was not showing context menu.
2026-04-08 08:11:51 -07:00
talksik 97437c7089 feat: show transcript preview in streams list 2026-04-08 08:11:21 -07:00
talksik 4a1ded9655 chore: bump version 2026-04-07 16:49:09 -07:00
talksik 9312f095b2 feat: seek media particles and speed playback
Closes #79
Closes #80
2026-04-07 16:48:40 -07:00
talksik 695adddaff feat: hold to pause stream playback 2026-04-07 16:32:51 -07:00
talksik 45fa4cb0e5 fix: close stream view on huddle
Closes #107
2026-04-07 16:24:19 -07:00
talksik 9c36a8f94d fix: close autoplay window on entering stream
Closes #114
2026-04-07 16:24:02 -07:00
talksik cd0d817cf7 fix: prevent skipping particles in stream after creation
Closes #112
2026-04-07 16:18:50 -07:00
Arjun Patel cc190ba85f feat: triage streams with open, close (#121)
* refactor: use interfaces for function params

* cleanup message retention code

* support open / closed streams

- Tabs for viewing separately
- Context menu to close / open streams
- Update stream particle status field

* ui tweak

* show stream state in stream-view

* ui tweaks

* cleanup message retention from orion api
2026-04-07 16:11:13 -07:00
talksik 4a9cb7fc58 fix: packaged app doesn't resolve multiple windows
The path that main.ts was reading was incorrect. This change fixes the
issue.
2026-04-01 11:51:59 -07:00
talksik c5525bb980 reduce footprint of closing indicator 2026-04-01 11:43:33 -07:00
talksik 1e3d946037 bump version 2026-04-01 11:30:01 -07:00
talksik e117f58dbe tidy bottom section of stream-view 2026-04-01 11:28:54 -07:00
talksik 7c77d02ab5 feat: show human playback markers on timeline
Resolves #105
2026-04-01 11:02:48 -07:00
Arjun Patel 4d1ad717ad support huddles (#106)
* add token endpoint for livekit

* fix: invalid type passed to hook

* fix: inject livekit env variables for orion

* fix: show controls indicator above stream # shortcut

* return livekit server url from api

* simple huddle implementation with streams

* set human name in livekit room context

* simplify deployment tooling

* join huddle with audio automatically

* feat: show when there is an active huddle

This introduces a webhook which listens to events from livekit and
updates our firestore stream particle. It keeps the client simple,
reacting to changes to firestore docs.

* use headphones icon for huddles

* fix: screenshare not working in electron

The default VideoConference component from livekit doesn't support
screenshare in electron. This attempts to compose our own layout with
livekit components ourselves and introduces our own flow for
screenshare.
2026-04-01 10:32:15 -07:00
talksik 47e2a9baa4 fix: audio bars overlap with bottom controls 2026-03-31 15:08:31 -07:00
Arjun Patel 3de3ca7cef setup electron multi-window architecture (#102)
* prototype with two windows

* use deps in separate window render process

* enhance autoplay into desktop overlay

* fix: autoplay window not applying tailwind styles
2026-03-31 15:05:59 -07:00
talksik 7f490b4596 add script for invalidating release.json 2026-03-30 14:13:12 -07:00
talksik c54a75c3c5 chore: bump version 2026-03-30 14:02:06 -07:00
talksik ffb4ae6c5a cleanup unused import 2026-03-30 14:01:49 -07:00
talksik 7a8b31dbcb cleanup unused import 2026-03-30 13:57:22 -07:00
talksik a5e1ea1acf feat: show unseen count in macos dock
Resolves #93
2026-03-30 13:53:50 -07:00
talksik 65ea901626 fix: use actual particle child particle creation time
This was causing issues in which the last_child_created_at field for
stream particles was slightly after the actual particle's creation time,
and so comparing whether it's unseen merely via the playback markers
would be inaccurate.
2026-03-30 13:52:40 -07:00
talksik d10de739b3 show indicator of stream expiring soon 2026-03-30 13:33:02 -07:00
talksik d56541bd13 feat: allow network admin to control ephemerality setting
Resolves #92
2026-03-30 13:27:27 -07:00
talksik 7f93aa9db3 reduce blur for thumbnails 2026-03-30 12:53:35 -07:00
talksik 086cf4f1ee keyboard-driven navigation
Resolves #66
2026-03-30 12:51:15 -07:00
talksik 14285575e4 default to 4 / 3 aspect ratio for videos
This also sets the default window size to adhere to the default aspect
ratio
2026-03-30 12:35:48 -07:00
talksik 9f6633bf1f feat: add grid view for streams 2026-03-30 12:08:16 -07:00
talksik 76c538dca2 enhance text compose experience 2026-03-30 11:34:53 -07:00
talksik c797d7253e improve attachments experience for media particles 2026-03-30 11:27:33 -07:00
Arjun Patel d6280439d0 feat: support file and image attachments (#98)
* upload & view attachments to particles

* allow download of attachments
2026-03-30 10:31:24 -07:00
talksik 3564055c29 update response status 2026-03-29 12:23:03 -07:00
talksik 21062a7786 tail migration logs 2026-03-29 11:57:50 -07:00
talksik 43c0a217c6 deploy production environment 2026-03-29 11:44:07 -07:00
talksik 48c73893b3 remove unused env var 2026-03-29 11:02:51 -07:00
talksik 7042b2d575 cleanup workaround for neon db
We are now using the llink database within our gcp datbase instances, so
likely won't be needing this workaround
2026-03-29 10:18:34 -07:00
talksik 5eaa51ffcc migrate to llink database within gcp 2026-03-29 10:17:17 -07:00
talksik 3d71dbaed0 chore: send admin email on adding waitlist entry 2026-03-29 09:43:21 -07:00
talksik be966d78a8 tests: remove unnecessary service test 2026-03-29 09:09:01 -07:00
talksik acac407fd0 feat(orion): add rest api for waitlist 2026-03-29 09:04:15 -07:00
talksik c471c28bb0 chore: improve release script 2026-03-25 16:15:25 -07:00
talksik bf1a9257ff chore: bump version 2026-03-25 16:12:10 -07:00
talksik 8cb9eb55c7 chore: cleanup network selector ui 2026-03-25 16:08:55 -07:00
talksik f3ddceecfc fix: persist auto-play setting 2026-03-25 16:03:14 -07:00
talksik 60daec38ff fix: allow text selection and copy
This was conflicting with the left and right tap areas for navigating a
stream.
2026-03-25 15:56:48 -07:00
talksik 739bb0cf80 fix: fullscreen button not working 2026-03-25 15:54:42 -07:00
talksik d525d6a588 fix: window title inaccurate 2026-03-25 15:54:05 -07:00
talksik 0ab2ec2bb8 fix: stale time ago in streams list 2026-03-25 15:53:35 -07:00
talksik d817e637c0 fix: janky captions experience 2026-03-25 15:41:25 -07:00
talksik 3c1d9cd4a7 feat: improved layout and captions 2026-03-25 15:05:38 -07:00
talksik 986a389606 feat: generate transcript and event-driven particle processing
This generates the transcript and shows the caption experience on the
client side for media particles. It also simplifies other side effects
that we must perform such as updating the `last_child_created_at` field
for stream and container particles.
2026-03-25 14:43:47 -07:00
talksik 92bbaa11b3 fix: neon db errors for prepared statements 2026-03-25 14:42:01 -07:00
talksik 0111ea28af refactor!: use consistent structure for particles
Making the firestore subcollections the same name helps with creating
indices and queries across subcollections of different depths. This also
prevents the need for conditional logic for network root / top-level
particles.
2026-03-25 13:14:08 -07:00
talksik d8a45b22d3 chore: setup boilerplate for particle processor worker
This sets up an example of listening to firestore data and reacting. It
also sets up our first worker in the kubernetes cluster.
2026-03-25 09:52:23 -07:00
talksik 86bdc78852 chore: bump version 2026-03-24 19:58:34 -07:00
Arjun Patel 3d52fa6ccc feat: create network, and manage members, invitations (#74) 2026-03-24 19:50:06 -07:00
Arjun Patel bf0147d542 refactor: reference human id instead of email (#73)
* refactor: update api and client to reference humanIds

* fix: prevent deletion of network member

This may cause various side effects if there is data in other services
which reference this member
2026-03-24 19:48:02 -07:00
talksik 3bf3f16be7 fix: auto-select myself as new stream's member 2026-03-24 11:29:14 -07:00
talksik 83190727f8 fix: show last sender avatar in streams list 2026-03-24 11:10:56 -07:00
talksik 6f033e2a73 feat: jump to my newly created particle once created 2026-03-24 10:27:50 -07:00
talksik 1fb31a0d04 chore: show submitting loader during compose 2026-03-24 09:58:50 -07:00
talksik 33a1318958 fix: hide author in "seen by" list 2026-03-24 07:29:13 -07:00
talksik fa91ce0382 fix: prefetch media warning
This is due to the "audio" and "video" preload having limited support in
browsers.
2026-03-24 07:27:23 -07:00
talksik 161c89acf7 feat: resume playback on the clip after we left off 2026-03-24 07:13:08 -07:00
talksik 0f993772c9 chore: bump version 2026-03-21 18:58:19 -07:00
talksik 97b2e7914e feat: filter to ephemeral streams and particles 2026-03-21 18:55:03 -07:00
talksik 356a166c7e chore: remove mic icon from autoplay overlay 2026-03-21 18:47:07 -07:00
talksik e61f838a98 feat: allow toggling eavesdropping 2026-03-21 18:34:41 -07:00
talksik 59ffa7ad6e feat: preload adjacent media in streams 2026-03-21 18:24:03 -07:00
talksik f6ee5e998a feat: play sound effect on new text particle 2026-03-21 18:06:58 -07:00
talksik 4a4d213eb6 feat: autoplay inbound clips 2026-03-21 17:59:41 -07:00
talksik 18f587864f fix: prevent resize of window
Users fiddling with window and complaining.
2026-03-21 16:58:44 -07:00
talksik 8168b7d8f5 fix: prevent duplicate artifact creation 2026-03-21 16:38:37 -07:00
talksik e488cc79d0 fix: escape not working during stream configure 2026-03-21 15:39:26 -07:00
talksik 35f24cb0ef chore: integrate linter for react hooks 2026-03-21 15:31:29 -07:00
talksik 3eea235ab1 feat: use declarative callbacks for data changes 2026-03-21 15:31:05 -07:00
talksik e96f5cbe8a fix: playback restarting from start of stream
This does a major refactor to using particleId for state of playback
instead of index, since index would change if the particle children data
does partial hydrations and a particle's index changes. Also it
abstracts much of the playback details from the view component.
2026-03-21 15:11:26 -07:00
talksik 79d8d0eac0 fix: use more condensed width for text content container 2026-03-21 13:45:40 -07:00
talksik a87b1d86a1 chore: always show top bar for necessary metadata 2026-03-21 13:40:48 -07:00
talksik 80afa58ac9 fix: increase size of compose container for long messages 2026-03-21 12:55:08 -07:00
talksik 8ee8aba5a2 fix: open link on clicking preview card 2026-03-21 12:48:45 -07:00
talksik d6b7ecc8fd feat: support sending and preview links 2026-03-21 12:46:23 -07:00
talksik 6bf2f2217a fix: top bar preventing click events on page indicator 2026-03-21 12:08:45 -07:00
talksik cbace421d2 fix: long subheadline overflowing listview 2026-03-21 12:07:25 -07:00
talksik 83c7b2ad53 refactor: structure container concerns better
The consumer of topbar should determine positioning
2026-03-21 11:49:18 -07:00
talksik 44f4dc772a fix: don't show seen by authed user 2026-03-21 11:46:26 -07:00
talksik 4edb6c853a adjust window size and aspect ratio 2026-03-21 11:40:53 -07:00
talksik cba24c5d95 enhance stream view 2026-03-21 11:39:43 -07:00
talksik 2262bf4f3b refactor: organize components flatter 2026-03-21 11:20:49 -07:00
talksik e1375eddfa remove top bar from stream-view 2026-03-21 11:18:13 -07:00
talksik 9ca12922b4 docs 2026-03-19 18:52:21 -07:00
talksik 67bdcddd33 chore: bump version 2026-03-19 18:36:23 -07:00
talksik f89a38ba7d fix: firestore not working in release build 2026-03-19 18:36:06 -07:00
talksik a1b1a46875 build: setup tooling for publish to gcs 2026-03-19 18:12:14 -07:00
talksik 2915a30fe0 fix: prevent opening devtools in production build 2026-03-19 17:20:23 -07:00
talksik e1295b434b fix: optional accessor 2026-03-19 17:18:52 -07:00
talksik 01c5d9be0c build: config electron package & build 2026-03-19 17:07:43 -07:00
Arjun Patel 4b66d8e185 feat: initial conversational flow (#37)
* chore: only set visibility for container particles

* create reusable controls indicator for reply or new

* compress the size of top bar

* refactor: restructure state, routing, and more

* introduce stream compose flow

* feat: compose new stream full flow

* implement stream player

* fix: prevent redirect for signed object urls

* fix: implement stream playback cleaner structure

* refactor: layout file name

* feat: show stream name in breadcrumbs

* chore: tweak padding

* chore: adjust position of audio bars

* feat: show latest particle preview in stream list

* fix: remove console log

* refactor: reorder classes

* fix: avoid passing in updated_at to firestore particle

* refactor: extract properties for container particles to flat fields in firestore

* make the stream previews look alive

* feat: show audio bars during audio clip playback

* feat: order streams by last child creation

* feat: playback where I left off

* chore: remove unused store

* fix: recording mode not using shared state

* chore: clean unused variable

* remove unused imports

* fix: improve controls indicator immersion

* feat: show playback progress in bar & auto-play text

* feat: auto-exit stream on playback completion

* fix: jittery media playback progress

* fix: navigate during state change is invalid with react router

* fix: buggy exit progress when changing clips

* feat: add app icon

* update package.json info

* feat: only show streams visible to me

* feat: show seen indicator on particles

* fix: prevent unnecessary effects

* fix: play new particle after playback is ended

* use contols indicator for exit timer
2026-03-19 16:29:40 -07:00
talksik 990137b829 feat: add current path
This resolves #20
2026-03-18 08:14:32 -07:00
talksik ff785f409f fix: handle pre-flight requests 2026-03-18 07:56:08 -07:00
Arjun Patel 51857bed63 chore: integrate firestore for particles (#32)
* plumb for firestore

* chore: cleanup orion api to only include essentials

* setup boilerplate for data and rendering

This includes zod types creation for API response validation, and
exploration of path based resolution of rendering particles.

* chore: structure container particles for rendering children

* wire firestore crud for particles

* integrate visibility to particles

* docs: explain particle view resolver
2026-03-17 19:52:31 -07:00
512 changed files with 53927 additions and 6471 deletions
+85
View File
@@ -0,0 +1,85 @@
name: Deploy (reusable)
on:
workflow_call:
inputs:
module:
description: "Skaffold module: orion, worker, pusher, emailnotifierjob, memberreconciler, or migrations"
required: true
type: string
environment:
description: "Target environment: dev or prod"
required: true
type: string
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
defaults:
run:
working-directory: go
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go/go.mod
cache-dependency-path: go/go.sum
- name: Resolve environment settings
id: env
run: |
case "${{ inputs.environment }}" in
dev)
echo "project=flowy-dev-440017" >> "$GITHUB_OUTPUT"
echo "cluster=cluster" >> "$GITHUB_OUTPUT"
;;
prod)
echo "project=flowy-prod-440017" >> "$GITHUB_OUTPUT"
echo "cluster=prod-cluster" >> "$GITHUB_OUTPUT"
;;
*)
echo "Unknown environment: ${{ inputs.environment }}" >&2; exit 1
;;
esac
echo "region=us-west2" >> "$GITHUB_OUTPUT"
- name: Run tests
run: go test ./...
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ inputs.environment == 'prod' && secrets.PROD_GKE_SERVICE_ACCOUNT_KEY || secrets.DEV_GKE_SERVICE_ACCOUNT_KEY }}
- uses: google-github-actions/setup-gcloud@v2
- name: Install gke-gcloud-auth-plugin
run: gcloud components install gke-gcloud-auth-plugin --quiet
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker us-west2-docker.pkg.dev --quiet
- name: Get GKE credentials
run: |
gcloud container clusters get-credentials "${{ steps.env.outputs.cluster }}" \
--region "${{ steps.env.outputs.region }}" \
--project "${{ steps.env.outputs.project }}"
- name: Install skaffold
run: |
curl -fsSLo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
sudo install skaffold /usr/local/bin/
skaffold version
- name: Deploy
env:
SKAFFOLD_DEFAULT_REPO: us-west2-docker.pkg.dev/${{ steps.env.outputs.project }}/deployments
run: |
if [ "${{ inputs.module }}" = "migrations" ]; then
kubectl delete job migrations --ignore-not-found
skaffold run -p migrations
else
skaffold run -p ${{ inputs.environment }} -m ${{ inputs.module }}
fi
+80
View File
@@ -0,0 +1,80 @@
name: Build Windows
on:
workflow_dispatch:
permissions:
# Azure Trusted Signing uses OIDC federated credentials from GitHub.
id-token: write
contents: read
jobs:
build:
runs-on: windows-latest
defaults:
run:
working-directory: js/desktop
env:
APP_ENV: prod
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
shell: pwsh
working-directory: .
- uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
cache-dependency-path: js/desktop/yarn.lock
- name: Install dependencies
run: yarn install --frozen-lockfile --network-timeout 600000
- name: Azure login (OIDC)
uses: azure/login@v2
with:
tenant-id: ${{ vars.AZURE_TENANT_ID }}
client-id: ${{ vars.AZURE_CLIENT_ID }}
allow-no-subscriptions: true
- name: Install Trusted Signing client dlib
shell: pwsh
working-directory: .
run: |
nuget install Microsoft.Trusted.Signing.Client -Version 1.0.60 -OutputDirectory $env:RUNNER_TEMP\trusted-signing -ExcludeVersion
$dlib = Join-Path $env:RUNNER_TEMP "trusted-signing\Microsoft.Trusted.Signing.Client\bin\x64\Azure.CodeSigning.Dlib.dll"
if (-not (Test-Path $dlib)) { throw "Dlib not found at $dlib" }
"AZURE_DLIB_PATH=$dlib" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"AZURE_METADATA_JSON_PATH=$env:GITHUB_WORKSPACE\js\desktop\signing-metadata.json" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Locate signtool.exe
shell: pwsh
working-directory: .
run: |
$signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending |
Select-Object -First 1
if (-not $signtool) { throw "signtool.exe not found in Windows Kits" }
"SIGNTOOL_PATH=$($signtool.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.PROD_GKE_SERVICE_ACCOUNT_KEY }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v2
- name: Publish signed installer (x64)
run: yarn publish:win
- name: Invalidate RELEASES cache
shell: pwsh
working-directory: .
run: gsutil setmeta -h "Cache-Control:no-cache, no-store, must-revalidate" gs://flowy-releases/llink/win32/x64/RELEASES
@@ -0,0 +1,22 @@
name: Deploy emailnotifierjob
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-emailnotifierjob-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: emailnotifierjob
environment: ${{ inputs.environment }}
secrets: inherit
+72
View File
@@ -0,0 +1,72 @@
name: Deploy llink-web
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-llink-web-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
defaults:
run:
working-directory: js/desktop
steps:
- uses: actions/checkout@v4
- name: Resolve environment settings
id: env
run: |
case "${{ inputs.environment }}" in
dev)
echo "project=flowy-dev-440017" >> "$GITHUB_OUTPUT"
echo "cluster=cluster" >> "$GITHUB_OUTPUT"
;;
prod)
echo "project=flowy-prod-440017" >> "$GITHUB_OUTPUT"
echo "cluster=prod-cluster" >> "$GITHUB_OUTPUT"
;;
*)
echo "Unknown environment: ${{ inputs.environment }}" >&2; exit 1
;;
esac
echo "region=us-west2" >> "$GITHUB_OUTPUT"
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ inputs.environment == 'prod' && secrets.PROD_GKE_SERVICE_ACCOUNT_KEY || secrets.DEV_GKE_SERVICE_ACCOUNT_KEY }}
- uses: google-github-actions/setup-gcloud@v2
- name: Install gke-gcloud-auth-plugin
run: gcloud components install gke-gcloud-auth-plugin --quiet
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker us-west2-docker.pkg.dev --quiet
- name: Get GKE credentials
run: |
gcloud container clusters get-credentials "${{ steps.env.outputs.cluster }}" \
--region "${{ steps.env.outputs.region }}" \
--project "${{ steps.env.outputs.project }}"
- name: Install skaffold
run: |
curl -fsSLo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
sudo install skaffold /usr/local/bin/
skaffold version
- name: Deploy
env:
SKAFFOLD_DEFAULT_REPO: us-west2-docker.pkg.dev/${{ steps.env.outputs.project }}/deployments
run: skaffold run -p ${{ inputs.environment }}
@@ -0,0 +1,22 @@
name: Deploy memberreconciler
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-memberreconciler-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: memberreconciler
environment: ${{ inputs.environment }}
secrets: inherit
+22
View File
@@ -0,0 +1,22 @@
name: Deploy migrations
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-migrations-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: migrations
environment: ${{ inputs.environment }}
secrets: inherit
+22
View File
@@ -0,0 +1,22 @@
name: Deploy orion
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-orion-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: orion
environment: ${{ inputs.environment }}
secrets: inherit
@@ -0,0 +1,22 @@
name: Deploy worker (particleprocessorworker)
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-worker-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: particleprocessor
environment: ${{ inputs.environment }}
secrets: inherit
+22
View File
@@ -0,0 +1,22 @@
name: Deploy pusher
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-pusher-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: pusher
environment: ${{ inputs.environment }}
secrets: inherit
@@ -0,0 +1,42 @@
name: PR Quality Gate (Desktop)
on:
pull_request:
paths:
- "js/desktop/**"
branches: [ main ]
types: [ opened, synchronize, reopened ]
jobs:
verify:
name: Lint & format check (desktop)
runs-on: ubuntu-latest
defaults:
run:
working-directory: js/desktop
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: yarn install --immutable
- name: Typecheck
run: yarn typecheck
- name: Run Code Linter
run: yarn lint
- name: Run Format Check
run: yarn format:check
@@ -0,0 +1,36 @@
name: Golang format and test
on:
pull_request:
paths:
- "go/**"
branches: [ main ]
types: [ opened, synchronize, reopened ]
jobs:
verify:
name: Verify
runs-on: ubuntu-latest
defaults:
run:
working-directory: go
steps:
- name: Check out repository code
uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go/go.mod
cache-dependency-path: go/go.sum
- name: Verify gofmt
run: |
if [ -n "$(gofmt -l .)" ]; then
echo "The following files are not formatted correctly:"
gofmt -l .
exit 1
fi
- name: Run tests
run: go test ./...
@@ -0,0 +1,37 @@
name: PR Quality Gate (Mobile)
on:
pull_request:
paths:
- "js/mobile/**"
branches: [ main ]
types: [ opened, synchronize, reopened ]
jobs:
verify:
name: Lint & format check (mobile)
runs-on: ubuntu-latest
defaults:
run:
working-directory: js/mobile
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: yarn install --immutable
- name: Run Code Linter
run: yarn lint
- name: Run Format Check
run: yarn format:check
+1
View File
@@ -4,3 +4,4 @@ build/
.cache/ .cache/
compile_commands.json compile_commands.json
CMakeLists.txt.user CMakeLists.txt.user
tags
+10 -7
View File
@@ -1,17 +1,23 @@
# Project Rules # Project Rules
## Architecture ## Architecture
- Electron typescript/react app is in `js/` folder - Electron typescript/react app is in `js/desktop` folder
- Orion is the api server which lives in the `go/` folder - Mobile react native expo app is in `js/mobile`
- Orion is the api server which lives in the `go/` folder along with other workers, jobs, services
- `cpp/` points to our prototype of a C++ Qt widgets client - `cpp/` points to our prototype of a C++ Qt widgets client
Whenever implementing anything, make sure to take into account best practices without over-engineering. Whenever implementing anything, make sure to take into account best practices without over-engineering.
## Electron App ## Electron App
### Quality ### Quality
We care about overall architectural quality and keeping consistent patterns according to best practices. We care about overall architectural quality and keeping consistent patterns according to best practices. Feel free to move stuff around to make things more elegant, instead of bolting on features.
As an example, we have as high of a bar as a product team like Linear, which outputs high quality software. Let's avoid slop at all costs. Another tradeoff we make is simpler, maintainable code over clever behavior.
As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs.
### Comments
Don't add non-essential comments everywhere. Clean them up if so.
### Package Manager ### Package Manager
- Use **yarn** (not npm) for all dependency management - Use **yarn** (not npm) for all dependency management
@@ -23,6 +29,3 @@ Whenever possible, we should use the design system components. If we need to add
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through. When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
IF you find that the API is poorly designed, please suggest changes to improve the client experience. IF you find that the API is poorly designed, please suggest changes to improve the client experience.
## Verification
Check using `yarn compile` which lives in the package.json as a script.
+24
View File
@@ -0,0 +1,24 @@
# Deployment Runbooks
## Desktop + web
1. Create a new branch
2. Bump version in `package.json`
3. Create a pull request and merge
4. Manually trigger github action to deploy llink-web, windows desktop
5. Deploy macOS desktop app from a local mac computer
6. Create a pull request in the `research-site` repo to update the download links
## Mobile
1. Create a new branch
2. Bump version in `package.json`
3. Create a pull request and merge
4. Switch to main and pull latest locally (on any machine with expo installed)
5. Manually run `yarn publish:ios`
## Golang services
1. Create a new branch
2. Make changes
3. Create a pull request
4. Deploy the appropriate github action for the service you're touching (input the branch name and dev)
5. Test with the dev deployment
6. Merge, then deploy the corresponding prod service
+20
View File
@@ -0,0 +1,20 @@
# golang two stage build
FROM golang:1.25 AS first-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
WORKDIR /app/cmd/emailnotifierjob
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
RUN ls
FROM alpine:latest AS second-stage
WORKDIR /app
COPY --from=first-stage /app/cmd/emailnotifierjob .
RUN echo "copied over binary to production stage"
CMD ["./main"]
+20
View File
@@ -0,0 +1,20 @@
# golang two stage build
FROM golang:1.25 AS first-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
WORKDIR /app/cmd/memberreconciler
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
RUN ls
FROM alpine:latest AS second-stage
WORKDIR /app
COPY --from=first-stage /app/cmd/memberreconciler .
RUN echo "copied over binary to production stage"
CMD ["./main"]
+22
View File
@@ -0,0 +1,22 @@
# golang two stage build
FROM golang:1.25 AS first-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
WORKDIR /app/cmd/particleprocessorworker
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
RUN ls
FROM alpine:latest AS second-stage
RUN apk add --no-cache ffmpeg ca-certificates
WORKDIR /app
COPY --from=first-stage /app/cmd/particleprocessorworker .
RUN echo "copied over binary to production stage"
CMD ["./main"]
+22
View File
@@ -0,0 +1,22 @@
# golang two stage build
FROM golang:1.25 AS first-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
WORKDIR /app/cmd/pusherservice
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
RUN ls
FROM alpine:latest AS second-stage
# for health check
RUN apk --no-cache add curl
WORKDIR /app
COPY --from=first-stage /app/cmd/pusherservice .
RUN echo "copied over binary to production stage"
CMD ["./main"]
+22
View File
@@ -0,0 +1,22 @@
# golang two stage build
FROM golang:1.25 AS first-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
WORKDIR /app/cmd/transcodebackfill
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
RUN ls
FROM alpine:latest AS second-stage
RUN apk add --no-cache ffmpeg ca-certificates
WORKDIR /app
COPY --from=first-stage /app/cmd/transcodebackfill .
RUN echo "copied over binary to production stage"
CMD ["./main"]
+32 -24
View File
@@ -8,19 +8,13 @@ generate:
test: test:
go test -json ./... | docker run -i ghcr.io/gotesttools/gotestfmt:latest go test -json ./... | docker run -i ghcr.io/gotesttools/gotestfmt:latest
.PHONY: migrate-dev-db
migrate-dev-db:
./migrate_dev.sh
# .PHONY: migrate-prod-db
# migrate-prod-db:
# ./migrate_prod.sh
# ex: make create-migration ARG="create_users_table" # ex: make create-migration ARG="create_users_table"
create-migration: create-migration:
echo "Creating migration with name $(ARG)" echo "Creating migration with name $(ARG)"
docker run -v ./migrations:/migrations --network host migrate/migrate create -ext sql -seq -dir /migrations $(ARG) docker run -v ./migrations:/migrations --network host migrate/migrate create -ext sql -seq -dir /migrations $(ARG)
# ---- Database access ----
dev-database: dev-database:
kubectl run postgres-client \ kubectl run postgres-client \
--rm -it --image=postgres:latest \ --rm -it --image=postgres:latest \
@@ -28,23 +22,37 @@ dev-database:
--context dev \ --context dev \
--command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL" --command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
# prod-database: prod-database:
# kubectl run postgres-client \ kubectl run postgres-client \
# --rm -it --image=postgres:latest \ --rm -it --image=postgres:latest \
# --env="LLINK_POSTGRES_CONNECTION_URL=$$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context prod | base64 --decode)" \ --env="LLINK_POSTGRES_CONNECTION_URL=$$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context prod | base64 --decode)" \
# --context prod \ --context prod \
# --command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL" --command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
# ---- Migrations ----
# NOTE: if you run into issues with 'dirty' migrations: https://github.com/golang-migrate/migrate/issues/282#issuecomment-530743258
DEV_REPO := us-west2-docker.pkg.dev/flowy-dev-440017/deployments
PROD_REPO := us-west2-docker.pkg.dev/flowy-prod-440017/deployments
.PHONY: migrate-dev
migrate-dev:
kubectl delete job migrations --ignore-not-found --context=dev
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p migrations --kube-context dev
.PHONY: migrate-prod
migrate-prod:
kubectl delete job migrations --ignore-not-found --context=prod
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod
# ---- Deploy ----
# Use MODULE=orion or MODULE=particleprocessor or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.:
# make deploy-dev MODULE=orion
.PHONY: deploy-dev .PHONY: deploy-dev
deploy-dev: generate test deploy-dev: generate test
./deploy_dev.sh SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p dev $(if $(MODULE),-m $(MODULE)) --kube-context dev --port-forward --tail
# .PHONY: release .PHONY: deploy-prod
# deploy-prod: generate test deploy-prod: generate test
# # Check for any changes (both staged and unstaged) SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p prod $(if $(MODULE),-m $(MODULE)) --kube-context prod --port-forward --tail
# @if [ -n "$(git status --porcelain)" ]; then \
# echo "Error: You have uncommitted changes in your Git repository."; \
# exit 1; \
# fi
# ./deploy_prod.sh
#
+34
View File
@@ -0,0 +1,34 @@
# Orion
API server, jobs, and worker services for llink.
## Deploy
```bash
# Deploy all services
make deploy-dev
make deploy-prod
# Deploy a single service
make deploy-dev MODULE=orion
make deploy-dev MODULE=particleprocessor
make deploy-dev MODULE=pusher
make deploy-dev MODULE=emailnotifierjob
```
## Migrations
```bash
make migrate-dev
make migrate-prod
# Create a new migration
make create-migration ARG="create_users_table"
```
## Database Access
```bash
make dev-database
make prod-database
```
+99
View File
@@ -0,0 +1,99 @@
version: '3'
vars:
DEV_REPO: us-west2-docker.pkg.dev/flowy-dev-440017/deployments
PROD_REPO: us-west2-docker.pkg.dev/flowy-prod-440017/deployments
tasks:
generate:
desc: Generate protobuf code and run go generate
cmds:
- task: genproto
- go generate ./...
format:
desc: Format go files
cmds:
- go fmt ./...
genproto:
desc: Generate Go code from .proto files via docker
vars:
PROTO_DIR: '{{.PROTO_DIR | default "./protocol"}}'
OUT_DIR: '{{.OUT_DIR | default "./genproto"}}'
cmds:
- 'echo "Proto directory: {{.PROTO_DIR}}"'
- 'echo "Output directory: {{.OUT_DIR}}"'
- rm -rf {{.OUT_DIR}}
- mkdir -p {{.OUT_DIR}}
- |
docker run --rm \
-v "{{.PROTO_DIR}}/":/protocol \
-v "{{.OUT_DIR}}":/genproto \
--workdir / \
talksik/golang-protoc:latest \
sh -c 'protoc --proto_path=./protocol \
--go_out=./genproto \
--go_opt=paths=source_relative \
--go-grpc_out=./genproto \
--go-grpc_opt=paths=source_relative \
$(find ./protocol -name "*.proto")'
test:
desc: Run tests with pretty output via gotestfmt
cmds:
- go test -json ./... | docker run -i ghcr.io/gotesttools/gotestfmt:latest
create-migration:
desc: 'Create new migration (usage: task create-migration ARG=create_users_table)'
cmds:
- 'echo "Creating migration with name {{.ARG}}"'
- docker run -v ./migrations:/migrations --network host migrate/migrate create -ext sql -seq -dir /migrations {{.ARG}}
dev-database:
desc: Open psql shell against the dev database
interactive: true
cmds:
- |
kubectl run postgres-client \
--rm -it --image=postgres:latest \
--env="LLINK_POSTGRES_CONNECTION_URL=$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context dev | base64 --decode)" \
--context dev \
--command -- /bin/bash -c "psql \$LLINK_POSTGRES_CONNECTION_URL"
prod-database:
desc: Open psql shell against the prod database
interactive: true
cmds:
- |
kubectl run postgres-client \
--rm -it --image=postgres:latest \
--env="LLINK_POSTGRES_CONNECTION_URL=$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context prod | base64 --decode)" \
--context prod \
--command -- /bin/bash -c "psql \$LLINK_POSTGRES_CONNECTION_URL"
migrate-dev:
desc: Run migrations against dev cluster
cmds:
- kubectl delete job migrations --ignore-not-found --context=dev
- SKAFFOLD_DEFAULT_REPO={{.DEV_REPO}} skaffold run -p migrations --kube-context dev --tail
migrate-prod:
desc: Run migrations against prod cluster
cmds:
- kubectl delete job migrations --ignore-not-found --context=prod
- SKAFFOLD_DEFAULT_REPO={{.PROD_REPO}} skaffold run -p migrations --kube-context prod --tail
deploy-dev:
desc: 'Deploy to dev (optionally MODULE=orion to deploy a single service)'
cmds:
- task: generate
- task: test
- SKAFFOLD_DEFAULT_REPO={{.DEV_REPO}} skaffold run -p dev {{if .MODULE}}-m {{.MODULE}}{{end}} --kube-context dev --port-forward --tail
deploy-prod:
desc: 'Deploy to prod (optionally MODULE=orion to deploy a single service)'
cmds:
- task: generate
- task: test
- SKAFFOLD_DEFAULT_REPO={{.PROD_REPO}} skaffold run -p prod {{if .MODULE}}-m {{.MODULE}}{{end}} --kube-context prod --port-forward --tail
+247
View File
@@ -0,0 +1,247 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pbaero "github.com/flowy-live/llink/genproto/aero"
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils"
)
const (
maxActivityAge = 24 * time.Hour // ignore streams idle longer than this
unreadThreshold = 10 * time.Minute // grace window before a message is "unread"
emailCooldown = 12 * time.Hour // min gap between emails to the same user
)
func main() {
ctx := context.Background()
db.Init()
defer db.Cleanup()
gcpProject := utils.MustGetEnv("GCP_PROJECT")
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
if err != nil {
flog.Error("failed to create Firestore client", "error", err)
os.Exit(1)
}
defer firestoreClient.Close()
aeroAddr := utils.MustGetEnv("AERO_ADDR")
aeroConn, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
flog.Error("failed to connect to aero", "error", err)
os.Exit(1)
}
defer aeroConn.Close()
aeroSvc := pbaero.NewPrimaryClient(aeroConn)
pusherAddr := utils.MustGetEnv("PUSHER_GRPC_ADDR")
pusherConn, err := grpc.NewClient(pusherAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
flog.Error("failed to connect to pusher", "error", err)
os.Exit(1)
}
defer pusherConn.Close()
pusherSvc := pbpusher.NewPusherServiceClient(pusherConn)
humanSvc := human.NewService(db.Pool())
networkSvc := network.NewReader(db.Pool())
flog.Info("starting email notification cycle")
if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil {
flog.Error("notification cycle failed", "error", err)
os.Exit(1)
}
flog.Info("email notification cycle complete")
}
func runNotificationCycle(
ctx context.Context,
fsClient *firestore.Client,
aeroSvc pbaero.PrimaryClient,
pusherSvc pbpusher.PusherServiceClient,
humanSvc human.Service,
networkReader network.Reader,
) error {
now := time.Now()
networks, err := networkReader.ListAll(ctx)
if err != nil {
return fmt.Errorf("listing networks: %w", err)
}
allHumans, err := humanSvc.ListAll(ctx)
if err != nil {
return fmt.Errorf("listing humans: %w", err)
}
humansById := make(map[string]*human.Human, len(allHumans))
for _, h := range allHumans {
humansById[h.ID] = h
}
behindCounts := map[string]int{}
latestActivity := map[string]time.Time{}
allOnline := map[string]bool{}
onlineResp, err := pusherSvc.GetOnlineHumanIds(ctx, &pbpusher.GetOnlineHumanIdsRequest{})
if err != nil {
return fmt.Errorf("failed to get online humans: %w", err)
}
flog.Info("gathered online presence", "onlineCount", len(onlineResp.HumanIds), "humanIds", onlineResp.HumanIds)
for _, id := range onlineResp.HumanIds {
allOnline[id] = true
}
for _, net := range networks {
streams, err := getOpenStreams(ctx, fsClient, net.ID)
if err != nil {
flog.Error("failed to query streams", "networkId", net.ID, "error", err)
continue
}
// net.MemberHumanIds already includes the admin.
for _, stream := range streams {
if stream.LastChildCreatedAt == nil {
continue
}
if now.Sub(*stream.LastChildCreatedAt) > maxActivityAge {
continue
}
if now.Sub(*stream.LastChildCreatedAt) < unreadThreshold {
continue
}
for _, humanId := range network.ResolveVisibility(stream.VisibleTo, net.MemberHumanIds) {
marker, hasMarker := stream.PlaybackMarkers[humanId]
if hasMarker && !marker.Before(*stream.LastChildCreatedAt) {
continue
}
behindCounts[humanId]++
if stream.LastChildCreatedAt.After(latestActivity[humanId]) {
latestActivity[humanId] = *stream.LastChildCreatedAt
}
}
}
}
sentCount := 0
for humanId, count := range behindCounts {
if allOnline[humanId] {
flog.Info("human online...skipping email", "humanId", humanId)
continue
}
h, ok := humansById[humanId]
if !ok {
continue
}
if !h.EmailNotificationsEnabled {
continue
}
// Skip if nothing new since the previous email.
if h.LastEmailNotificationSentAt != nil && !latestActivity[humanId].After(*h.LastEmailNotificationSentAt) {
continue
}
if h.LastEmailNotificationSentAt != nil && now.Sub(*h.LastEmailNotificationSentAt) < emailCooldown {
continue
}
if err := sendNotificationEmail(ctx, aeroSvc, h, count); err != nil {
flog.Error("failed to send email", "humanId", humanId, "error", err)
continue
}
if err := humanSvc.UpdateLastEmailNotificationSentAt(ctx, humanId, now); err != nil {
flog.Error("failed to update last_email_notification_sent_at", "humanId", humanId, "error", err)
}
sentCount++
}
flog.Info("notification cycle summary",
"networks", len(networks),
"humansBehind", len(behindCounts),
"emailsSent", sentCount,
)
return nil
}
func getOpenStreams(ctx context.Context, client *firestore.Client, networkId string) ([]particle.FirestoreStreamParticle, error) {
collPath := fmt.Sprintf("networks/%s/children", networkId)
docs, err := client.Collection(collPath).
Where("type", "==", "stream").
Where("status", "==", "open").
Documents(ctx).
GetAll()
if err != nil {
return nil, err
}
streams := make([]particle.FirestoreStreamParticle, 0, len(docs))
for _, doc := range docs {
var s particle.FirestoreStreamParticle
if err := doc.DataTo(&s); err != nil {
flog.Warn("failed to unmarshal stream particle", "docId", doc.Ref.ID, "error", err)
continue
}
streams = append(streams, s)
}
return streams, nil
}
func sendNotificationEmail(ctx context.Context, aeroSvc pbaero.PrimaryClient, h *human.Human, streamCount int) error {
streamsWord := "stream"
if streamCount != 1 {
streamsWord = "streams"
}
subject := fmt.Sprintf("You have unseen messages in %d %s", streamCount, streamsWord)
html := buildEmailHTML(h.EmailPrefix, streamCount, streamsWord)
_, err := aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: []string{h.Email},
Subject: subject,
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
SimpleHtmlData: &pbaero.SimpleHtmlData{
Html: html,
},
},
})
return err
}
func buildEmailHTML(name string, count int, streamsWord string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a1a; max-width: 480px; margin: 0 auto; padding: 24px;">
<p>Hi %s,</p>
<p>You have unread messages in <strong>%d %s</strong> on Flowy.llink.</p>
<p>Open the app to catch up with your team.</p>
<p style="margin: 24px 0;">
<a href="https://llink.flowy.live" style="display: inline-block; background: #1a1a1a; color: #ffffff; text-decoration: none; padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 500;">Open Flowy.llink</a>
</p>
<p style="color: #666; font-size: 13px; margin-top: 32px;">
Best,<br>Flowy Team <br>Note: disable email notifications from in-app settings.
</p>
</body>
</html>`, name, count, streamsWord)
}
+135
View File
@@ -0,0 +1,135 @@
// memberreconciler reconciles the Firestore membership mirror
// (humans/{humanId}.networks) against the authoritative Postgres
// network_members table. Safe to run on a cron — only humans whose mirrored
// set differs from Postgres are written, so a steady-state run is nearly free.
package main
import (
"context"
"os"
"slices"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"google.golang.org/api/iterator"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/utils"
)
func main() {
ctx := context.Background()
db.Init()
defer db.Cleanup()
gcpProject := utils.MustGetEnv("GCP_PROJECT")
fs, err := firestore.NewClient(ctx, gcpProject)
if err != nil {
flog.Error("failed to create Firestore client", "error", err)
os.Exit(1)
}
defer fs.Close()
humanSvc := human.NewService(db.Pool())
networkSvc := network.NewReader(db.Pool())
started := time.Now()
written, scanned, err := reconcile(ctx, fs, humanSvc, networkSvc)
if err != nil {
flog.Error("reconciliation failed", "error", err, "elapsed", time.Since(started))
os.Exit(1)
}
flog.Info("reconciliation complete",
"humans_scanned", scanned,
"humans_written", written,
"elapsed", time.Since(started),
)
}
func reconcile(
ctx context.Context,
fs *firestore.Client,
humanSvc human.Service,
networkReader network.Reader,
) (written, scanned int, err error) {
humans, err := humanSvc.ListAll(ctx)
if err != nil {
return 0, 0, err
}
memberships, err := networkReader.ListAllMemberships(ctx)
if err != nil {
return 0, 0, err
}
current, err := snapshotMirror(ctx, fs)
if err != nil {
return 0, 0, err
}
bulk := fs.BulkWriter(ctx)
for _, h := range humans {
scanned++
desired := memberships[h.ID]
if desired == nil {
desired = []string{}
}
if sameSet(current[h.ID], desired) {
continue
}
if _, err := bulk.Set(fs.Collection("humans").Doc(h.ID), map[string]any{
"networks": desired,
"updated_at": firestore.ServerTimestamp,
}, firestore.MergeAll); err != nil {
return written, scanned, err
}
written++
}
bulk.End()
return written, scanned, nil
}
// One iterator, N billed reads — returns humanId → mirrored networks.
func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) {
out := map[string][]string{}
iter := fs.Collection("humans").Documents(ctx)
defer iter.Stop()
for {
doc, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return nil, err
}
var data struct {
Networks []string `firestore:"networks"`
}
if err := doc.DataTo(&data); err != nil {
flog.Warn("skipping malformed mirror doc", "id", doc.Ref.ID, "error", err)
continue
}
out[doc.Ref.ID] = data.Networks
}
return out, nil
}
// Set equality (order- and duplicate-insensitive); Firestore array ops don't
// preserve order.
func sameSet(a, b []string) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
ac := slices.Clone(a)
bc := slices.Clone(b)
slices.Sort(ac)
slices.Sort(bc)
ac = slices.Compact(ac)
bc = slices.Compact(bc)
return slices.Equal(ac, bc)
}
+113 -47
View File
@@ -3,83 +3,118 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"strings"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"cloud.google.com/go/storage" "cloud.google.com/go/storage"
firebase "firebase.google.com/go/v4"
"github.com/redis/go-redis/v9"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pbaero "github.com/flowy-live/llink/genproto/aero" pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal" "github.com/flowy-live/llink/internal"
"github.com/flowy-live/llink/internal/auth" "github.com/flowy-live/llink/internal/auth"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/db" "github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/handler" "github.com/flowy-live/llink/internal/handler"
"github.com/flowy-live/llink/internal/human" "github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/human/pushnotify"
"github.com/flowy-live/llink/internal/livekit"
"github.com/flowy-live/llink/internal/livestore"
"github.com/flowy-live/llink/internal/middleware" "github.com/flowy-live/llink/internal/middleware"
"github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle" "github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils" "github.com/flowy-live/llink/internal/utils"
"github.com/redis/go-redis/v9" "github.com/flowy-live/llink/internal/waitlist"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
// FIX: Use separate redis instance. We start with higher number because use this same instance in helios.
REDIS_DATABASE_FOR_AUTH int = 4
) )
func redisForAuth() *redis.Client { func redisForAuth() *redis.Client {
return internal.ConnectAndTestRedis(REDIS_DATABASE_FOR_AUTH) return internal.ConnectAndTestRedis(db.RedisDBAuth)
} }
func main() { func main() {
port := utils.MustGetEnv("PORT") port := utils.MustGetEnv("PORT")
gcsBucket := utils.MustGetEnv("GCS_BUCKET") gcsBucket := utils.MustGetEnv("GCS_BUCKET")
// Initialize database
db.Init() db.Init()
defer db.Cleanup() defer db.Cleanup()
// Initialize Redis for auth
redisClient := redisForAuth() redisClient := redisForAuth()
// Initialize GCS client
ctx := context.Background() ctx := context.Background()
storageClient, err := storage.NewClient(ctx) storageClient, err := storage.NewClient(ctx)
if err != nil { if err != nil {
slog.Error("failed to create GCS client", "error", err) flog.Error("failed to create GCS client", "error", err)
os.Exit(1) os.Exit(1)
} }
defer storageClient.Close() defer storageClient.Close()
aeroAddr := utils.MustGetEnv("AERO_ADDR") aeroAddr := utils.MustGetEnv("AERO_ADDR")
if aeroAddr == "" { if aeroAddr == "" {
slog.Error("must provide AERO_ADDR") flog.Error("must provide AERO_ADDR")
os.Exit(1) os.Exit(1)
} }
aeroServer, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) aeroServer, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { if err != nil {
slog.Error("connection to aero server invalid", "error", err) flog.Error("connection to aero server invalid", "error", err)
os.Exit(1) os.Exit(1)
} }
defer aeroServer.Close() defer aeroServer.Close()
aeroSvc := pbaero.NewPrimaryClient(aeroServer) aeroSvc := pbaero.NewPrimaryClient(aeroServer)
// Initialize services gcpProject := utils.MustGetEnv("GCP_PROJECT")
authSvc := auth.NewAuthService(redisClient, aeroSvc) fbApp, err := firebase.NewApp(ctx, &firebase.Config{ProjectID: gcpProject})
if err != nil {
flog.Error("failed to init Firebase Admin app", "error", err)
os.Exit(1)
}
fbAuth, err := fbApp.Auth(ctx)
if err != nil {
flog.Error("failed to create Firebase auth client", "error", err)
os.Exit(1)
}
authSvc := auth.NewAuthService(redisClient, aeroSvc, fbAuth)
humanSvc := human.NewService(db.Pool()) humanSvc := human.NewService(db.Pool())
networkSvc := network.NewService(db.Pool())
billingSvc, err := billing.NewService(ctx, db.Pool(), billing.Config{
SecretKey: utils.MustGetEnv("STRIPE_SECRET_KEY"),
WebhookSecret: utils.MustGetEnv("STRIPE_WEBHOOK_SECRET"),
PriceMonthlyID: utils.MustGetEnv("STRIPE_PRICE_PRO_MONTHLY"),
PriceAnnualID: utils.MustGetEnv("STRIPE_PRICE_PRO_ANNUAL"),
SuccessURL: utils.MustGetEnv("BILLING_SUCCESS_URL"),
CancelURL: utils.MustGetEnv("BILLING_CANCEL_URL"),
})
if err != nil {
flog.Error("failed to initialize billing service", "error", err)
os.Exit(1)
}
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
if err != nil {
flog.Error("failed to create Firestore client", "error", err)
os.Exit(1)
}
defer firestoreClient.Close()
networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, livestore.NewMembershipPublisher(firestoreClient), humanSvc)
particleSvc := particle.NewService(db.Pool(), networkSvc) particleSvc := particle.NewService(db.Pool(), networkSvc)
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{ depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"), GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
BucketName: gcsBucket, BucketName: gcsBucket,
}) })
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
pushTokenSvc := pushnotify.NewService(db.Pool())
livekitClient := livekit.NewClient()
// Initialize handler h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, pushTokenSvc, livekitClient, firestoreClient)
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc)
// Helper to wrap handlers with auth middleware
withAuth := func(hf http.HandlerFunc) http.Handler { withAuth := func(hf http.HandlerFunc) http.Handler {
return middleware.Auth(authSvc)(http.HandlerFunc(hf)) return middleware.Auth(authSvc)(http.HandlerFunc(hf))
} }
@@ -97,6 +132,9 @@ func main() {
}) })
mux.HandleFunc("POST /auth/request-code", h.RequestSignInCode) mux.HandleFunc("POST /auth/request-code", h.RequestSignInCode)
mux.HandleFunc("POST /auth/sign-in", h.SignIn) mux.HandleFunc("POST /auth/sign-in", h.SignIn)
mux.HandleFunc("POST /waitlist", h.AddToWaitlist)
mux.HandleFunc("POST /livekit/webhook", h.HandleLivekitWebhook)
mux.HandleFunc("POST /webhooks/stripe", h.HandleStripeWebhook)
// ========================================================================== // ==========================================================================
// Protected routes (auth required) // Protected routes (auth required)
@@ -105,47 +143,75 @@ func main() {
// Auth // Auth
mux.Handle("POST /auth/sign-out", withAuth(h.SignOut)) mux.Handle("POST /auth/sign-out", withAuth(h.SignOut))
mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman)) mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman))
mux.Handle("POST /auth/firebase-token", withAuth(h.FirebaseToken))
// Bootstrap startup data // Settings
mux.Handle("GET /startup", withAuth(h.StartupData)) mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
mux.Handle("PUT /humans/me/avatar", withAuth(h.UpdateAvatar))
mux.Handle("DELETE /humans/me/avatar", withAuth(h.DeleteAvatar))
// Get avatar download url, given objectId
mux.Handle("GET /humans/avatar/{id}", withAuth(h.GetObjectDownloadUrl))
// Push notification tokens (per-device)
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
mux.Handle("DELETE /humans/me/push-tokens", withAuth(h.UnregisterPushToken))
// Networks // Networks
mux.Handle("POST /networks", withAuth(h.CreateNetwork)) mux.Handle("POST /networks", withAuth(h.CreateNetwork))
mux.Handle("GET /networks", withAuth(h.ListNetworks)) mux.Handle("GET /networks", withAuth(h.ListNetworks))
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork)) mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork)) mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
// TODO: what about members who are part of streams visibility within this network? mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork))
mux.Handle("DELETE /networks/{id}/members/{email}", withAuth(h.RemoveMemberFromNetwork))
mux.Handle("PUT /networks/{id}/capacity", withAuth(h.SetOpenStreamCapacity))
// Streams // Billing (network admin only; admin check happens inside each handler)
mux.Handle("POST /networks/{network_id}/streams", withAuth(h.CreateStream)) mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling))
mux.Handle("GET /streams/{id}", withAuth(h.GetStream)) mux.Handle("POST /networks/{id}/billing/checkout-session", withAuth(h.CreateCheckoutSession))
mux.Handle("PATCH /streams/{id}", withAuth(h.UpdateStream)) mux.Handle("POST /networks/{id}/billing/portal-session", withAuth(h.CreatePortalSession))
mux.Handle("POST /streams/{id}/particles", withAuth(h.CreateStreamParticle))
mux.Handle("POST /streams/{id}/open", withAuth(h.OpenStream)) // Freemium usage (any network member)
mux.Handle("POST /streams/{id}/close", withAuth(h.CloseStream)) mux.Handle("GET /networks/{id}/usage", withAuth(h.GetNetworkUsage))
mux.Handle("POST /streams/{id}/members", withAuth(h.AddMembers))
mux.Handle("DELETE /streams/{id}/members", withAuth(h.RemoveMembers)) // Network Invitations
mux.Handle("GET /networks/{id}/invitations", withAuth(h.ListInvitationsForNetwork))
mux.Handle("DELETE /networks/{id}/invitations", withAuth(h.RevokeInvitation))
mux.Handle("GET /invitations", withAuth(h.ListMyInvitations))
mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation))
// Particles // Particles
mux.Handle("GET /networks/{network_id}/particles", withAuth(h.ListParticles)) mux.Handle("GET /particles/{id}/download", withAuth(h.GetObjectDownloadUrl))
mux.Handle("GET /particles/{id}", withAuth(h.GetParticle))
mux.Handle("PATCH /particles/{id}", withAuth(h.UpdateParticle)) // Link metadata
mux.Handle("DELETE /particles/{id}", withAuth(h.DeleteParticle)) mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
mux.Handle("POST /particles/{id}/seen", withAuth(h.MarkSeen))
mux.Handle("POST /particles/{id}/ack", withAuth(h.AckParticle))
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticle))
mux.Handle("POST /particles/seen", withAuth(h.MarkSeenBatch))
// Depot // Depot
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload)) mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload)) mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload))
// LiveKit
mux.Handle("POST /livekit/token", withAuth(h.GetLivekitToken))
// Waitlist (admin-only)
mux.Handle("GET /waitlist", withAuth(h.GetWaitlist))
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
// CORS_ALLOWED_ORIGINS is a comma-separated whitelist for the web client.
// Empty / unset = allow all
var allowedOrigins []string
if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" {
for _, o := range strings.Split(raw, ",") {
if o = strings.TrimSpace(o); o != "" {
allowedOrigins = append(allowedOrigins, o)
}
}
}
muxWithCors := middleware.CORS(allowedOrigins)(mux)
addr := fmt.Sprintf("0.0.0.0:%s", port) addr := fmt.Sprintf("0.0.0.0:%s", port)
slog.Info("running server", "addr", addr) flog.Info("running server", "addr", addr)
if err := http.ListenAndServe(addr, mux); err != nil { if err := http.ListenAndServe(addr, muxWithCors); err != nil {
slog.Error("server failed", "error", err) flog.Error("server failed", "error", err)
os.Exit(1) os.Exit(1)
} }
} }
+446
View File
@@ -0,0 +1,446 @@
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/human/pushnotify"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/speech"
"github.com/flowy-live/llink/internal/utils"
"cloud.google.com/go/firestore"
"cloud.google.com/go/storage"
)
func createClient(ctx context.Context) *firestore.Client {
projectId := utils.MustGetEnv("GCP_PROJECT")
client, err := firestore.NewClient(ctx, projectId)
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
return client
}
// Listens for new particles and runs per-particle side effects: transcripts,
// transcode, parent stream's last_child_created_at, freemium usage, and push
// notifications for offline recipients.
func main() {
ctx := context.Background()
db.Init()
defer db.Cleanup()
processingRepo := particle.NewProcessingRepository(db.Pool())
billingSvc := billing.NewServiceForWorker(db.Pool())
storageClient, err := storage.NewClient(ctx)
if err != nil {
flog.Error("failed to create GCS client", "error", err)
os.Exit(1)
}
defer storageClient.Close()
gcsBucket := utils.MustGetEnv("GCS_BUCKET")
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
BucketName: gcsBucket,
})
speechSvc := speech.NewSpeechService(ctx)
humanSvc := human.NewService(db.Pool())
networkReader := network.NewReader(db.Pool())
pushTokenSvc := pushnotify.NewService(db.Pool())
// EXPO_ACCESS_TOKEN is required: Enhanced Security is on for our Expo
// project (otherwise anyone holding one of our push tokens could spam users).
expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN"))
notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, expoClient)
client := createClient(ctx)
defer client.Close()
cutoff := time.Now().Add(-5 * time.Minute)
it := client.CollectionGroup("children").
Where("created_at", ">", cutoff).
Snapshots(ctx)
for {
snap, err := it.Next()
if e := status.Code(err); e == codes.DeadlineExceeded || e == codes.Canceled {
panic(fmt.Errorf("error: %w", err))
}
if err != nil {
flog.Error("error in processing snapshot", "error", err)
continue
}
if snap == nil {
continue
}
for _, change := range snap.Changes {
if change.Kind != firestore.DocumentAdded {
continue
}
particleID := change.Doc.Ref.ID
processed, err := processingRepo.IsProcessed(ctx, particleID)
if err != nil {
flog.Error("failed to check processing status", "particleID", particleID, "error", err)
continue
}
if processed {
flog.Debug("skipping already processed particle", "particleID", particleID)
continue
}
flog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data())
// Side effects below are best-effort — failures don't prevent
// marking the particle as processed.
parentDoc := loadParentParticle(ctx, change.Doc)
updateParentLastChildCreatedAt(ctx, change.Doc, parentDoc)
transcript := transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
particle.Transcode(ctx, depotSvc, change.Doc)
recordFreemiumUsage(ctx, billingSvc, change.Doc)
notifyForParticle(ctx, notifier, humanSvc, change.Doc, parentDoc, transcript)
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
flog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
}
}
}
}
// Writes the structured transcript to Firestore and returns the raw text;
// returns "" for non-media particles or on any error (logged internally).
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) string {
var mediaParticle particle.FirestoreMediaParticle
err := doc.DataTo(&mediaParticle)
if err != nil {
flog.Error("unable to marshal particle data", "error", err)
return ""
}
particleType, err := particle.ParseParticleType(mediaParticle.Type)
if err != nil {
flog.Error("invalid particle type", "error", err)
return ""
}
if particleType != particle.TypeMedia {
flog.Info("received a particle of type", "particle type", particleType)
return ""
}
downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
if err != nil {
flog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
return ""
}
result, err := speechSvc.Transcribe(ctx, downloadURL)
if err != nil {
flog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID)
return ""
}
transcript := toFirestoreTranscript(result)
_, err = doc.Ref.Set(ctx, map[string]interface{}{
"properties": map[string]interface{}{
"transcript": transcript,
},
}, firestore.MergeAll)
if err != nil {
flog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID)
return ""
}
flog.Info("transcribed media particle", "particleID", doc.Ref.ID)
return transcript.Transcript
}
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
words := make([]particle.FirestoreTranscriptWord, len(result.Words))
for i, w := range result.Words {
words[i] = particle.FirestoreTranscriptWord{
Word: w.Word,
Start: w.Start,
End: w.End,
}
}
paragraphs := make([]particle.FirestoreTranscriptParagraph, len(result.Paragraphs))
for i, p := range result.Paragraphs {
sentences := make([]particle.FirestoreTranscriptSentence, len(p.Sentences))
for j, s := range p.Sentences {
sentences[j] = particle.FirestoreTranscriptSentence{
Text: s.Text,
Start: s.Start,
End: s.End,
}
}
paragraphs[i] = particle.FirestoreTranscriptParagraph{
Sentences: sentences,
Start: p.Start,
End: p.End,
}
}
return particle.FirestoreTranscript{
Transcript: result.Transcript,
Words: words,
Paragraphs: paragraphs,
}
}
// Bumps the network's daily message counter for non-container particles.
// The surrounding processed_particles guard keeps this idempotent across
// crashes/restarts.
func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) {
rawType, err := doc.DataAt("type")
if err != nil {
flog.Error("failed to read particle type", "error", err, "particleID", doc.Ref.ID)
return
}
typeStr, ok := rawType.(string)
if !ok {
flog.Error("particle type is not a string", "particleID", doc.Ref.ID, "type", rawType)
return
}
particleType, err := particle.ParseParticleType(typeStr)
if err != nil {
flog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID)
return
}
// Containers don't count toward the daily message cap.
if particleType == particle.TypeStream || particleType == particle.TypeFolder {
return
}
networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
if err != nil {
flog.Error("failed to derive network id", "error", err, "path", doc.Ref.Path)
return
}
if err := billingSvc.IncrementDailyUsage(ctx, networkID, doc.CreateTime); err != nil {
flog.Error("failed to increment daily usage", "error", err, "networkID", networkID, "particleID", doc.Ref.ID)
}
}
// Returns nil (and logs) if the path has no parent or the read fails.
func loadParentParticle(ctx context.Context, doc *firestore.DocumentSnapshot) *firestore.DocumentSnapshot {
parentChildrenCollectionRef := doc.Ref.Parent
if parentChildrenCollectionRef == nil {
return nil
}
parentParticleDocRef := parentChildrenCollectionRef.Parent
if parentParticleDocRef == nil {
flog.Error("particle has no parent document", "particleID", doc.Ref.ID)
return nil
}
parentParticleDoc, err := parentParticleDocRef.Get(ctx)
if err != nil {
flog.Error("failed to get parent particle", "error", err, "particleID", doc.Ref.ID)
return nil
}
return parentParticleDoc
}
// Sets last_child_created_at to the child's created_at so it stays directly
// comparable with playback markers (which also store child created_at values).
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot, parent *firestore.DocumentSnapshot) {
if parent == nil {
return
}
var streamParticle particle.FirestoreStreamParticle
if err := parent.DataTo(&streamParticle); err != nil {
flog.Error("failed to parse stream particle", "error", err)
return
}
particleType, err := particle.ParseParticleType(streamParticle.Type)
if err != nil {
flog.Error("invalid particle type", "error", err)
return
}
if particleType != particle.TypeStream {
return
}
childCreatedAt, err := doc.DataAt("created_at")
if err != nil {
flog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID)
return
}
_, err = parent.Ref.Update(ctx, []firestore.Update{
{
Path: "last_child_created_at",
Value: childCreatedAt,
},
})
if err != nil {
flog.Error("unable to update parent particle `last_child_created_at`", "error", err)
}
}
// Skips containers and particles whose parent isn't a stream — notifications
// are scoped to stream messages today. The transcript arg becomes the preview
// body for media particles when available.
func notifyForParticle(
ctx context.Context,
notifier *pushnotify.Notifier,
humanSvc human.Service,
doc *firestore.DocumentSnapshot,
parent *firestore.DocumentSnapshot,
transcript string,
) {
if parent == nil {
flog.Info("notify: skip — no parent", "particleID", doc.Ref.ID)
return
}
typeStr, _ := doc.DataAt("type")
typeName, _ := typeStr.(string)
pType, err := particle.ParseParticleType(typeName)
if err != nil {
flog.Info("notify: skip — unparseable particle type",
"particleID", doc.Ref.ID, "type", typeName, "error", err)
return
}
if pType == particle.TypeStream || pType == particle.TypeFolder {
flog.Info("notify: skip — container particle",
"particleID", doc.Ref.ID, "type", pType)
return
}
var parentStream particle.FirestoreStreamParticle
if err := parent.DataTo(&parentStream); err != nil {
flog.Error("notify: failed to parse parent stream", "error", err)
return
}
parentType, err := particle.ParseParticleType(parentStream.Type)
if err != nil || parentType != particle.TypeStream {
flog.Info("notify: skip — parent isn't a stream",
"particleID", doc.Ref.ID, "parentType", parentType, "parseErr", err)
return
}
networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
if err != nil {
flog.Error("notify: failed to derive network id", "error", err, "path", doc.Ref.Path)
return
}
senderHumanID := parentStream.CreatedByHumanId
if v, err := doc.DataAt("created_by_human_id"); err == nil {
if s, ok := v.(string); ok && s != "" {
senderHumanID = s
}
}
streamName := ""
if v, err := parent.DataAt("properties.name"); err == nil {
if s, ok := v.(string); ok {
streamName = s
}
}
senderEmailPrefix := ""
if senderHumanID != "" {
if sender, err := humanSvc.GetByID(ctx, senderHumanID); err == nil {
senderEmailPrefix = sender.EmailPrefix
} else {
flog.Warn("notify: failed to look up sender", "error", err, "humanID", senderHumanID)
}
}
if err := notifier.NotifyParticleCreated(ctx, pushnotify.NotifyInput{
NetworkID: networkID,
SenderHumanID: senderHumanID,
SenderEmailPrefix: senderEmailPrefix,
ParticleID: doc.Ref.ID,
ParticleKind: string(pType),
StreamID: parent.Ref.ID,
StreamName: streamName,
StreamVisibleTo: parentStream.VisibleTo,
Body: previewForParticle(pType, doc, transcript),
}); err != nil {
flog.Error("notify: dispatch failed", "error", err, "particleID", doc.Ref.ID, "networkID", networkID)
}
}
// Builds the notification body. Kept short — lockscreens truncate aggressively.
// Media prefers transcript text and falls back to a generic "Sent a …" line.
func previewForParticle(pType particle.ParticleType, doc *firestore.DocumentSnapshot, transcript string) string {
switch pType {
case particle.TypeText:
if v, err := doc.DataAt("properties.content"); err == nil {
if s, ok := v.(string); ok {
return truncatePreview(s, 140)
}
}
return "Sent a message"
case particle.TypeMedia:
if t := strings.TrimSpace(transcript); t != "" {
return truncatePreview(t, 140)
}
mime := ""
if v, err := doc.DataAt("properties.mime_type"); err == nil {
if s, ok := v.(string); ok {
mime = s
}
}
if strings.HasPrefix(mime, "video/") {
return "Sent a video"
}
return "Sent a voice message"
case particle.TypeFile:
return "Sent a file"
case particle.TypeQuest:
if v, err := doc.DataAt("properties.title"); err == nil {
if s, ok := v.(string); ok && s != "" {
return "Quest: " + truncatePreview(s, 120)
}
}
return "Added a quest"
case particle.TypePaper:
if v, err := doc.DataAt("properties.title"); err == nil {
if s, ok := v.(string); ok && s != "" {
return "Paper: " + truncatePreview(s, 120)
}
}
return "Added a paper"
default:
return "New activity"
}
}
func truncatePreview(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal"
"github.com/flowy-live/llink/internal/auth"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/pusher"
"github.com/flowy-live/llink/internal/utils"
"google.golang.org/grpc"
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
)
func main() {
port := utils.MustGetEnv("PORT")
grpcPort := utils.MustGetEnv("GRPC_PORT")
db.Init()
defer db.Cleanup()
authRedis := internal.ConnectAndTestRedis(db.RedisDBAuth) // shared with orion
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) // presence + pub/sub
sessionReader := auth.NewSessionReader(authRedis)
networkReader := network.NewReader(db.Pool())
// Hostname is the k8s pod name.
podID, err := os.Hostname()
if err != nil {
podID = fmt.Sprintf("pod-%d", os.Getpid())
}
bridge := pusher.NewRedisBridge(pusherRedis, podID)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
authorizer := pusher.NewAuthorizer(networkReader)
hub := pusher.NewHub(bridge, authorizer)
bridge.SetHub(hub)
server := pusher.NewServer(ctx, hub, bridge, sessionReader)
go hub.Run(ctx)
go bridge.Listen(ctx)
go bridge.Heartbeat(ctx)
// --- gRPC server (internal presence queries) ---
grpcListener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%s", grpcPort))
if err != nil {
flog.Error("failed to listen for gRPC", "port", grpcPort, "error", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
pbpusher.RegisterPusherServiceServer(grpcServer, server)
go func() {
flog.Info("gRPC server listening", "port", grpcPort)
if err := grpcServer.Serve(grpcListener); err != nil {
flog.Error("gRPC server failed", "error", err)
}
}()
// --- HTTP server (WebSocket + health) ---
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /ws", server.HandleWebSocket)
httpAddr := fmt.Sprintf("0.0.0.0:%s", port)
httpServer := &http.Server{Addr: httpAddr, Handler: mux}
go func() {
flog.Info("HTTP server listening", "addr", httpAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
flog.Error("HTTP server failed", "error", err)
os.Exit(1)
}
}()
// --- Graceful shutdown ---
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
flog.Info("shutting down...")
cancel()
grpcServer.GracefulStop()
httpServer.Shutdown(context.Background())
flog.Info("shutdown complete")
}
+141
View File
@@ -0,0 +1,141 @@
// transcodebackfill walks every doc under the "children" collection group and
// re-runs transcode for media particles missing a transcoded variant.
// Idempotent: particle.Transcode short-circuits on transcoded_object_id != "".
package main
import (
"context"
"os"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"cloud.google.com/go/storage"
"google.golang.org/api/iterator"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils"
)
func main() {
ctx := context.Background()
db.Init()
defer db.Cleanup()
storageClient, err := storage.NewClient(ctx)
if err != nil {
flog.Error("failed to create GCS client", "error", err)
os.Exit(1)
}
defer storageClient.Close()
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
BucketName: utils.MustGetEnv("GCS_BUCKET"),
})
gcpProject := utils.MustGetEnv("GCP_PROJECT")
fs, err := firestore.NewClient(ctx, gcpProject)
if err != nil {
flog.Error("failed to create Firestore client", "error", err)
os.Exit(1)
}
defer fs.Close()
started := time.Now()
stats, err := run(ctx, fs, depotSvc)
elapsed := time.Since(started)
flog.Info("transcode_backfill_summary",
"scanned", stats.scanned,
"media", stats.media,
"transcoded", stats.transcoded,
"skippedAlreadyDone", stats.skippedAlreadyDone,
"skippedIOSPlayable", stats.skippedIOSPlayable,
"skippedNonMedia", stats.skippedNonMedia,
"failures", stats.failures,
"elapsed_seconds", elapsed.Seconds(),
)
if err != nil {
flog.Error("transcode backfill aborted", "error", err)
os.Exit(1)
}
if stats.failures > 0 {
os.Exit(1)
}
}
type stats struct {
scanned int
media int
transcoded int
skippedAlreadyDone int
skippedIOSPlayable int
skippedNonMedia int
failures int
}
func run(ctx context.Context, fs *firestore.Client, depotSvc depot.Service) (stats, error) {
var s stats
it := fs.CollectionGroup("children").
OrderBy(firestore.DocumentID, firestore.Asc).
Documents(ctx)
defer it.Stop()
for {
doc, err := it.Next()
if err == iterator.Done {
return s, nil
}
if err != nil {
return s, err
}
s.scanned++
// DataAt avoids unmarshalling the full doc; most children aren't media.
rawType, err := doc.DataAt("type")
if err != nil {
s.skippedNonMedia++
continue
}
typeStr, ok := rawType.(string)
if !ok || typeStr != string(particle.TypeMedia) {
s.skippedNonMedia++
continue
}
s.media++
result := particle.Transcode(ctx, depotSvc, doc)
switch {
case result.Err != nil:
s.failures++
flog.Error("transcode_backfill_failure",
"particleID", doc.Ref.ID,
"path", doc.Ref.Path,
"error", result.Err,
)
case result.Skipped && result.SkipReason == particle.SkipReasonAlreadyTranscoded:
s.skippedAlreadyDone++
case result.Skipped && result.SkipReason == particle.SkipReasonIOSPlayable:
s.skippedIOSPlayable++
case result.Skipped:
s.skippedNonMedia++
default:
s.transcoded++
flog.Info("transcode_backfill_progress",
"particleID", doc.Ref.ID,
"transcodedObjectID", result.TranscodedObjectID,
"outputMime", result.OutputMimeType,
"idx", s.transcoded,
)
}
}
}
-7
View File
@@ -1,7 +0,0 @@
#!/bin/bash
set -e
export KUBE_CONTEXT=dev
export SKAFFOLD_DEFAULT_REPO=us-west2-docker.pkg.dev/flowy-dev-440017/deployments
skaffold run -p dev --kube-context dev --port-forward --tail
+3 -311
View File
@@ -51,54 +51,6 @@ Returns the authenticated user.
--- ---
## Startup
### Get Startup Data
`GET /startup` (Protected)
Bootstrap endpoint for initial app load. Returns all networks the user belongs to, with all streams and their particles fully enriched.
**Response:**
```json
{
"networks": [
{
"id": "net-456",
"name": "My Team",
"admin_human": { "id": "...", "email": "...", "email_prefix": "...", "created_at": "..." },
"humans": [{ "id": "...", "email": "...", "email_prefix": "...", "created_at": "..." }],
"open_stream_count": 2,
"open_stream_capacity": 5,
"created_at": "2025-01-15T10:30:00Z",
"streams": [
{
"id": "p-001",
"name": "Sprint Planning",
"description": "Weekly sync",
"status": "open",
"members": ["alice@example.com"],
"particles": [
{
"id": "p-002",
"type": "text",
"data": { "content": "Hello" },
"created_by_email": "alice@example.com",
"seen": true,
"acks": [],
"updated_at": "...",
"created_at": "..."
}
],
"unseen_count": 0
}
]
}
]
}
```
---
## Networks ## Networks
### Create Network ### Create Network
@@ -136,183 +88,15 @@ Returns a specific network by ID.
Removes a member by email from the network. Removes a member by email from the network.
### Set Open Stream Capacity
`PUT /networks/{id}/capacity` (Protected, Admin only)
**Request Body:**
```json
{
"capacity": 10
}
```
--- ---
## Streams ### Download Particle Object
Streams are top-level particles of type `stream`. They have dedicated endpoints for creation and management, and contain child particles.
### Create Stream
`POST /networks/{network_id}/streams` (Protected)
**Request Body:**
```json
{
"name": "Sprint Planning",
"description": "Weekly sync",
"visibility": "custom",
"members": ["user@example.com"]
}
```
- `visibility`: `network_all` (default) or `custom`
- `members` is required when visibility is `custom`
**Response:** `201 Created` — returns a [Stream](#stream-1) object.
### Get Stream
`GET /streams/{id}` (Protected)
Returns a stream with all its child particles, enriched with seen/ack state.
**Response:** returns a [Stream](#stream-1) object.
### Update Stream
`PATCH /streams/{id}` (Protected)
Updates a stream's name and/or description. Status is not affected (use the open/close endpoints instead). Only provided fields are updated.
**Request Body:**
```json
{
"name": "New Name",
"description": "New description"
}
```
- Both fields are optional — omit a field to leave it unchanged
- `name` cannot be empty if provided
**Response:** returns the updated [Stream](#stream-1) object.
### Create Stream Particle
`POST /streams/{id}/particles` (Protected)
Creates a child particle inside a stream. Child particles inherit visibility from the stream.
**Request Body:**
```json
{
"type": "text|media|file|quest|paper",
"data": {}
}
```
- Cannot create `stream` or `folder` types as children
- For `media` and `file` types, `data` must include a valid `object_id` from depot
**Response:** `201 Created` — returns a [StreamParticle](#streamparticle) object.
### Open Stream
`POST /streams/{id}/open` (Protected)
Opens a closed stream. Fails with `409` if capacity would be exceeded.
### Close Stream
`POST /streams/{id}/close` (Protected)
Closes an open stream.
### Add Members to Stream
`POST /streams/{id}/members` (Protected)
**Request Body:**
```json
{
"emails": ["user@example.com"]
}
```
### Remove Members from Stream
`DELETE /streams/{id}/members` (Protected)
**Request Body:**
```json
{
"emails": ["user@example.com"]
}
```
---
## Particles
### List Particles
`GET /networks/{network_id}/particles` (Protected)
**Query Parameters:**
- `parent_id` (optional): Filter by parent particle
- `cursor` (optional): Pagination cursor
- `direction` (optional): `after` or `before` (default: `after`)
- `type` (optional, repeatable): Filter by particle type
**Response enrichment:**
Each particle in the response includes:
- `seen` (boolean): Whether the requester has marked this particle as seen
- `acks` (array): List of acknowledgments `[{email, acked_at}]`
- `unseen_count` (integer, streams only): Count of unseen child particles
### Get Particle
`GET /particles/{id}` (Protected)
### Update Particle
`PATCH /particles/{id}` (Protected)
**Request Body:**
```json
{
"data": {}
}
```
### Delete Particle
`DELETE /particles/{id}` (Protected)
Deletes the particle and all children. If it references a depot object, that is also deleted.
### Download Particle
`GET /particles/{id}/download` (Protected) `GET /particles/{id}/download` (Protected)
For now, the `{id}` should be an object id. Not the particle id.
Returns a `302` redirect to a signed download URL. Only works for `media` and `file` particles. Returns a `302` redirect to a signed download URL. Only works for `media` and `file` particles.
### Mark Seen
`POST /particles/{id}/seen` (Protected)
Marks a particle as seen by the requester. This is private state, only visible to the requester.
Returns `204 No Content` on success.
### Mark Seen (Batch)
`POST /particles/seen` (Protected)
Marks multiple particles as seen by the requester.
**Request Body:**
```json
{
"particle_ids": ["particle_uuid1", "particle_uuid2"]
}
```
Returns `204 No Content` on success.
### Acknowledge Particle
`POST /particles/{id}/ack` (Protected)
Acknowledges a particle. Acknowledgments are public and permanent, visible to all users with access. Also marks the particle as seen.
Returns `204 No Content` on success.
--- ---
## Depot (File Storage) ## Depot (File Storage)
@@ -395,8 +179,6 @@ Confirms that an upload has been completed.
| `name` | `string` | Display name of the network. | | `name` | `string` | Display name of the network. |
| `admin_human` | `Human` | The network administrator. | | `admin_human` | `Human` | The network administrator. |
| `humans` | `Human[]` | All members of the network (including admin). | | `humans` | `Human[]` | All members of the network (including admin). |
| `open_stream_count` | `integer` | Number of currently open streams. |
| `open_stream_capacity` | `integer` | Maximum number of concurrent open streams (default: 5). |
| `created_at` | `string` | ISO 8601 timestamp. | | `created_at` | `string` | ISO 8601 timestamp. |
```json ```json
@@ -407,86 +189,10 @@ Confirms that an upload has been completed.
"humans": [ "humans": [
{ "id": "abc-123", "email": "alice@example.com", "email_prefix": "alice", "created_at": "..." } { "id": "abc-123", "email": "alice@example.com", "email_prefix": "alice", "created_at": "..." }
], ],
"open_stream_count": 2,
"open_stream_capacity": 5,
"created_at": "2025-01-15T10:30:00Z" "created_at": "2025-01-15T10:30:00Z"
} }
``` ```
### Stream
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique identifier (this is a particle ID). |
| `name` | `string` | Stream name. |
| `description` | `string` | Stream description. |
| `status` | `string` | `"open"`, `"closed"`, or `"unspecified"`. |
| `members` | `string[]` | Emails of stream members. Omitted for `network_all` visibility. |
| `particles` | `StreamParticle[]` | Child particles in the stream. |
| `unseen_count` | `integer` | Number of unseen child particles for the requester. |
```json
{
"id": "p-001",
"name": "Sprint Planning",
"description": "Weekly sync",
"status": "open",
"members": ["alice@example.com"],
"particles": [],
"unseen_count": 0
}
```
### StreamParticle
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique identifier. |
| `type` | `string` | One of: `media`, `file`, `text`, `quest`, `paper`. |
| `data` | `object` | Type-specific payload (see [Particle Data by Type](#particle-data-by-type)). |
| `created_by_email` | `string` | Email of the creator. |
| `seen` | `boolean` | Whether the requester has seen this particle. |
| `acks` | `AckInfo[]` | Acknowledgments from users. |
| `updated_at` | `string` | ISO 8601 timestamp. |
| `created_at` | `string` | ISO 8601 timestamp. |
### Particle
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique identifier. |
| `type` | `string` | One of: `stream`, `folder`, `media`, `file`, `text`, `quest`, `paper`. |
| `network_id` | `string` | The network this particle belongs to. |
| `parent_id` | `string \| null` | Parent particle ID, if nested. |
| `created_by_email` | `string` | Email of the creator. |
| `visibility` | `string` | `"network_all"`, `"custom"`, or `"inherited"`. |
| `stream_status` | `string \| null` | Only on `stream` type: `"open"` or `"closed"`. |
| `data` | `object` | Type-specific payload (see [Particle Data by Type](#particle-data-by-type)). |
| `download_url` | `string \| null` | Signed download URL. Only on `media`/`file` particles. |
| `seen` | `boolean \| null` | Whether the requester has seen this particle. Only in list responses. |
| `acks` | `AckInfo[]` | Acknowledgments. Only in list responses. |
| `unseen_count` | `integer \| null` | Unseen child count. Only on `stream` particles in list responses. |
| `updated_at` | `string` | ISO 8601 timestamp. |
| `created_at` | `string` | ISO 8601 timestamp. |
### AckInfo
| Field | Type | Description |
|-------|------|-------------|
| `email` | `string` | Email of the user who acknowledged. |
| `acked_at` | `string` | ISO 8601 timestamp of the acknowledgment. |
### ParticleList
Returned by `GET /networks/{network_id}/particles`.
| Field | Type | Description |
|-------|------|-------------|
| `particles` | `Particle[]` | Array of enriched particle objects. |
| `has_more` | `boolean` | Whether more results exist beyond this page. |
| `next_cursor` | `string \| null` | Cursor to fetch the next page. |
| `prev_cursor` | `string \| null` | Cursor to fetch the previous page. |
### DepotObject ### DepotObject
Returned by `POST /depot/objects/{id}/confirm`. Returned by `POST /depot/objects/{id}/confirm`.
@@ -591,20 +297,6 @@ The `data` field on a Particle is a JSON object whose schema depends on the part
--- ---
## Visibility
Particles support three visibility modes:
| Mode | Description |
|------|-------------|
| `network_all` | Visible to all network members. |
| `custom` | Visible only to specified members (requires `members` list). |
| `inherited` | Inherits visibility from parent particle. Used for child particles in streams. |
Root-level particles (streams, folders) use `network_all` or `custom`. Child particles created via `POST /streams/{id}/particles` automatically use `inherited`.
---
## Error Responses ## Error Responses
All endpoints return standard HTTP status codes with plain text error bodies: All endpoints return standard HTTP status codes with plain text error bodies:
+462
View File
@@ -0,0 +1,462 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.28.3
// source: llink/pusher/pusher.proto
package pbpusher
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type GetOnlineHumanIdsRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *GetOnlineHumanIdsRequest) Reset() {
*x = GetOnlineHumanIdsRequest{}
mi := &file_llink_pusher_pusher_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetOnlineHumanIdsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetOnlineHumanIdsRequest) ProtoMessage() {}
func (x *GetOnlineHumanIdsRequest) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetOnlineHumanIdsRequest.ProtoReflect.Descriptor instead.
func (*GetOnlineHumanIdsRequest) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{0}
}
type GetOnlineHumanIdsResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
HumanIds []string `protobuf:"bytes,1,rep,name=human_ids,json=humanIds,proto3" json:"human_ids,omitempty"`
}
func (x *GetOnlineHumanIdsResponse) Reset() {
*x = GetOnlineHumanIdsResponse{}
mi := &file_llink_pusher_pusher_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetOnlineHumanIdsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetOnlineHumanIdsResponse) ProtoMessage() {}
func (x *GetOnlineHumanIdsResponse) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetOnlineHumanIdsResponse.ProtoReflect.Descriptor instead.
func (*GetOnlineHumanIdsResponse) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{1}
}
func (x *GetOnlineHumanIdsResponse) GetHumanIds() []string {
if x != nil {
return x.HumanIds
}
return nil
}
type IsOnlineRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
HumanIds []string `protobuf:"bytes,1,rep,name=human_ids,json=humanIds,proto3" json:"human_ids,omitempty"`
}
func (x *IsOnlineRequest) Reset() {
*x = IsOnlineRequest{}
mi := &file_llink_pusher_pusher_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *IsOnlineRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IsOnlineRequest) ProtoMessage() {}
func (x *IsOnlineRequest) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use IsOnlineRequest.ProtoReflect.Descriptor instead.
func (*IsOnlineRequest) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{2}
}
func (x *IsOnlineRequest) GetHumanIds() []string {
if x != nil {
return x.HumanIds
}
return nil
}
type IsOnlineResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Online map[string]bool `protobuf:"bytes,1,rep,name=online,proto3" json:"online,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"`
}
func (x *IsOnlineResponse) Reset() {
*x = IsOnlineResponse{}
mi := &file_llink_pusher_pusher_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *IsOnlineResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IsOnlineResponse) ProtoMessage() {}
func (x *IsOnlineResponse) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use IsOnlineResponse.ProtoReflect.Descriptor instead.
func (*IsOnlineResponse) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{3}
}
func (x *IsOnlineResponse) GetOnline() map[string]bool {
if x != nil {
return x.Online
}
return nil
}
type GetChannelPresenceRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ChannelIds []string `protobuf:"bytes,1,rep,name=channel_ids,json=channelIds,proto3" json:"channel_ids,omitempty"`
}
func (x *GetChannelPresenceRequest) Reset() {
*x = GetChannelPresenceRequest{}
mi := &file_llink_pusher_pusher_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetChannelPresenceRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetChannelPresenceRequest) ProtoMessage() {}
func (x *GetChannelPresenceRequest) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetChannelPresenceRequest.ProtoReflect.Descriptor instead.
func (*GetChannelPresenceRequest) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{4}
}
func (x *GetChannelPresenceRequest) GetChannelIds() []string {
if x != nil {
return x.ChannelIds
}
return nil
}
type GetChannelPresenceResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Presences map[string]*ChannelPresence `protobuf:"bytes,1,rep,name=presences,proto3" json:"presences,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
}
func (x *GetChannelPresenceResponse) Reset() {
*x = GetChannelPresenceResponse{}
mi := &file_llink_pusher_pusher_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetChannelPresenceResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetChannelPresenceResponse) ProtoMessage() {}
func (x *GetChannelPresenceResponse) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetChannelPresenceResponse.ProtoReflect.Descriptor instead.
func (*GetChannelPresenceResponse) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{5}
}
func (x *GetChannelPresenceResponse) GetPresences() map[string]*ChannelPresence {
if x != nil {
return x.Presences
}
return nil
}
type ChannelPresence struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
HumanIds []string `protobuf:"bytes,1,rep,name=human_ids,json=humanIds,proto3" json:"human_ids,omitempty"`
}
func (x *ChannelPresence) Reset() {
*x = ChannelPresence{}
mi := &file_llink_pusher_pusher_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ChannelPresence) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ChannelPresence) ProtoMessage() {}
func (x *ChannelPresence) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ChannelPresence.ProtoReflect.Descriptor instead.
func (*ChannelPresence) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{6}
}
func (x *ChannelPresence) GetHumanIds() []string {
if x != nil {
return x.HumanIds
}
return nil
}
var File_llink_pusher_pusher_proto protoreflect.FileDescriptor
var file_llink_pusher_pusher_proto_rawDesc = []byte{
0x0a, 0x19, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2f, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2f, 0x70,
0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0c, 0x6c, 0x6c, 0x69,
0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x22, 0x1a, 0x0a, 0x18, 0x47, 0x65, 0x74,
0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x38, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69,
0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x22,
0x2e, 0x0a, 0x0f, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x22,
0x91, 0x01, 0x0a, 0x10, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x42, 0x0a, 0x06, 0x6f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x01,
0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73,
0x68, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79,
0x52, 0x06, 0x6f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x1a, 0x39, 0x0a, 0x0b, 0x4f, 0x6e, 0x6c, 0x69,
0x6e, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
0x02, 0x38, 0x01, 0x22, 0x3c, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65,
0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x64,
0x73, 0x22, 0xd0, 0x01, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c,
0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x55, 0x0a, 0x09, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68,
0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x72, 0x65,
0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x72,
0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x70, 0x72,
0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x1a, 0x5b, 0x0a, 0x0e, 0x50, 0x72, 0x65, 0x73, 0x65,
0x6e, 0x63, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x76,
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6c, 0x6c, 0x69,
0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65,
0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x0f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50,
0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x75, 0x6d, 0x61, 0x6e,
0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x68, 0x75, 0x6d, 0x61,
0x6e, 0x49, 0x64, 0x73, 0x32, 0xa9, 0x02, 0x0a, 0x0d, 0x50, 0x75, 0x73, 0x68, 0x65, 0x72, 0x53,
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x64, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c,
0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x12, 0x26, 0x2e, 0x6c, 0x6c,
0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e,
0x6c, 0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68,
0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61,
0x6e, 0x49, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x08,
0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x1d, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b,
0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e,
0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x43, 0x68,
0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x27, 0x2e,
0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74,
0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70,
0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c,
0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x42, 0x3c, 0x5a, 0x3a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x66,
0x6c, 0x6f, 0x77, 0x79, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2f, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2f,
0x67, 0x65, 0x6e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2f, 0x70,
0x75, 0x73, 0x68, 0x65, 0x72, 0x3b, 0x70, 0x62, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x62, 0x06,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_llink_pusher_pusher_proto_rawDescOnce sync.Once
file_llink_pusher_pusher_proto_rawDescData = file_llink_pusher_pusher_proto_rawDesc
)
func file_llink_pusher_pusher_proto_rawDescGZIP() []byte {
file_llink_pusher_pusher_proto_rawDescOnce.Do(func() {
file_llink_pusher_pusher_proto_rawDescData = protoimpl.X.CompressGZIP(file_llink_pusher_pusher_proto_rawDescData)
})
return file_llink_pusher_pusher_proto_rawDescData
}
var file_llink_pusher_pusher_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_llink_pusher_pusher_proto_goTypes = []any{
(*GetOnlineHumanIdsRequest)(nil), // 0: llink.pusher.GetOnlineHumanIdsRequest
(*GetOnlineHumanIdsResponse)(nil), // 1: llink.pusher.GetOnlineHumanIdsResponse
(*IsOnlineRequest)(nil), // 2: llink.pusher.IsOnlineRequest
(*IsOnlineResponse)(nil), // 3: llink.pusher.IsOnlineResponse
(*GetChannelPresenceRequest)(nil), // 4: llink.pusher.GetChannelPresenceRequest
(*GetChannelPresenceResponse)(nil), // 5: llink.pusher.GetChannelPresenceResponse
(*ChannelPresence)(nil), // 6: llink.pusher.ChannelPresence
nil, // 7: llink.pusher.IsOnlineResponse.OnlineEntry
nil, // 8: llink.pusher.GetChannelPresenceResponse.PresencesEntry
}
var file_llink_pusher_pusher_proto_depIdxs = []int32{
7, // 0: llink.pusher.IsOnlineResponse.online:type_name -> llink.pusher.IsOnlineResponse.OnlineEntry
8, // 1: llink.pusher.GetChannelPresenceResponse.presences:type_name -> llink.pusher.GetChannelPresenceResponse.PresencesEntry
6, // 2: llink.pusher.GetChannelPresenceResponse.PresencesEntry.value:type_name -> llink.pusher.ChannelPresence
0, // 3: llink.pusher.PusherService.GetOnlineHumanIds:input_type -> llink.pusher.GetOnlineHumanIdsRequest
2, // 4: llink.pusher.PusherService.IsOnline:input_type -> llink.pusher.IsOnlineRequest
4, // 5: llink.pusher.PusherService.GetChannelPresence:input_type -> llink.pusher.GetChannelPresenceRequest
1, // 6: llink.pusher.PusherService.GetOnlineHumanIds:output_type -> llink.pusher.GetOnlineHumanIdsResponse
3, // 7: llink.pusher.PusherService.IsOnline:output_type -> llink.pusher.IsOnlineResponse
5, // 8: llink.pusher.PusherService.GetChannelPresence:output_type -> llink.pusher.GetChannelPresenceResponse
6, // [6:9] is the sub-list for method output_type
3, // [3:6] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_llink_pusher_pusher_proto_init() }
func file_llink_pusher_pusher_proto_init() {
if File_llink_pusher_pusher_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_llink_pusher_pusher_proto_rawDesc,
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_llink_pusher_pusher_proto_goTypes,
DependencyIndexes: file_llink_pusher_pusher_proto_depIdxs,
MessageInfos: file_llink_pusher_pusher_proto_msgTypes,
}.Build()
File_llink_pusher_pusher_proto = out.File
file_llink_pusher_pusher_proto_rawDesc = nil
file_llink_pusher_pusher_proto_goTypes = nil
file_llink_pusher_pusher_proto_depIdxs = nil
}
+203
View File
@@ -0,0 +1,203 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.28.3
// source: llink/pusher/pusher.proto
package pbpusher
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
PusherService_GetOnlineHumanIds_FullMethodName = "/llink.pusher.PusherService/GetOnlineHumanIds"
PusherService_IsOnline_FullMethodName = "/llink.pusher.PusherService/IsOnline"
PusherService_GetChannelPresence_FullMethodName = "/llink.pusher.PusherService/GetChannelPresence"
)
// PusherServiceClient is the client API for PusherService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type PusherServiceClient interface {
// Returns all currently connected human IDs.
GetOnlineHumanIds(ctx context.Context, in *GetOnlineHumanIdsRequest, opts ...grpc.CallOption) (*GetOnlineHumanIdsResponse, error)
// Returns whether specific humans are currently online.
IsOnline(ctx context.Context, in *IsOnlineRequest, opts ...grpc.CallOption) (*IsOnlineResponse, error)
// Returns presence (human IDs) for specific channels.
GetChannelPresence(ctx context.Context, in *GetChannelPresenceRequest, opts ...grpc.CallOption) (*GetChannelPresenceResponse, error)
}
type pusherServiceClient struct {
cc grpc.ClientConnInterface
}
func NewPusherServiceClient(cc grpc.ClientConnInterface) PusherServiceClient {
return &pusherServiceClient{cc}
}
func (c *pusherServiceClient) GetOnlineHumanIds(ctx context.Context, in *GetOnlineHumanIdsRequest, opts ...grpc.CallOption) (*GetOnlineHumanIdsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetOnlineHumanIdsResponse)
err := c.cc.Invoke(ctx, PusherService_GetOnlineHumanIds_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *pusherServiceClient) IsOnline(ctx context.Context, in *IsOnlineRequest, opts ...grpc.CallOption) (*IsOnlineResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(IsOnlineResponse)
err := c.cc.Invoke(ctx, PusherService_IsOnline_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *pusherServiceClient) GetChannelPresence(ctx context.Context, in *GetChannelPresenceRequest, opts ...grpc.CallOption) (*GetChannelPresenceResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetChannelPresenceResponse)
err := c.cc.Invoke(ctx, PusherService_GetChannelPresence_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// PusherServiceServer is the server API for PusherService service.
// All implementations must embed UnimplementedPusherServiceServer
// for forward compatibility.
type PusherServiceServer interface {
// Returns all currently connected human IDs.
GetOnlineHumanIds(context.Context, *GetOnlineHumanIdsRequest) (*GetOnlineHumanIdsResponse, error)
// Returns whether specific humans are currently online.
IsOnline(context.Context, *IsOnlineRequest) (*IsOnlineResponse, error)
// Returns presence (human IDs) for specific channels.
GetChannelPresence(context.Context, *GetChannelPresenceRequest) (*GetChannelPresenceResponse, error)
mustEmbedUnimplementedPusherServiceServer()
}
// UnimplementedPusherServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedPusherServiceServer struct{}
func (UnimplementedPusherServiceServer) GetOnlineHumanIds(context.Context, *GetOnlineHumanIdsRequest) (*GetOnlineHumanIdsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetOnlineHumanIds not implemented")
}
func (UnimplementedPusherServiceServer) IsOnline(context.Context, *IsOnlineRequest) (*IsOnlineResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method IsOnline not implemented")
}
func (UnimplementedPusherServiceServer) GetChannelPresence(context.Context, *GetChannelPresenceRequest) (*GetChannelPresenceResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetChannelPresence not implemented")
}
func (UnimplementedPusherServiceServer) mustEmbedUnimplementedPusherServiceServer() {}
func (UnimplementedPusherServiceServer) testEmbeddedByValue() {}
// UnsafePusherServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to PusherServiceServer will
// result in compilation errors.
type UnsafePusherServiceServer interface {
mustEmbedUnimplementedPusherServiceServer()
}
func RegisterPusherServiceServer(s grpc.ServiceRegistrar, srv PusherServiceServer) {
// If the following call pancis, it indicates UnimplementedPusherServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&PusherService_ServiceDesc, srv)
}
func _PusherService_GetOnlineHumanIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetOnlineHumanIdsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PusherServiceServer).GetOnlineHumanIds(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: PusherService_GetOnlineHumanIds_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(PusherServiceServer).GetOnlineHumanIds(ctx, req.(*GetOnlineHumanIdsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _PusherService_IsOnline_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(IsOnlineRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PusherServiceServer).IsOnline(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: PusherService_IsOnline_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(PusherServiceServer).IsOnline(ctx, req.(*IsOnlineRequest))
}
return interceptor(ctx, in, info, handler)
}
func _PusherService_GetChannelPresence_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetChannelPresenceRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PusherServiceServer).GetChannelPresence(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: PusherService_GetChannelPresence_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(PusherServiceServer).GetChannelPresence(ctx, req.(*GetChannelPresenceRequest))
}
return interceptor(ctx, in, info, handler)
}
// PusherService_ServiceDesc is the grpc.ServiceDesc for PusherService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var PusherService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "llink.pusher.PusherService",
HandlerType: (*PusherServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetOnlineHumanIds",
Handler: _PusherService_GetOnlineHumanIds_Handler,
},
{
MethodName: "IsOnline",
Handler: _PusherService_IsOnline_Handler,
},
{
MethodName: "GetChannelPresence",
Handler: _PusherService_GetChannelPresence_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "llink/pusher/pusher.proto",
}
+104 -23
View File
@@ -1,108 +1,189 @@
module github.com/flowy-live/llink module github.com/flowy-live/llink
go 1.25 go 1.25.0
require ( require (
cloud.google.com/go/firestore v1.21.0
cloud.google.com/go/storage v1.59.1 cloud.google.com/go/storage v1.59.1
firebase.google.com/go/v4 v4.19.0
github.com/deepgram/deepgram-go-sdk/v3 v3.5.0
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2 github.com/jackc/pgx/v5 v5.7.2
github.com/livekit/protocol v1.45.1
github.com/livekit/server-sdk-go/v2 v2.16.1
github.com/redis/go-redis/v9 v9.17.2 github.com/redis/go-redis/v9 v9.17.2
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v85 v85.0.1
github.com/testcontainers/testcontainers-go v0.40.0 github.com/testcontainers/testcontainers-go v0.40.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
go.jetify.com/typeid v1.3.0 go.jetify.com/typeid v1.3.0
google.golang.org/grpc v1.78.0 go.uber.org/mock v0.6.0
google.golang.org/api v0.256.0
google.golang.org/grpc v1.79.1
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
nhooyr.io/websocket v1.8.17
) )
require ( require (
cel.dev/expr v0.24.0 // indirect buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect
buf.build/go/protovalidate v1.1.2 // indirect
buf.build/go/protoyaml v0.6.0 // indirect
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/longrunning v0.7.0 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/platforms v1.0.0-rc.2 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dennwc/iters v1.2.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/docker v28.5.1+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/dvonthenen/websocket v1.5.1-dyv.2 // indirect
github.com/ebitengine/purego v0.8.4 // indirect github.com/ebitengine/purego v0.8.4 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gammazero/deque v1.2.1 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gofrs/uuid/v5 v5.2.0 // indirect github.com/gofrs/uuid/v5 v5.2.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/schema v1.3.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 // indirect
github.com/livekit/psrpc v0.7.1 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/magiconair/properties v1.8.10 // indirect github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.0 // indirect
github.com/pion/interceptor v0.1.44 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.1 // indirect
github.com/pion/sctp v1.9.2 // indirect
github.com/pion/sdp/v3 v3.0.17 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/webrtc/v4 v4.2.6 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.64.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect
golang.org/x/crypto v0.45.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/net v0.47.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.256.0 // indirect golang.org/x/tools v0.42.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.110.1 // indirect
) )
tool go.uber.org/mock/mockgen
+296 -54
View File
@@ -1,5 +1,11 @@
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/go/protovalidate v1.1.2 h1:83vYHoY8f34hB8MeitGaYE3CGVPFxwdEUuskh5qQpA0=
buf.build/go/protovalidate v1.1.2/go.mod h1:Ez3z+w4c+wG+EpW8ovgZaZPnPl2XVF6kaxgcv1NG/QE=
buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w=
buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q=
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
@@ -8,6 +14,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
@@ -22,10 +30,12 @@ cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
@@ -34,88 +44,149 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deepgram/deepgram-go-sdk/v3 v3.5.0 h1:ug48j1DVNRKrkXti18/aFT3NP5HV2Q2CN3QMwTvHmy4=
github.com/deepgram/deepgram-go-sdk/v3 v3.5.0/go.mod h1:wVr0PDvlJFWVLUmf65u+K80SJVf/PUWvkFFubGPW/As=
github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo=
github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v29.0.0+incompatible h1:KgsN2RUFMNM8wChxryicn4p46BdQWpXOA1XLGBGPGAw=
github.com/docker/cli v29.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dvonthenen/websocket v1.5.1-dyv.2 h1:OXlWJJkeHt8k4+MEI0Y8SQjY2ihHYD2z/tI7sZZfsnA=
github.com/dvonthenen/websocket v1.5.1-dyv.2/go.mod h1:q2GbopbpFJvBP4iqVvqwwahVmvu2HnCfdqCWDoQVKMM=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofrs/uuid/v5 v5.2.0 h1:qw1GMx6/y8vhVsx626ImfKMuS5CvJmhIKKtuyvfajMM= github.com/gofrs/uuid/v5 v5.2.0 h1:qw1GMx6/y8vhVsx626ImfKMuS5CvJmhIKKtuyvfajMM=
github.com/gofrs/uuid/v5 v5.2.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gofrs/uuid/v5 v5.2.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gorilla/schema v1.3.0 h1:rbciOzXAx3IB8stEFnfTwO3sYa6EWlQk79XdyustPDA=
github.com/gorilla/schema v1.3.0/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -124,24 +195,51 @@ github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 h1:dzCBxOGLLWVtQhL7OYK2EGN+5Q+23Mq/jfz4vQisirA=
github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22/go.mod h1:mSNtYzSf6iY9xM3UX42VEI+STHvMgHmrYzEHPcdhB8A=
github.com/livekit/protocol v1.45.1 h1:4cbynsPZW32gS2z6nUWfAfr4YaTUwZSKUiLpSpjX+lQ=
github.com/livekit/protocol v1.45.1/go.mod h1:63AUi0vQak6Y6gPqSBHLc+ExYTUwEqF/m4b2IRW1iO0=
github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw=
github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk=
github.com/livekit/server-sdk-go/v2 v2.16.1 h1:ZkIA9OdVvQ6Up1uW/RtQ0YJUgYMJ6+ywOmDg0jX7bTg=
github.com/livekit/server-sdk-go/v2 v2.16.1/go.mod h1:oQbYijcbPzfjBAOzoq7tz9Ktqur8JNRCd923VP8xOQQ=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/moby/client v0.1.0 h1:nt+hn6O9cyJQqq5UWnFGqsZRTS/JirUqzPjEl0Bdc/8=
github.com/moby/moby/client v0.1.0/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -152,14 +250,60 @@ github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lkFQr4=
github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI=
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw=
github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4=
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo=
github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=
github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/webrtc/v4 v4.2.6 h1:e9H/du7PbYA2qMJkqKp9Ou2z5Igb/6qbKSeEeUCVv0M=
github.com/pion/webrtc/v4 v4.2.6/go.mod h1:+GAy0jwidoZAHsgjsx77sH09spnV0YWjpB3ROAXmz5A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
@@ -169,12 +313,26 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
@@ -186,6 +344,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v85 v85.0.1 h1:vlIo5VHrR9GkYneH5D9YGOPwNDRD6LW/THhtx9zNs6M=
github.com/stripe/stripe-go/v85 v85.0.1/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
@@ -194,71 +354,149 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.jetify.com/typeid v1.3.0 h1:fuWV7oxO4mSsgpxwhaVpFXgt0IfjogR29p+XAjDCVKY= go.jetify.com/typeid v1.3.0 h1:fuWV7oxO4mSsgpxwhaVpFXgt0IfjogR29p+XAjDCVKY=
go.jetify.com/typeid v1.3.0/go.mod h1:CtVGyt2+TSp4Rq5+ARLvGsJqdNypKBAC6INQ9TLPlmk= go.jetify.com/typeid v1.3.0/go.mod h1:CtVGyt2+TSp4Rq5+ARLvGsJqdNypKBAC6INQ9TLPlmk=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI= google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964= google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc= google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc=
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc= google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -271,3 +509,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
+44
View File
@@ -0,0 +1,44 @@
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
)
type SessionReader interface {
// GetSession returns ErrSessionNotFound if no valid session.
GetSession(ctx context.Context, sessionToken string) (*Session, error)
}
type sessionReaderImpl struct {
redisClient *redis.Client
}
// Exposes the concrete type so authServiceImpl can embed it without
// hiding redisClient behind the SessionReader interface.
func newSessionReader(redisClient *redis.Client) *sessionReaderImpl {
return &sessionReaderImpl{redisClient: redisClient}
}
func NewSessionReader(redisClient *redis.Client) SessionReader {
return newSessionReader(redisClient)
}
func (r *sessionReaderImpl) GetSession(ctx context.Context, token string) (*Session, error) {
sessionInfo, err := r.redisClient.Get(ctx, token).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, ErrSessionNotFound
}
return nil, fmt.Errorf("error getting session: %w", err)
}
var session Session
err = json.Unmarshal([]byte(sessionInfo), &session)
return &session, nil
}
+53 -35
View File
@@ -2,16 +2,20 @@ package auth
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"time" "time"
"github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal/utils"
firebaseauth "firebase.google.com/go/v4/auth"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.jetify.com/typeid" "go.jetify.com/typeid"
pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal/utils"
) )
const ( const (
@@ -37,34 +41,54 @@ func newSessionToken() (sessionToken, error) {
return typeid.New[sessionToken]() return typeid.New[sessionToken]()
} }
type Session struct {
Email string `json:"email"`
HumanId string `json:"human_id"`
}
type AuthService interface { type AuthService interface {
// RequestSignInCode generates a code and emails it to the provided email. SessionReader
// To retrieve a session, client must verify with VerifySignInCode.
// RequestSignInCode emails a one-time code; the client redeems it via VerifySignInCode.
RequestSignInCode(ctx context.Context, email string) error RequestSignInCode(ctx context.Context, email string) error
// VerifySignInCode returns ErrInvalidCode if incorrect code // VerifySignInCode returns ErrInvalidCode on a wrong code, otherwise creates
VerifySignInCode(ctx context.Context, email, code string) (sessionToken string, err error) // a session keyed to (email, humanId).
// GetSession returns ErrSessionNotFound if no valid session VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error)
GetSession(ctx context.Context, sessionToken string) (email string, err error) // ExtendSession returns ErrSessionNotFound if no valid session.
// ExtendSession returns ErrSessionNotFound if no valid session
ExtendSession(ctx context.Context, sessionToken string) error ExtendSession(ctx context.Context, sessionToken string) error
SignOut(ctx context.Context, sessionToken string) error SignOut(ctx context.Context, sessionToken string) error
// MintFirebaseCustomToken issues a Firebase custom token with uid=humanId and no claims.
MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error)
IsSystemAdmin(ctx context.Context, email string) bool IsSystemAdmin(ctx context.Context, email string) bool
} }
type authServiceImpl struct { type authServiceImpl struct {
redisClient *redis.Client *sessionReaderImpl
aeroSvc pbaero.PrimaryClient aeroSvc pbaero.PrimaryClient
fbAuth *firebaseauth.Client
} }
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient) AuthService { func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient, fbAuth *firebaseauth.Client) AuthService {
return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc} return &authServiceImpl{
sessionReaderImpl: newSessionReader(redisClient),
aeroSvc: aeroSvc,
fbAuth: fbAuth,
}
}
func (a *authServiceImpl) MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error) {
if humanId == "" {
return "", errors.New("humanId is required")
}
return a.fbAuth.CustomToken(ctx, humanId)
} }
func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool { func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool {
formattedEmail, err := utils.NormalizeEmail(email) formattedEmail, err := utils.NormalizeEmail(email)
if err != nil { if err != nil {
slog.Error("problem validating email", "error", err) flog.Error("problem validating email", "error", err)
return false return false
} }
@@ -88,12 +112,12 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e
err = a.redisClient.Set(ctx, formattedEmail, code, codeExpiry).Err() err = a.redisClient.Set(ctx, formattedEmail, code, codeExpiry).Err()
if err != nil { if err != nil {
slog.Error("error setting code in redis", "error", err) flog.Error("error setting code in redis", "error", err)
return fmt.Errorf("error storing sign-in code: %w", err) return fmt.Errorf("error storing sign-in code: %w", err)
} }
message := fmt.Sprintf("Here is your one-time code for signing into Flowy: %s\n\nPlease do not share this with anyone.\n\nBest, \nFlowy Team", code) message := fmt.Sprintf("Here is your one-time code for signing into Flowy: %s\n\nPlease do not share this with anyone.\n\nBest, \nFlowy Team", code)
subject := fmt.Sprintf("Sign In - Your One-Time Code for Flowy.llink") subject := fmt.Sprint("Sign In - Your One-Time Code for Flowy.llink")
_, err = a.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{ _, err = a.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: []string{formattedEmail}, ToEmails: []string{formattedEmail},
Subject: subject, Subject: subject,
@@ -107,11 +131,11 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e
return fmt.Errorf("an error occurred while sending the email: %w", err) return fmt.Errorf("an error occurred while sending the email: %w", err)
} }
slog.Info("sent sign in code", "email", formattedEmail) flog.Info("sent sign in code", "email", formattedEmail)
return nil return nil
} }
func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code string) (string, error) { func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, humanId string) (string, error) {
formattedEmail, err := utils.NormalizeEmail(email) formattedEmail, err := utils.NormalizeEmail(email)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid email: %w", err) return "", fmt.Errorf("invalid email: %w", err)
@@ -122,7 +146,7 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code stri
if errors.Is(err, redis.Nil) { if errors.Is(err, redis.Nil) {
return "", ErrInvalidCode return "", ErrInvalidCode
} }
slog.Error("error getting code from redis", "error", err) flog.Error("error getting code from redis", "error", err)
return "", fmt.Errorf("error verifying code: %w", err) return "", fmt.Errorf("error verifying code: %w", err)
} }
@@ -131,10 +155,10 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code stri
} }
if err := a.redisClient.Del(ctx, formattedEmail).Err(); err != nil { if err := a.redisClient.Del(ctx, formattedEmail).Err(); err != nil {
slog.Error("error deleting code from redis", "error", err) flog.Error("error deleting code from redis", "error", err)
} }
token, err := a.createSession(ctx, formattedEmail) token, err := a.createSession(ctx, formattedEmail, humanId)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -142,18 +166,6 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code stri
return token, nil return token, nil
} }
func (a *authServiceImpl) GetSession(ctx context.Context, token string) (string, error) {
email, err := a.redisClient.Get(ctx, token).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return "", ErrSessionNotFound
}
return "", fmt.Errorf("error getting session: %w", err)
}
return email, nil
}
func (a *authServiceImpl) ExtendSession(ctx context.Context, token string) error { func (a *authServiceImpl) ExtendSession(ctx context.Context, token string) error {
ttl, err := a.redisClient.TTL(ctx, token).Result() ttl, err := a.redisClient.TTL(ctx, token).Result()
if err != nil { if err != nil {
@@ -180,7 +192,7 @@ func (a *authServiceImpl) SignOut(ctx context.Context, token string) error {
return nil return nil
} }
func (a *authServiceImpl) createSession(ctx context.Context, email string) (string, error) { func (a *authServiceImpl) createSession(ctx context.Context, email, humanId string) (string, error) {
formattedEmail, err := utils.NormalizeEmail(email) formattedEmail, err := utils.NormalizeEmail(email)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid email: %w", err) return "", fmt.Errorf("invalid email: %w", err)
@@ -191,7 +203,13 @@ func (a *authServiceImpl) createSession(ctx context.Context, email string) (stri
return "", fmt.Errorf("error generating session token: %w", err) return "", fmt.Errorf("error generating session token: %w", err)
} }
if err := a.redisClient.Set(ctx, token.String(), formattedEmail, sessionExpiry).Err(); err != nil { session := Session{Email: formattedEmail, HumanId: humanId}
data, err := json.Marshal(session)
if err != nil {
return "", fmt.Errorf("error marshaling session: %w", err)
}
if err := a.redisClient.Set(ctx, token.String(), data, sessionExpiry).Err(); err != nil {
return "", fmt.Errorf("error storing session: %w", err) return "", fmt.Errorf("error storing session: %w", err)
} }
+37
View File
@@ -0,0 +1,37 @@
package billing
import (
"fmt"
"strings"
)
type Config struct {
SecretKey string
WebhookSecret string
PriceMonthlyID string
PriceAnnualID string
SuccessURL string
CancelURL string
}
func (c Config) Validate() error {
required := map[string]string{
"SecretKey": c.SecretKey,
"WebhookSecret": c.WebhookSecret,
"PriceMonthlyID": c.PriceMonthlyID,
"PriceAnnualID": c.PriceAnnualID,
"SuccessURL": c.SuccessURL,
"CancelURL": c.CancelURL,
}
var missing []string
for name, value := range required {
if value == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return fmt.Errorf("billing config missing: %s", strings.Join(missing, ", "))
}
return nil
}
+145
View File
@@ -0,0 +1,145 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./service.go
//
// Generated by this command:
//
// mockgen -source ./service.go -destination ./mocks/service.go
//
// Package mock_billing is a generated GoMock package.
package mock_billing
import (
context "context"
reflect "reflect"
time "time"
billing "github.com/flowy-live/llink/internal/billing"
gomock "go.uber.org/mock/gomock"
)
// MockService is a mock of Service interface.
type MockService struct {
ctrl *gomock.Controller
recorder *MockServiceMockRecorder
isgomock struct{}
}
// MockServiceMockRecorder is the mock recorder for MockService.
type MockServiceMockRecorder struct {
mock *MockService
}
// NewMockService creates a new mock instance.
func NewMockService(ctrl *gomock.Controller) *MockService {
mock := &MockService{ctrl: ctrl}
mock.recorder = &MockServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockService) EXPECT() *MockServiceMockRecorder {
return m.recorder
}
// CreateCheckoutSession mocks base method.
func (m *MockService) CreateCheckoutSession(ctx context.Context, p billing.CheckoutParams) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateCheckoutSession", ctx, p)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CreateCheckoutSession indicates an expected call of CreateCheckoutSession.
func (mr *MockServiceMockRecorder) CreateCheckoutSession(ctx, p any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCheckoutSession", reflect.TypeOf((*MockService)(nil).CreateCheckoutSession), ctx, p)
}
// CreatePortalSession mocks base method.
func (m *MockService) CreatePortalSession(ctx context.Context, networkID string) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreatePortalSession", ctx, networkID)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CreatePortalSession indicates an expected call of CreatePortalSession.
func (mr *MockServiceMockRecorder) CreatePortalSession(ctx, networkID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePortalSession", reflect.TypeOf((*MockService)(nil).CreatePortalSession), ctx, networkID)
}
// GetStatus mocks base method.
func (m *MockService) GetStatus(ctx context.Context, networkID string) (*billing.Status, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetStatus", ctx, networkID)
ret0, _ := ret[0].(*billing.Status)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetStatus indicates an expected call of GetStatus.
func (mr *MockServiceMockRecorder) GetStatus(ctx, networkID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatus", reflect.TypeOf((*MockService)(nil).GetStatus), ctx, networkID)
}
// GetUsage mocks base method.
func (m *MockService) GetUsage(ctx context.Context, networkID string) (*billing.Usage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUsage", ctx, networkID)
ret0, _ := ret[0].(*billing.Usage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUsage indicates an expected call of GetUsage.
func (mr *MockServiceMockRecorder) GetUsage(ctx, networkID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsage", reflect.TypeOf((*MockService)(nil).GetUsage), ctx, networkID)
}
// HandleWebhook mocks base method.
func (m *MockService) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HandleWebhook", ctx, payload, signature)
ret0, _ := ret[0].(error)
return ret0
}
// HandleWebhook indicates an expected call of HandleWebhook.
func (mr *MockServiceMockRecorder) HandleWebhook(ctx, payload, signature any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleWebhook", reflect.TypeOf((*MockService)(nil).HandleWebhook), ctx, payload, signature)
}
// IncrementDailyUsage mocks base method.
func (m *MockService) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IncrementDailyUsage", ctx, networkID, at)
ret0, _ := ret[0].(error)
return ret0
}
// IncrementDailyUsage indicates an expected call of IncrementDailyUsage.
func (mr *MockServiceMockRecorder) IncrementDailyUsage(ctx, networkID, at any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementDailyUsage", reflect.TypeOf((*MockService)(nil).IncrementDailyUsage), ctx, networkID, at)
}
// SyncSeats mocks base method.
func (m *MockService) SyncSeats(ctx context.Context, networkID string, seats int) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SyncSeats", ctx, networkID, seats)
ret0, _ := ret[0].(error)
return ret0
}
// SyncSeats indicates an expected call of SyncSeats.
func (mr *MockServiceMockRecorder) SyncSeats(ctx, networkID, seats any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncSeats", reflect.TypeOf((*MockService)(nil).SyncSeats), ctx, networkID, seats)
}
+57
View File
@@ -0,0 +1,57 @@
package billing
import "time"
type Cadence string
const (
CadenceMonthly Cadence = "monthly"
CadenceAnnual Cadence = "annual"
)
func (c Cadence) IsValid() bool {
return c == CadenceMonthly || c == CadenceAnnual
}
type Plan string
const (
PlanFree Plan = "free"
PlanPro Plan = "pro"
)
// Subscription is the persisted projection of a Stripe subscription,
// reconciled on every webhook event.
type Subscription struct {
ID string
NetworkID string
StripeCustomerID string
Status string
PriceID string
Cadence Cadence
Quantity int
CancelAtPeriodEnd bool
CurrentPeriodStart time.Time
CurrentPeriodEnd time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type Status struct {
Plan Plan `json:"plan"`
PlanStatus string `json:"plan_status"`
Cadence *Cadence `json:"cadence"`
Seats int `json:"seats"` // 0 on free plan; sub.Quantity on pro
CurrentPeriodEnd *time.Time `json:"current_period_end"`
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
PriceMonthlyCents int64 `json:"price_monthly_cents"`
PriceAnnualCents int64 `json:"price_annual_cents"`
}
type CheckoutParams struct {
NetworkID string
AdminHumanID string
AdminEmail string
Cadence Cadence
Seats int
}
+112
View File
@@ -0,0 +1,112 @@
package billing
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var errNotFound = errors.New("not found")
type repository interface {
getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error)
upsertSubscription(ctx context.Context, sub *Subscription) error
deleteSubscriptionByID(ctx context.Context, subscriptionID string) error
getStripeCustomerID(ctx context.Context, networkID string) (string, error)
setStripeCustomerID(ctx context.Context, networkID, customerID string) error
}
type repositoryImpl struct {
pool *pgxpool.Pool
}
func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool}
}
const subscriptionColumns = `id, network_id, stripe_customer_id, status, price_id, cadence, quantity, cancel_at_period_end, current_period_start, current_period_end, created_at, updated_at`
func scanSubscription(row pgx.Row, s *Subscription) error {
return row.Scan(
&s.ID, &s.NetworkID, &s.StripeCustomerID, &s.Status, &s.PriceID,
&s.Cadence, &s.Quantity, &s.CancelAtPeriodEnd,
&s.CurrentPeriodStart, &s.CurrentPeriodEnd,
&s.CreatedAt, &s.UpdatedAt,
)
}
func (r *repositoryImpl) getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error) {
var s Subscription
err := scanSubscription(
r.pool.QueryRow(ctx,
`SELECT `+subscriptionColumns+` FROM network_subscriptions WHERE network_id = $1`,
networkID,
),
&s,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
}
if err != nil {
return nil, err
}
return &s, nil
}
func (r *repositoryImpl) upsertSubscription(ctx context.Context, sub *Subscription) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO network_subscriptions (
id, network_id, stripe_customer_id, status, price_id, cadence,
quantity, cancel_at_period_end, current_period_start, current_period_end
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (id) DO UPDATE SET
stripe_customer_id = EXCLUDED.stripe_customer_id,
status = EXCLUDED.status,
price_id = EXCLUDED.price_id,
cadence = EXCLUDED.cadence,
quantity = EXCLUDED.quantity,
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
current_period_start = EXCLUDED.current_period_start,
current_period_end = EXCLUDED.current_period_end,
updated_at = NOW()
`,
sub.ID, sub.NetworkID, sub.StripeCustomerID, sub.Status, sub.PriceID, sub.Cadence,
sub.Quantity, sub.CancelAtPeriodEnd, sub.CurrentPeriodStart, sub.CurrentPeriodEnd,
)
return err
}
func (r *repositoryImpl) deleteSubscriptionByID(ctx context.Context, subscriptionID string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM network_subscriptions WHERE id = $1`,
subscriptionID,
)
return err
}
func (r *repositoryImpl) getStripeCustomerID(ctx context.Context, networkID string) (string, error) {
var customerID string
err := r.pool.QueryRow(ctx,
`SELECT stripe_customer_id FROM network_stripe_customers WHERE network_id = $1`,
networkID,
).Scan(&customerID)
if errors.Is(err, pgx.ErrNoRows) {
return "", errNotFound
}
if err != nil {
return "", err
}
return customerID, nil
}
func (r *repositoryImpl) setStripeCustomerID(ctx context.Context, networkID, customerID string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO network_stripe_customers (network_id, stripe_customer_id)
VALUES ($1, $2)
ON CONFLICT (network_id) DO NOTHING
`, networkID, customerID)
return err
}
+290
View File
@@ -0,0 +1,290 @@
package billing
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stripe/stripe-go/v85"
billingportalsession "github.com/stripe/stripe-go/v85/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v85/checkout/session"
stripecustomer "github.com/stripe/stripe-go/v85/customer"
stripeprice "github.com/stripe/stripe-go/v85/price"
stripesub "github.com/stripe/stripe-go/v85/subscription"
)
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
type Service interface {
GetStatus(ctx context.Context, networkID string) (*Status, error)
CreateCheckoutSession(ctx context.Context, p CheckoutParams) (url string, err error)
CreatePortalSession(ctx context.Context, networkID string) (url string, err error)
// SyncSeats updates the Stripe subscription quantity with proration.
// No-op if the network has no active subscription.
SyncSeats(ctx context.Context, networkID string, seats int) error
HandleWebhook(ctx context.Context, payload []byte, signature string) error
// GetUsage reports today's freemium quota state for a network.
// Pro networks get Limit=nil (unlimited); free networks get Limit=&FreemiumDailyLimit.
GetUsage(ctx context.Context, networkID string) (*Usage, error)
IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error
}
var (
ErrNoActiveSubscription = errors.New("network has no stripe customer yet")
ErrInvalidCadence = errors.New("invalid billing cadence")
)
type serviceImpl struct {
cfg Config
repo repository
usageRepo usageRepository
priceMonthlyCents int64
priceAnnualCents int64
}
func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
stripe.Key = cfg.SecretKey
monthly, err := stripeprice.Get(cfg.PriceMonthlyID, &stripe.PriceParams{
Params: stripe.Params{Context: ctx},
})
if err != nil {
return nil, fmt.Errorf("fetch stripe monthly price: %w", err)
}
annual, err := stripeprice.Get(cfg.PriceAnnualID, &stripe.PriceParams{
Params: stripe.Params{Context: ctx},
})
if err != nil {
return nil, fmt.Errorf("fetch stripe annual price: %w", err)
}
return &serviceImpl{
cfg: cfg,
repo: newRepository(pool),
usageRepo: newUsageRepository(pool),
priceMonthlyCents: monthly.UnitAmount,
priceAnnualCents: annual.UnitAmount,
}, nil
}
// NewServiceForWorker skips Stripe client setup since workers only exercise
// the usage-tracking path. No API key required.
func NewServiceForWorker(pool *pgxpool.Pool) Service {
return &serviceImpl{
usageRepo: newUsageRepository(pool),
repo: newRepository(pool),
}
}
func (s *serviceImpl) GetStatus(ctx context.Context, networkID string) (*Status, error) {
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
if err != nil && !errors.Is(err, errNotFound) {
return nil, err
}
status := &Status{
Plan: PlanFree,
PlanStatus: "active",
PriceMonthlyCents: s.priceMonthlyCents,
PriceAnnualCents: s.priceAnnualCents,
}
if sub != nil {
cadence := sub.Cadence
periodEnd := sub.CurrentPeriodEnd
// past_due keeps access: Stripe still considers the subscription live
// during the dunning window.
switch stripe.SubscriptionStatus(sub.Status) {
case stripe.SubscriptionStatusActive,
stripe.SubscriptionStatusTrialing,
stripe.SubscriptionStatusPastDue:
status.Plan = PlanPro
}
status.PlanStatus = sub.Status
status.Cadence = &cadence
status.Seats = sub.Quantity
status.CurrentPeriodEnd = &periodEnd
status.CancelAtPeriodEnd = sub.CancelAtPeriodEnd
}
return status, nil
}
func (s *serviceImpl) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (string, error) {
if !p.Cadence.IsValid() {
return "", ErrInvalidCadence
}
if p.Seats < 1 {
return "", fmt.Errorf("seats must be >= 1")
}
customerID, err := s.ensureStripeCustomer(ctx, p)
if err != nil {
return "", fmt.Errorf("ensure stripe customer: %w", err)
}
priceID := s.cfg.PriceAnnualID
switch p.Cadence {
case CadenceAnnual:
priceID = s.cfg.PriceAnnualID
break
case CadenceMonthly:
priceID = s.cfg.PriceMonthlyID
break
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
Customer: stripe.String(customerID),
ClientReferenceID: stripe.String(p.NetworkID),
SuccessURL: stripe.String(s.cfg.SuccessURL),
CancelURL: stripe.String(s.cfg.CancelURL),
LineItems: []*stripe.CheckoutSessionLineItemParams{{
Price: stripe.String(priceID),
Quantity: stripe.Int64(int64(p.Seats)),
}},
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
Metadata: map[string]string{
"network_id": p.NetworkID,
"admin_human_id": p.AdminHumanID,
"cadence": string(p.Cadence),
},
},
}
params.Context = ctx
sess, err := checkoutsession.New(params)
if err != nil {
return "", fmt.Errorf("stripe checkout: %w", err)
}
return sess.URL, nil
}
func (s *serviceImpl) CreatePortalSession(ctx context.Context, networkID string) (string, error) {
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
if errors.Is(err, errNotFound) {
return "", ErrNoActiveSubscription
}
if err != nil {
return "", err
}
params := &stripe.BillingPortalSessionParams{
Customer: stripe.String(sub.StripeCustomerID),
ReturnURL: stripe.String(s.cfg.SuccessURL),
}
params.Context = ctx
sess, err := billingportalsession.New(params)
if err != nil {
return "", fmt.Errorf("stripe portal: %w", err)
}
return sess.URL, nil
}
func (s *serviceImpl) SyncSeats(ctx context.Context, networkID string, seats int) error {
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
if errors.Is(err, errNotFound) {
return nil
}
if err != nil {
return err
}
if sub.Quantity == seats {
return nil
}
liveSub, err := stripesub.Get(sub.ID, &stripe.SubscriptionParams{
Params: stripe.Params{Context: ctx},
})
if err != nil {
return fmt.Errorf("fetch stripe subscription: %w", err)
}
if len(liveSub.Items.Data) == 0 {
return fmt.Errorf("stripe subscription %s has no items", sub.ID)
}
params := &stripe.SubscriptionParams{
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
Items: []*stripe.SubscriptionItemsParams{{
ID: stripe.String(liveSub.Items.Data[0].ID),
Quantity: stripe.Int64(int64(seats)),
}},
}
params.Context = ctx
if _, err := stripesub.Update(sub.ID, params); err != nil {
return fmt.Errorf("update stripe subscription quantity: %w", err)
}
// customer.subscription.updated webhook arrives within seconds and
// reconciles quantity in our DB.
return nil
}
func (s *serviceImpl) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error {
return s.usageRepo.incrementDaily(ctx, networkID, at)
}
func (s *serviceImpl) GetUsage(ctx context.Context, networkID string) (*Usage, error) {
now := time.Now()
used, err := s.usageRepo.getDaily(ctx, networkID, now)
if err != nil {
return nil, fmt.Errorf("read daily usage: %w", err)
}
sub, err := s.GetStatus(ctx, networkID)
if err != nil {
return nil, fmt.Errorf("unable to get network billing status: %w", err)
}
u := &Usage{
Plan: sub.Plan,
Used: used,
ResetAt: nextUTCMidnight(now),
Limit: nil,
}
if sub.Plan == PlanFree {
limit := FreemiumDailyLimit
u.Limit = &limit
}
return u, nil
}
func nextUTCMidnight(now time.Time) time.Time {
utc := now.UTC()
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
}
func (s *serviceImpl) ensureStripeCustomer(ctx context.Context, p CheckoutParams) (string, error) {
existing, err := s.repo.getStripeCustomerID(ctx, p.NetworkID)
if err == nil {
return existing, nil
}
if !errors.Is(err, errNotFound) {
return "", err
}
params := &stripe.CustomerParams{
Email: stripe.String(p.AdminEmail),
Metadata: map[string]string{
"network_id": p.NetworkID,
"admin_human_id": p.AdminHumanID,
},
}
params.Context = ctx
cust, err := stripecustomer.New(params)
if err != nil {
return "", fmt.Errorf("create stripe customer: %w", err)
}
if err := s.repo.setStripeCustomerID(ctx, p.NetworkID, cust.ID); err != nil {
return "", fmt.Errorf("persist stripe customer id: %w", err)
}
return cust.ID, nil
}
+13
View File
@@ -0,0 +1,13 @@
package billing
import "time"
// Per-network daily cap on the free plan. Unit-agnostic.
const FreemiumDailyLimit = 50
type Usage struct {
Plan Plan `json:"plan"`
Used int `json:"used"`
Limit *int `json:"limit"` // nil = unlimited (pro)
ResetAt time.Time `json:"reset_at"`
}
+49
View File
@@ -0,0 +1,49 @@
package billing
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type usageRepository interface {
incrementDaily(ctx context.Context, networkID string, at time.Time) error
getDaily(ctx context.Context, networkID string, at time.Time) (int, error)
}
type usageRepositoryImpl struct {
pool *pgxpool.Pool
}
func newUsageRepository(pool *pgxpool.Pool) usageRepository {
return &usageRepositoryImpl{pool: pool}
}
func (r *usageRepositoryImpl) incrementDaily(ctx context.Context, networkID string, at time.Time) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO network_message_usage (network_id, usage_date, message_count, updated_at)
VALUES ($1, ($2 AT TIME ZONE 'UTC')::date, 1, NOW())
ON CONFLICT (network_id, usage_date) DO UPDATE
SET message_count = network_message_usage.message_count + 1,
updated_at = NOW()
`, networkID, at)
return err
}
func (r *usageRepositoryImpl) getDaily(ctx context.Context, networkID string, at time.Time) (int, error) {
var count int
err := r.pool.QueryRow(ctx, `
SELECT message_count FROM network_message_usage
WHERE network_id = $1 AND usage_date = ($2 AT TIME ZONE 'UTC')::date
`, networkID, at).Scan(&count)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
return count, nil
}
+116
View File
@@ -0,0 +1,116 @@
package billing
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/stripe/stripe-go/v85"
"github.com/stripe/stripe-go/v85/webhook"
)
func (s *serviceImpl) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
event, err := webhook.ConstructEvent(payload, signature, s.cfg.WebhookSecret)
if err != nil {
return fmt.Errorf("verify stripe signature: %w", err)
}
flog.Info("stripe webhook", "type", event.Type, "id", event.ID)
switch event.Type {
case "checkout.session.completed":
// subscription.created fires right after with full detail; we handle
// the subscription there.
return nil
case "customer.subscription.created", "customer.subscription.updated":
return s.handleSubscriptionUpsert(ctx, event)
case "customer.subscription.deleted":
return s.handleSubscriptionDeleted(ctx, event)
default:
return nil
}
}
func (s *serviceImpl) handleSubscriptionUpsert(ctx context.Context, event stripe.Event) error {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return fmt.Errorf("decode subscription: %w", err)
}
networkID := sub.Metadata["network_id"]
if networkID == "" {
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
}
local, err := subscriptionFromStripe(&sub, networkID)
if err != nil {
return err
}
if err := s.repo.upsertSubscription(ctx, local); err != nil {
return fmt.Errorf("upsert subscription: %w", err)
}
return nil
}
func (s *serviceImpl) handleSubscriptionDeleted(ctx context.Context, event stripe.Event) error {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return fmt.Errorf("decode subscription: %w", err)
}
networkID := sub.Metadata["network_id"]
if networkID == "" {
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
}
if err := s.repo.deleteSubscriptionByID(ctx, sub.ID); err != nil {
return fmt.Errorf("delete subscription: %w", err)
}
return nil
}
func subscriptionFromStripe(sub *stripe.Subscription, networkID string) (*Subscription, error) {
if len(sub.Items.Data) == 0 {
return nil, fmt.Errorf("subscription %s has no items", sub.ID)
}
item := sub.Items.Data[0]
cadence, err := cadenceFromInterval(item.Price)
if err != nil {
return nil, fmt.Errorf("subscription %s: %w", sub.ID, err)
}
var customerID string
if sub.Customer != nil {
customerID = sub.Customer.ID
}
return &Subscription{
ID: sub.ID,
NetworkID: networkID,
StripeCustomerID: customerID,
Status: string(sub.Status),
PriceID: item.Price.ID,
Cadence: cadence,
Quantity: int(item.Quantity),
CancelAtPeriodEnd: sub.CancelAtPeriodEnd,
CurrentPeriodStart: time.Unix(item.CurrentPeriodStart, 0).UTC(),
CurrentPeriodEnd: time.Unix(item.CurrentPeriodEnd, 0).UTC(),
}, nil
}
func cadenceFromInterval(price *stripe.Price) (Cadence, error) {
if price == nil || price.Recurring == nil {
return "", fmt.Errorf("price is not recurring")
}
switch price.Recurring.Interval {
case stripe.PriceRecurringIntervalMonth:
return CadenceMonthly, nil
case stripe.PriceRecurringIntervalYear:
return CadenceAnnual, nil
default:
return "", fmt.Errorf("unsupported price interval %q", price.Recurring.Interval)
}
}
+3
View File
@@ -0,0 +1,3 @@
package constants
const FlowyAdminEmail = "team@flowylabs.ai"
@@ -2,9 +2,10 @@ package db
import ( import (
"context" "context"
"log/slog"
"os" "os"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
@@ -21,24 +22,24 @@ func Pool() *pgxpool.Pool {
func Init() { func Init() {
connString := os.Getenv("LLINK_POSTGRES_CONNECTION_URL") connString := os.Getenv("LLINK_POSTGRES_CONNECTION_URL")
if connString == "" { if connString == "" {
slog.Error("must provide LLINK_POSTGRES_CONNECTION_URL in env") flog.Error("must provide LLINK_POSTGRES_CONNECTION_URL in env")
os.Exit(1) os.Exit(1)
} }
dbpool, err := pgxpool.New(context.Background(), connString) dbpool, err := pgxpool.New(context.Background(), connString)
if err != nil { if err != nil {
slog.Error("unable to create connection pool", "error", err) flog.Error("unable to create connection pool", "error", err)
os.Exit(1) os.Exit(1)
} }
var greeting string var greeting string
err = dbpool.QueryRow(context.Background(), "select 'Hello, world!'").Scan(&greeting) err = dbpool.QueryRow(context.Background(), "select 'Hello, world!'").Scan(&greeting)
if err != nil { if err != nil {
slog.Error("queryRow failed", "error", err) flog.Error("queryRow failed", "error", err)
os.Exit(1) os.Exit(1)
} }
slog.Info("successfully connected to database", "greeting", greeting) flog.Info("successfully connected to database", "greeting", greeting)
db = dbpool db = dbpool
} }
+8
View File
@@ -0,0 +1,8 @@
package db
// FIX: move to a dedicated Redis instance. The high DB numbers exist because
// this instance is shared with helios.
const (
RedisDBAuth = 4 // auth sessions
RedisDBPusher = 5 // presence, pub/sub
)
+8 -5
View File
@@ -2,7 +2,6 @@ package depot
import "time" import "time"
// Object represents a stored object in the depot
type Object struct { type Object struct {
ID string ID string
Name string Name string
@@ -14,22 +13,26 @@ type Object struct {
CreatedAt time.Time CreatedAt time.Time
} }
// PrepareUploadInput represents the input for preparing an upload
type PrepareUploadInput struct { type PrepareUploadInput struct {
Prefix string // Optional prefix for organizing objects (e.g., network_id) Prefix string // optional, e.g. network_id
Name string Name string
ContentType string ContentType string
ContentLength int64 ContentLength int64
} }
// PrepareUploadResult represents the result of preparing an upload
type PrepareUploadResult struct { type PrepareUploadResult struct {
ObjectID string ObjectID string
UploadURL string UploadURL string
UploadHeaders map[string]string UploadHeaders map[string]string
} }
// Config holds configuration for the depot service // For server-side direct uploads (no presigned URL).
type CreateFromReaderInput struct {
Prefix string // optional, e.g. network_id
Name string
ContentType string
}
type Config struct { type Config struct {
GoogleServiceAccountEmail string GoogleServiceAccountEmail string
BucketName string BucketName string
+64 -19
View File
@@ -4,9 +4,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog" "io"
"time" "time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/storage" "cloud.google.com/go/storage"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -20,6 +22,7 @@ const (
type Service interface { type Service interface {
PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error) PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error)
ConfirmUpload(ctx context.Context, objectID string) (*Object, error) ConfirmUpload(ctx context.Context, objectID string) (*Object, error)
CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error)
GetByID(ctx context.Context, objectID string) (*Object, error) GetByID(ctx context.Context, objectID string) (*Object, error)
GetDownloadURL(ctx context.Context, objectID string) (string, error) GetDownloadURL(ctx context.Context, objectID string) (string, error)
Delete(ctx context.Context, objectID string) error Delete(ctx context.Context, objectID string) error
@@ -47,7 +50,7 @@ func NewService(pool *pgxpool.Pool, storageClient *storage.Client, config Config
} }
if config.GoogleServiceAccountEmail == "" { if config.GoogleServiceAccountEmail == "" {
slog.Error("GoogleServiceAccountEmail is not set in config. Signed URLs may not work if the storage client is not properly authenticated with a service account.") flog.Error("GoogleServiceAccountEmail is not set in config. Signed URLs may not work if the storage client is not properly authenticated with a service account.")
panic("GoogleServiceAccountEmail is required for signed URL generation") panic("GoogleServiceAccountEmail is required for signed URL generation")
} }
@@ -72,10 +75,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive")) return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive"))
} }
// Generate object key: {prefix}/{uuid}/{filename} // {prefix}/{uuid}/{filename}
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name) objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
// Create the database record (contains_content = false initially) // Row is written first with contains_content=false; ConfirmUpload flips it.
obj := &Object{ obj := &Object{
Name: input.Name, Name: input.Name,
ContentType: input.ContentType, ContentType: input.ContentType,
@@ -90,8 +93,7 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
return nil, err return nil, err
} }
// Generate a signed URL for uploading with Content-Length enforcement // Content-Length is part of the signature, so the client must send it verbatim.
// The Headers field specifies headers that MUST be included in the upload request
contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength) contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength)
uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{ uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{
GoogleAccessID: s.googleServiceAccountEmail, GoogleAccessID: s.googleServiceAccountEmail,
@@ -101,10 +103,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
Headers: []string{contentLengthHeader}, Headers: []string{contentLengthHeader},
}) })
if err != nil { if err != nil {
slog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey) flog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
// Clean up the database record if we can't generate the URL // Roll back the placeholder row.
if delErr := s.repo.delete(ctx, created.ID); delErr != nil { if delErr := s.repo.delete(ctx, created.ID); delErr != nil {
slog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID) flog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
} }
return nil, err return nil, err
} }
@@ -128,22 +130,19 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
return nil, err return nil, err
} }
// Verify the object exists in GCS and check its size matches expected
attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx) attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx)
if err != nil { if err != nil {
if errors.Is(err, storage.ErrObjectNotExist) { if errors.Is(err, storage.ErrObjectNotExist) {
return nil, errors.Join(ErrNotFound, errors.New("object not found in storage")) return nil, errors.Join(ErrNotFound, errors.New("object not found in storage"))
} }
slog.Error("failed to get GCS object attrs", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey) flog.Error("failed to get GCS object attrs", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
return nil, err return nil, err
} }
// Verify content length matches what was declared
if attrs.Size != obj.ContentLength { if attrs.Size != obj.ContentLength {
return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size)) return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size))
} }
// Mark as containing content
if err := s.repo.setContainsContent(ctx, objectID, true); err != nil { if err := s.repo.setContainsContent(ctx, objectID, true); err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -151,10 +150,58 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
return nil, err return nil, err
} }
// Fetch and return the updated object
return s.repo.getByID(ctx, objectID) return s.repo.getByID(ctx, objectID)
} }
// CreateFromReader streams bytes straight to GCS and writes the row in one
// shot — no signed URL, no client round-trip. For server-side flows that
// already have the bytes (e.g. transcoded variants).
func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) {
if input.Name == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("name is required"))
}
if input.ContentType == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("content_type is required"))
}
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
w := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx)
w.ContentType = input.ContentType
if _, err := io.Copy(w, body); err != nil {
// Always release the writer; surface the copy error, not Close's.
if cerr := w.Close(); cerr != nil {
flog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey)
}
flog.Error("failed to stream object to GCS", "error", err, "bucket", s.bucketName, "object_key", objectKey)
return nil, err
}
if err := w.Close(); err != nil {
flog.Error("failed to close GCS writer", "error", err, "bucket", s.bucketName, "object_key", objectKey)
return nil, err
}
obj := &Object{
Name: input.Name,
ContentType: input.ContentType,
ContentLength: w.Attrs().Size,
BucketName: s.bucketName,
ObjectKey: objectKey,
ContainsContent: true,
}
created, err := s.repo.create(ctx, obj)
if err != nil {
// Best-effort: drop the now-untracked GCS object.
if delErr := s.storageClient.Bucket(s.bucketName).Object(objectKey).Delete(ctx); delErr != nil {
flog.Warn("failed to clean up GCS object after db create failure", "error", delErr, "object_key", objectKey)
}
return nil, err
}
return created, nil
}
func (s *serviceImpl) GetByID(ctx context.Context, objectID string) (*Object, error) { func (s *serviceImpl) GetByID(ctx context.Context, objectID string) (*Object, error) {
obj, err := s.repo.getByID(ctx, objectID) obj, err := s.repo.getByID(ctx, objectID)
if err != nil { if err != nil {
@@ -175,14 +222,13 @@ func (s *serviceImpl) GetDownloadURL(ctx context.Context, objectID string) (stri
return "", err return "", err
} }
// Generate a signed URL for downloading
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{ downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
GoogleAccessID: s.googleServiceAccountEmail, GoogleAccessID: s.googleServiceAccountEmail,
Method: "GET", Method: "GET",
Expires: time.Now().Add(s.downloadURLExpiry), Expires: time.Now().Add(s.downloadURLExpiry),
}) })
if err != nil { if err != nil {
slog.Error("failed to generate signed download URL", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey) flog.Error("failed to generate signed download URL", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
return "", err return "", err
} }
@@ -198,14 +244,13 @@ func (s *serviceImpl) Delete(ctx context.Context, objectID string) error {
return err return err
} }
// Delete from GCS (ignore not found errors) // GCS first so we don't strand an object after the row vanishes; missing object is fine.
gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx) gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx)
if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) { if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) {
slog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey) flog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
return gcsErr return gcsErr
} }
// Delete from database
if err := s.repo.delete(ctx, objectID); err != nil { if err := s.repo.delete(ctx, objectID); err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
return ErrNotFound return ErrNotFound
-250
View File
@@ -1,250 +0,0 @@
package depot_test
import (
"context"
"os"
"testing"
"time"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/testhelper"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
)
var dbPool *pgxpool.Pool
func TestMain(m *testing.M) {
dbPool = testhelper.SetupTestDB()
defer testhelper.TeardownTestDB()
ret := m.Run()
os.Exit(ret)
}
// TestDepotRepository tests the repository layer directly
// These tests can run without GCS since they only test database operations
func TestDepotRepository_CreateAndGet(t *testing.T) {
ctx := context.Background()
// Create a depot object directly in the database for testing
var id string
err := dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_test123", "test-file.txt", "text/plain", int64(1024), "test-bucket", "test-key-123", false,
).Scan(&id)
assert.NoError(t, err)
assert.Equal(t, "dpo_test123", id)
// Query the object back
var obj struct {
ID string
Name string
ContentType string
ContentLength int64
BucketName string
ObjectKey string
ContainsContent bool
CreatedAt time.Time
}
err = dbPool.QueryRow(ctx,
`SELECT id, name, content_type, content_length, bucket_name, object_key, contains_content, created_at
FROM depot_objects WHERE id = $1`,
id,
).Scan(&obj.ID, &obj.Name, &obj.ContentType, &obj.ContentLength,
&obj.BucketName, &obj.ObjectKey, &obj.ContainsContent, &obj.CreatedAt)
assert.NoError(t, err)
assert.Equal(t, "test-file.txt", obj.Name)
assert.Equal(t, "text/plain", obj.ContentType)
assert.Equal(t, int64(1024), obj.ContentLength)
assert.Equal(t, "test-bucket", obj.BucketName)
assert.Equal(t, "test-key-123", obj.ObjectKey)
assert.False(t, obj.ContainsContent)
assert.False(t, obj.CreatedAt.IsZero())
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
assert.NoError(t, err)
}
func TestDepotRepository_ConfirmUpload(t *testing.T) {
ctx := context.Background()
// Create a depot object
var id string
err := dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_confirm123", "confirm-file.txt", "text/plain", int64(2048), "test-bucket", "confirm-key", false,
).Scan(&id)
assert.NoError(t, err)
// Verify it starts with contains_content = false
var containsContent bool
err = dbPool.QueryRow(ctx,
`SELECT contains_content FROM depot_objects WHERE id = $1`,
id,
).Scan(&containsContent)
assert.NoError(t, err)
assert.False(t, containsContent)
// Confirm upload
_, err = dbPool.Exec(ctx,
`UPDATE depot_objects SET contains_content = TRUE WHERE id = $1`,
id,
)
assert.NoError(t, err)
// Verify it's now true
err = dbPool.QueryRow(ctx,
`SELECT contains_content FROM depot_objects WHERE id = $1`,
id,
).Scan(&containsContent)
assert.NoError(t, err)
assert.True(t, containsContent)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
assert.NoError(t, err)
}
func TestDepotRepository_Delete(t *testing.T) {
ctx := context.Background()
// Create a depot object
var id string
err := dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_delete123", "delete-file.txt", "text/plain", int64(512), "test-bucket", "delete-key", false,
).Scan(&id)
assert.NoError(t, err)
// Delete it
result, err := dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
assert.NoError(t, err)
assert.Equal(t, int64(1), result.RowsAffected())
// Verify it's gone
var count int
err = dbPool.QueryRow(ctx,
`SELECT COUNT(*) FROM depot_objects WHERE id = $1`,
id,
).Scan(&count)
assert.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestDepotRepository_Exists(t *testing.T) {
ctx := context.Background()
// Create a depot object
var id string
err := dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_exists123", "exists-file.txt", "text/plain", int64(256), "test-bucket", "exists-key", true,
).Scan(&id)
assert.NoError(t, err)
// Check exists
var exists bool
err = dbPool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM depot_objects WHERE id = $1)`,
id,
).Scan(&exists)
assert.NoError(t, err)
assert.True(t, exists)
// Check non-existent
err = dbPool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM depot_objects WHERE id = $1)`,
"dpo_nonexistent",
).Scan(&exists)
assert.NoError(t, err)
assert.False(t, exists)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
assert.NoError(t, err)
}
func TestDepotRepository_OrphanedIndex(t *testing.T) {
ctx := context.Background()
// Create an orphaned object (contains_content = false)
var orphanID string
err := dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_orphan123", "orphan-file.txt", "text/plain", int64(128), "test-bucket", "orphan-key", false,
).Scan(&orphanID)
assert.NoError(t, err)
// Create a confirmed object (contains_content = true)
var confirmedID string
err = dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_confirmed123", "confirmed-file.txt", "text/plain", int64(128), "test-bucket", "confirmed-key", true,
).Scan(&confirmedID)
assert.NoError(t, err)
// Query orphaned objects using the index
rows, err := dbPool.Query(ctx,
`SELECT id FROM depot_objects WHERE contains_content = FALSE`)
assert.NoError(t, err)
defer rows.Close()
var orphanedIDs []string
for rows.Next() {
var id string
err := rows.Scan(&id)
assert.NoError(t, err)
orphanedIDs = append(orphanedIDs, id)
}
// Our orphan should be in the list
assert.Contains(t, orphanedIDs, orphanID)
assert.NotContains(t, orphanedIDs, confirmedID)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id IN ($1, $2)`, orphanID, confirmedID)
assert.NoError(t, err)
}
// TestDepotModels tests the model structures
func TestDepotModels(t *testing.T) {
// Test Config defaults
config := depot.Config{
BucketName: "test-bucket",
}
assert.Equal(t, "test-bucket", config.BucketName)
assert.Equal(t, time.Duration(0), config.UploadURLExpiry)
assert.Equal(t, time.Duration(0), config.DownloadURLExpiry)
// Test with explicit values
config = depot.Config{
BucketName: "custom-bucket",
UploadURLExpiry: 10 * time.Minute,
DownloadURLExpiry: 12 * time.Hour,
}
assert.Equal(t, "custom-bucket", config.BucketName)
assert.Equal(t, 10*time.Minute, config.UploadURLExpiry)
assert.Equal(t, 12*time.Hour, config.DownloadURLExpiry)
}
// TestDepotErrors tests the error definitions
func TestDepotErrors(t *testing.T) {
assert.Error(t, depot.ErrNotFound)
assert.Error(t, depot.ErrInvalidInput)
assert.Equal(t, "object not found", depot.ErrNotFound.Error())
assert.Equal(t, "invalid input", depot.ErrInvalidInput.Error())
}
+209
View File
@@ -0,0 +1,209 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/middleware"
"github.com/flowy-live/llink/internal/network"
)
type CreateCheckoutSessionRequest struct {
Cadence string `json:"cadence"`
}
type CheckoutSessionResponse struct {
URL string `json:"url"`
}
type PortalSessionResponse struct {
URL string `json:"url"`
}
// GetNetworkUsage reports today's usage, daily limit (nil on pro), and reset
// time. Open to any network member since the UI surfaces it to every sender.
func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
networkID := r.PathValue("id")
if networkID == "" {
http.Error(w, "network id is required", http.StatusBadRequest)
return
}
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
if err != nil {
flog.Error("failed to check network membership", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !isMember {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
usage, err := h.billingSvc.GetUsage(r.Context(), networkID)
if err != nil {
flog.Error("failed to get network usage", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, usage)
}
func (h *Handler) GetNetworkBilling(w http.ResponseWriter, r *http.Request) {
net, _, ok := h.loadNetworkForAdmin(w, r)
if !ok {
return
}
status, err := h.billingSvc.GetStatus(r.Context(), net.ID)
if err != nil {
flog.Error("failed to get billing status", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, status)
}
func (h *Handler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
net, adminHumanId, ok := h.loadNetworkForAdmin(w, r)
if !ok {
return
}
var req CreateCheckoutSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
cadence := billing.Cadence(req.Cadence)
if !cadence.IsValid() {
http.Error(w, "invalid cadence", http.StatusBadRequest)
return
}
adminHuman, err := h.humanSvc.GetByID(r.Context(), adminHumanId)
if err != nil {
flog.Error("failed to load admin human", "error", err, "human_id", adminHumanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
seats, err := h.networkSvc.CountSeats(r.Context(), net.ID)
if err != nil {
flog.Error("failed to count seats", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
url, err := h.billingSvc.CreateCheckoutSession(r.Context(), billing.CheckoutParams{
NetworkID: net.ID,
AdminHumanID: adminHumanId,
AdminEmail: adminHuman.Email,
Cadence: cadence,
Seats: seats,
})
if err != nil {
flog.Error("failed to create checkout session", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, CheckoutSessionResponse{URL: url})
}
func (h *Handler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
net, _, ok := h.loadNetworkForAdmin(w, r)
if !ok {
return
}
url, err := h.billingSvc.CreatePortalSession(r.Context(), net.ID)
if errors.Is(err, billing.ErrNoActiveSubscription) {
http.Error(w, "no active subscription", http.StatusConflict)
return
}
if err != nil {
flog.Error("failed to create portal session", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, PortalSessionResponse{URL: url})
}
const maxStripeWebhookBytes = 1 << 20 // 1 MiB
func (h *Handler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(io.LimitReader(r.Body, maxStripeWebhookBytes))
if err != nil {
flog.Warn("stripe webhook: failed to read body", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
signature := r.Header.Get("Stripe-Signature")
if err := h.billingSvc.HandleWebhook(r.Context(), payload, signature); err != nil {
flog.Error("stripe webhook failed", "error", err)
formattedErr := fmt.Errorf("webhook processing failed: %w", err)
http.Error(w, formattedErr.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
// Resolves {id}, verifies the caller is admin. On failure writes the HTTP
// error and returns ok=false.
func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, "", false
}
networkID := r.PathValue("id")
if networkID == "" {
http.Error(w, "network id is required", http.StatusBadRequest)
return nil, "", false
}
net, err := h.networkSvc.GetByID(r.Context(), networkID)
if errors.Is(err, network.ErrNotFound) {
http.Error(w, "network not found", http.StatusNotFound)
return nil, "", false
}
if err != nil {
flog.Error("failed to load network", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return nil, "", false
}
if net.AdminHumanId != humanId {
http.Error(w, "forbidden", http.StatusForbidden)
return nil, "", false
}
return net, humanId, true
}
func writeJSON(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(body); err != nil {
flog.Error("failed to write json", "error", err)
}
}
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
package handler
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
)
// LinkMetadata mirrors the TS shape in `js/desktop/src/lib/link-metadata.ts`.
// All clients (web and Electron) call this endpoint — the Electron main-process
// fetcher was retired so both clients share one parser and the server-side cache.
type LinkMetadata struct {
URL string `json:"url"`
Title *string `json:"title"`
Description *string `json:"description"`
Image *string `json:"image"`
Favicon *string `json:"favicon"`
Domain string `json:"domain"`
}
const (
metadataFetchTimeout = 5 * time.Second
metadataMaxBytes = 50 * 1024
metadataUserAgent = "Mozilla/5.0 (compatible; llink/1.0)"
)
// In-process cache, unbounded but only successful results are stored.
// URL space is bounded in practice.
var metadataCache sync.Map // map[string]LinkMetadata
func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) {
raw := r.URL.Query().Get("url")
if raw == "" {
http.Error(w, "url is required", http.StatusBadRequest)
return
}
parsed, err := url.Parse(raw)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
http.Error(w, "invalid url", http.StatusBadRequest)
return
}
if cached, ok := metadataCache.Load(raw); ok {
writeJSON(w, cached)
return
}
if err := guardSSRF(parsed.Hostname()); err != nil {
// Treat unresolvable / private hosts as "no metadata" rather than 4xx — the
// client treats a null body as a progressive-enhancement miss.
writeJSON(w, nil)
return
}
meta, err := fetchLinkMetadata(r.Context(), parsed)
if err != nil {
flog.Warn("link metadata fetch failed", "url", raw, "error", err)
writeJSON(w, nil)
return
}
metadataCache.Store(raw, *meta)
writeJSON(w, meta)
}
// guardSSRF resolves the host and rejects loopback, private, link-local, and
// unspecified addresses so the endpoint can't be turned into an internal-network
// probe.
func guardSSRF(host string) error {
ips, err := net.LookupIP(host)
if err != nil {
return err
}
if len(ips) == 0 {
return errors.New("no addresses for host")
}
for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return errors.New("private or local address")
}
}
return nil
}
func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, error) {
ctx, cancel := context.WithTimeout(ctx, metadataFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", metadataUserAgent)
req.Header.Set("Accept", "text/html")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errors.New("upstream non-2xx")
}
body, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBytes))
if err != nil {
return nil, err
}
html := string(body)
domain := strings.TrimPrefix(target.Hostname(), "www.")
title := firstNonNil(metaContent(html, "og:title"), parseTitle(html))
description := firstNonNil(
metaContent(html, "og:description"),
metaContent(html, "description"),
)
image := resolveURL(metaContent(html, "og:image"), target)
favicon := parseFavicon(html, target)
return &LinkMetadata{
URL: target.String(),
Title: title,
Description: description,
Image: image,
Favicon: favicon,
Domain: domain,
}, nil
}
var titleRe = regexp.MustCompile(`(?i)<title[^>]*>([^<]*)</title>`)
func parseTitle(html string) *string {
m := titleRe.FindStringSubmatch(html)
if len(m) < 2 {
return nil
}
s := strings.TrimSpace(m[1])
if s == "" {
return nil
}
return &s
}
// metaContent finds <meta property|name="<prop>" content="..."> in either attribute order.
func metaContent(html, prop string) *string {
escaped := regexp.QuoteMeta(prop)
re := regexp.MustCompile(
`(?i)<meta[^>]*(?:property|name)=["']` + escaped + `["'][^>]*content=["']([^"']*)["']` +
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']` + escaped + `["']`,
)
m := re.FindStringSubmatch(html)
if len(m) == 0 {
return nil
}
for _, g := range m[1:] {
if g != "" {
return &g
}
}
return nil
}
var (
faviconRe1 = regexp.MustCompile(`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']`)
faviconRe2 = regexp.MustCompile(`(?i)<link[^>]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']`)
)
func parseFavicon(html string, base *url.URL) *string {
for _, re := range []*regexp.Regexp{faviconRe1, faviconRe2} {
if m := re.FindStringSubmatch(html); len(m) >= 2 {
return resolveURL(&m[1], base)
}
}
// Fall back to /favicon.ico
fallback := (&url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/favicon.ico"}).String()
return &fallback
}
func resolveURL(src *string, base *url.URL) *string {
if src == nil || *src == "" {
return nil
}
parsed, err := url.Parse(*src)
if err != nil {
return src
}
resolved := base.ResolveReference(parsed).String()
return &resolved
}
func firstNonNil(values ...*string) *string {
for _, v := range values {
if v != nil && *v != "" {
return v
}
}
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal/human/pushnotify"
"github.com/flowy-live/llink/internal/middleware"
)
type RegisterPushTokenRequest struct {
Token string `json:"token"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
}
type UnregisterPushTokenRequest struct {
Token string `json:"token"`
}
// RegisterPushToken upserts an Expo token; ON CONFLICT transparently re-binds
// a token to a new human after a device-level account switch.
func (h *Handler) RegisterPushToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req RegisterPushTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
err := h.pushTokenSvc.Register(r.Context(), humanId, pushnotify.RegisterInput{
Token: req.Token,
Platform: pushnotify.Platform(req.Platform),
AppVersion: req.AppVersion,
})
if err != nil {
if errors.Is(err, pushnotify.ErrInvalidPlatform) || errors.Is(err, pushnotify.ErrInvalidToken) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
flog.Error("failed to register push token", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UnregisterPushToken returns 204 whether or not the token existed (idempotent).
func (h *Handler) UnregisterPushToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req UnregisterPushTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Token == "" {
http.Error(w, "token is required", http.StatusBadRequest)
return
}
err := h.pushTokenSvc.Unregister(r.Context(), humanId, req.Token)
if err != nil && !errors.Is(err, pushnotify.ErrNotFound) {
flog.Error("failed to unregister push token", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
+131
View File
@@ -0,0 +1,131 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./service.go
//
// Generated by this command:
//
// mockgen -source ./service.go -destination ./mocks/service.go
//
// Package mock_human is a generated GoMock package.
package mock_human
import (
context "context"
reflect "reflect"
time "time"
human "github.com/flowy-live/llink/internal/human"
gomock "go.uber.org/mock/gomock"
)
// MockService is a mock of Service interface.
type MockService struct {
ctrl *gomock.Controller
recorder *MockServiceMockRecorder
isgomock struct{}
}
// MockServiceMockRecorder is the mock recorder for MockService.
type MockServiceMockRecorder struct {
mock *MockService
}
// NewMockService creates a new mock instance.
func NewMockService(ctrl *gomock.Controller) *MockService {
mock := &MockService{ctrl: ctrl}
mock.recorder = &MockServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockService) EXPECT() *MockServiceMockRecorder {
return m.recorder
}
// GetByEmail mocks base method.
func (m *MockService) GetByEmail(ctx context.Context, email string) (*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByEmail", ctx, email)
ret0, _ := ret[0].(*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByEmail indicates an expected call of GetByEmail.
func (mr *MockServiceMockRecorder) GetByEmail(ctx, email any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByEmail", reflect.TypeOf((*MockService)(nil).GetByEmail), ctx, email)
}
// GetByID mocks base method.
func (m *MockService) GetByID(ctx context.Context, id string) (*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByID", ctx, id)
ret0, _ := ret[0].(*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByID indicates an expected call of GetByID.
func (mr *MockServiceMockRecorder) GetByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockService)(nil).GetByID), ctx, id)
}
// GetOrCreateByEmail mocks base method.
func (m *MockService) GetOrCreateByEmail(ctx context.Context, email string) (*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOrCreateByEmail", ctx, email)
ret0, _ := ret[0].(*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOrCreateByEmail indicates an expected call of GetOrCreateByEmail.
func (mr *MockServiceMockRecorder) GetOrCreateByEmail(ctx, email any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateByEmail", reflect.TypeOf((*MockService)(nil).GetOrCreateByEmail), ctx, email)
}
// ListAll mocks base method.
func (m *MockService) ListAll(ctx context.Context) ([]*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAll", ctx)
ret0, _ := ret[0].([]*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAll indicates an expected call of ListAll.
func (mr *MockServiceMockRecorder) ListAll(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAll", reflect.TypeOf((*MockService)(nil).ListAll), ctx)
}
// UpdateEmailNotificationsEnabled mocks base method.
func (m *MockService) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateEmailNotificationsEnabled", ctx, id, enabled)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateEmailNotificationsEnabled indicates an expected call of UpdateEmailNotificationsEnabled.
func (mr *MockServiceMockRecorder) UpdateEmailNotificationsEnabled(ctx, id, enabled any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEmailNotificationsEnabled", reflect.TypeOf((*MockService)(nil).UpdateEmailNotificationsEnabled), ctx, id, enabled)
}
// UpdateLastEmailNotificationSentAt mocks base method.
func (m *MockService) UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateLastEmailNotificationSentAt", ctx, id, t)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateLastEmailNotificationSentAt indicates an expected call of UpdateLastEmailNotificationSentAt.
func (mr *MockServiceMockRecorder) UpdateLastEmailNotificationSentAt(ctx, id, t any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLastEmailNotificationSentAt", reflect.TypeOf((*MockService)(nil).UpdateLastEmailNotificationSentAt), ctx, id, t)
}
+3
View File
@@ -6,5 +6,8 @@ type Human struct {
ID string ID string
Email string Email string
EmailPrefix string EmailPrefix string
EmailNotificationsEnabled bool
AvatarObjectID *string
LastEmailNotificationSentAt *time.Time
CreatedAt time.Time CreatedAt time.Time
} }
+138
View File
@@ -0,0 +1,138 @@
package pushnotify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
expoPushAPIURL = "https://exp.host/--/api/v2/push/send"
// expoMaxBatchSize is the documented per-request cap on push messages.
expoMaxBatchSize = 100
// Ticket error codes returned by Expo Push API. The only one we act on is
// DeviceNotRegistered — others are logged but not retried (per product call).
ExpoErrorDeviceNotRegistered = "DeviceNotRegistered"
)
// Sound defaults to "default" when empty (set in Send).
type Message struct {
To string `json:"to"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
Data map[string]any `json:"data,omitempty"`
Sound string `json:"sound,omitempty"`
}
// Status is "ok" or "error". On error, Details["error"] carries the code
// (e.g. "DeviceNotRegistered", "MessageTooBig", "InvalidCredentials").
type Ticket struct {
Status string `json:"status"`
ID string `json:"id,omitempty"`
Message string `json:"message,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
// ExpoClient does NOT poll receipts and does NOT retry — fire-and-forget,
// with DeviceNotRegistered handled out-of-band by the notifier.
type ExpoClient struct {
http *http.Client
accessToken string
}
func NewExpoClient(accessToken string) *ExpoClient {
return &ExpoClient{
http: &http.Client{Timeout: 15 * time.Second},
accessToken: accessToken,
}
}
type expoSendResponse struct {
Data []Ticket `json:"data"`
Errors []map[string]any `json:"errors,omitempty"`
}
// Send batches msgs (cap expoMaxBatchSize) and preserves input order:
// tickets[i] corresponds to msgs[i]. A request-level failure aborts the
// remaining batches; tickets already collected are returned with the error.
func (c *ExpoClient) Send(ctx context.Context, msgs []Message) ([]Ticket, error) {
if len(msgs) == 0 {
return nil, nil
}
for i := range msgs {
if msgs[i].Sound == "" {
msgs[i].Sound = "default"
}
}
tickets := make([]Ticket, 0, len(msgs))
for start := 0; start < len(msgs); start += expoMaxBatchSize {
end := start + expoMaxBatchSize
if end > len(msgs) {
end = len(msgs)
}
batch := msgs[start:end]
batchTickets, err := c.sendBatch(ctx, batch)
tickets = append(tickets, batchTickets...)
if err != nil {
return tickets, fmt.Errorf("expo push batch [%d:%d]: %w", start, end, err)
}
}
return tickets, nil
}
func (c *ExpoClient) sendBatch(ctx context.Context, batch []Message) ([]Ticket, error) {
body, err := json.Marshal(batch)
if err != nil {
return nil, fmt.Errorf("marshal batch: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, expoPushAPIURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip, deflate")
if c.accessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("expo push api returned %d: %s", resp.StatusCode, truncate(string(raw), 512))
}
var parsed expoSendResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if len(parsed.Data) != len(batch) {
return parsed.Data, fmt.Errorf("expo returned %d tickets for %d messages", len(parsed.Data), len(batch))
}
return parsed.Data, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+181
View File
@@ -0,0 +1,181 @@
package pushnotify
import (
"context"
"errors"
"fmt"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal/network"
)
type NotifyInput struct {
NetworkID string
SenderHumanID string
SenderEmailPrefix string
ParticleID string
// One of "text", "media", "file", "quest", "paper". Containers (stream,
// folder) are dropped by the caller before reaching the notifier.
ParticleKind string
// Parent stream context — drives the title and the recipient set.
StreamID string
StreamName string
StreamVisibleTo []string
// Body — already formatted by the caller (e.g. truncated text, "Sent a
// voice message"). Title is derived inside the notifier.
Body string
}
// Notifier fans out one particle to Expo:
// 1. Resolve recipients (visibility ∩ network members, minus sender).
// 2. Send a batched Expo request for every recipient's tokens.
// 3. Prune tokens Expo reports as DeviceNotRegistered.
//
// Online/offline presence is intentionally NOT consulted: a live WebSocket
// is a poor proxy for "user is actively consuming this particle right now"
// (backgrounded apps, idle desktops, etc. all look online), and the resulting
// false-negatives outweigh the duplicate-notification cost on a focused
// device, which the OS handles via Focus modes and per-app settings.
type Notifier struct {
networkR network.Reader
tokens Service
expo *ExpoClient
}
func NewNotifier(networkR network.Reader, tokens Service, expo *ExpoClient) *Notifier {
return &Notifier{
networkR: networkR,
tokens: tokens,
expo: expo,
}
}
func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error {
if in.NetworkID == "" || in.ParticleID == "" {
flog.Info("pushnotify: skip — missing ids",
"networkID", in.NetworkID,
"particleID", in.ParticleID,
)
return nil
}
members, err := n.networkR.ListMembers(ctx, in.NetworkID)
if err != nil {
return fmt.Errorf("list network members: %w", err)
}
recipients := network.ResolveVisibility(in.StreamVisibleTo, members)
recipientsBeforeSenderFilter := len(recipients)
recipients = filterOut(recipients, in.SenderHumanID)
if len(recipients) == 0 {
flog.Info("pushnotify: skip — no recipients",
"networkID", in.NetworkID,
"particleID", in.ParticleID,
"senderHumanID", in.SenderHumanID,
"members", len(members),
"visibleTo", in.StreamVisibleTo,
"resolved", recipientsBeforeSenderFilter,
)
return nil
}
tokens, err := n.tokens.ListForHumans(ctx, recipients)
if err != nil {
return fmt.Errorf("token lookup: %w", err)
}
if len(tokens) == 0 {
flog.Info("pushnotify: skip — no tokens for recipients",
"networkID", in.NetworkID,
"particleID", in.ParticleID,
"recipients", len(recipients),
"recipientIDs", recipients,
)
return nil
}
msgs := buildMessages(tokens, in)
tickets, sendErr := n.expo.Send(ctx, msgs)
flog.Info("pushnotify: dispatch",
"networkID", in.NetworkID,
"particleID", in.ParticleID,
"recipients", len(recipients),
"tokens", len(tokens),
"sent", len(tickets),
)
n.cleanupDeadTokens(ctx, msgs, tickets)
if sendErr != nil {
return fmt.Errorf("expo send: %w", sendErr)
}
return nil
}
// DeviceNotRegistered is the one feedback signal we honor; other ticket
// errors (MessageTooBig, RateLimit, …) are logged and dropped.
func (n *Notifier) cleanupDeadTokens(ctx context.Context, msgs []Message, tickets []Ticket) {
for i, t := range tickets {
if i >= len(msgs) {
break
}
if t.Status != "error" || t.Details == nil {
continue
}
code, _ := t.Details["error"].(string)
if code != ExpoErrorDeviceNotRegistered {
if t.Status == "error" {
flog.Warn("pushnotify: ticket error", "code", code, "message", t.Message, "to", msgs[i].To)
}
continue
}
if err := n.tokens.DeleteByToken(ctx, msgs[i].To); err != nil && !errors.Is(err, ErrNotFound) {
flog.Error("pushnotify: failed to delete dead token", "error", err, "token", msgs[i].To)
} else {
flog.Info("pushnotify: removed unregistered token", "token", msgs[i].To)
}
}
}
func buildMessages(tokens []*PushToken, in NotifyInput) []Message {
title := in.SenderEmailPrefix
if in.StreamName != "" {
title = in.SenderEmailPrefix + " in " + in.StreamName
}
data := map[string]any{
"kind": "particle_created",
"network_id": in.NetworkID,
"stream_id": in.StreamID,
"particle_id": in.ParticleID,
"sender_human_id": in.SenderHumanID,
"particle_kind": in.ParticleKind,
}
msgs := make([]Message, 0, len(tokens))
for _, t := range tokens {
msgs = append(msgs, Message{
To: t.Token,
Title: title,
Body: in.Body,
Data: data,
})
}
return msgs
}
func filterOut(ids []string, exclude string) []string {
if exclude == "" {
return ids
}
out := ids[:0:len(ids)]
for _, id := range ids {
if id != exclude {
out = append(out, id)
}
}
return out
}
@@ -0,0 +1,95 @@
package pushnotify
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type repository interface {
upsert(ctx context.Context, t *PushToken) error
deleteForHuman(ctx context.Context, humanID, token string) error
deleteByToken(ctx context.Context, token string) error
listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
}
type repositoryImpl struct {
pool *pgxpool.Pool
}
func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool}
}
func (r *repositoryImpl) upsert(ctx context.Context, t *PushToken) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO push_tokens (token, human_id, platform, app_version)
VALUES ($1, $2, $3, NULLIF($4, ''))
ON CONFLICT (token) DO UPDATE SET
human_id = EXCLUDED.human_id,
platform = EXCLUDED.platform,
app_version = EXCLUDED.app_version,
last_seen_at = NOW()`,
t.Token, t.HumanID, string(t.Platform), t.AppVersion,
)
return err
}
func (r *repositoryImpl) deleteForHuman(ctx context.Context, humanID, token string) error {
result, err := r.pool.Exec(ctx,
`DELETE FROM push_tokens WHERE human_id = $1 AND token = $2`,
humanID, token,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *repositoryImpl) deleteByToken(ctx context.Context, token string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM push_tokens WHERE token = $1`,
token,
)
return err
}
func (r *repositoryImpl) listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
if len(humanIDs) == 0 {
return nil, nil
}
rows, err := r.pool.Query(ctx,
`SELECT token, human_id, platform, app_version, created_at, last_seen_at
FROM push_tokens
WHERE human_id = ANY($1)`,
humanIDs,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
defer rows.Close()
var tokens []*PushToken
for rows.Next() {
var t PushToken
var appVersion *string
var platform string
if err := rows.Scan(&t.Token, &t.HumanID, &platform, &appVersion, &t.CreatedAt, &t.LastSeenAt); err != nil {
return nil, err
}
t.Platform = Platform(platform)
if appVersion != nil {
t.AppVersion = *appVersion
}
tokens = append(tokens, &t)
}
return tokens, rows.Err()
}
+66
View File
@@ -0,0 +1,66 @@
package pushnotify
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service stores per-device Expo push tokens and exposes the operations
// needed by both the HTTP handlers and the worker-side notifier.
type Service interface {
// Register returns ErrInvalidToken / ErrInvalidPlatform on bad input.
Register(ctx context.Context, humanID string, in RegisterInput) error
// Unregister is scoped to humanID so a user can't delete another user's
// token. Returns ErrNotFound if the token isn't owned by humanID.
Unregister(ctx context.Context, humanID, token string) error
// ListForHumans returns an empty slice when nothing matches.
ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
// DeleteByToken removes a token regardless of owner — used to prune after
// Expo reports DeviceNotRegistered.
DeleteByToken(ctx context.Context, token string) error
}
type RegisterInput struct {
Token string
Platform Platform
AppVersion string
}
type serviceImpl struct {
repo repository
}
func NewService(pool *pgxpool.Pool) Service {
return &serviceImpl{repo: newRepository(pool)}
}
func (s *serviceImpl) Register(ctx context.Context, humanID string, in RegisterInput) error {
if !in.Platform.Valid() {
return ErrInvalidPlatform
}
if !IsValidExpoToken(in.Token) {
return ErrInvalidToken
}
return s.repo.upsert(ctx, &PushToken{
Token: in.Token,
HumanID: humanID,
Platform: in.Platform,
AppVersion: in.AppVersion,
})
}
func (s *serviceImpl) Unregister(ctx context.Context, humanID, token string) error {
if token == "" {
return ErrInvalidToken
}
return s.repo.deleteForHuman(ctx, humanID, token)
}
func (s *serviceImpl) ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
return s.repo.listForHumans(ctx, humanIDs)
}
func (s *serviceImpl) DeleteByToken(ctx context.Context, token string) error {
return s.repo.deleteByToken(ctx, token)
}
+43
View File
@@ -0,0 +1,43 @@
// Package pushnotify owns mobile push notification delivery: storage of per-device
// Expo push tokens, and the worker-side orchestration of sending notifications
// to offline recipients via the Expo Push API.
package pushnotify
import (
"errors"
"strings"
"time"
)
type Platform string
const (
PlatformIOS Platform = "ios"
PlatformAndroid Platform = "android"
)
func (p Platform) Valid() bool {
return p == PlatformIOS || p == PlatformAndroid
}
type PushToken struct {
Token string
HumanID string
Platform Platform
AppVersion string
CreatedAt time.Time
LastSeenAt time.Time
}
var (
ErrInvalidPlatform = errors.New("invalid platform")
ErrInvalidToken = errors.New("invalid expo push token")
ErrNotFound = errors.New("push token not found")
)
// IsValidExpoToken matches the two prefix formats Expo currently uses.
// We don't validate the inner contents — Expo's server will reject malformed
// tokens with a per-message error and we'll clean those up via DeviceNotRegistered.
func IsValidExpoToken(token string) bool {
return strings.HasPrefix(token, "ExponentPushToken[") || strings.HasPrefix(token, "ExpoPushToken[")
}
+74 -6
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"strings" "strings"
"time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -33,6 +34,10 @@ type repository interface {
getByID(ctx context.Context, id string) (*Human, error) getByID(ctx context.Context, id string) (*Human, error)
create(ctx context.Context, email string) (*Human, error) create(ctx context.Context, email string) (*Human, error)
exists(ctx context.Context, email string) (bool, error) exists(ctx context.Context, email string) (bool, error)
listAll(ctx context.Context) ([]*Human, error)
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
updateAvatarObjectID(ctx context.Context, id string, objectID *string) error
} }
type repositoryImpl struct { type repositoryImpl struct {
@@ -46,9 +51,9 @@ func newRepository(pool *pgxpool.Pool) repository {
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) { func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
var h Human var h Human
err := r.pool.QueryRow(ctx, err := r.pool.QueryRow(ctx,
`SELECT id, email, created_at FROM humans WHERE email = $1`, `SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE email = $1`,
email, email,
).Scan(&h.ID, &h.Email, &h.CreatedAt) ).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound return nil, errNotFound
@@ -62,9 +67,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human,
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) { func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
var h Human var h Human
err := r.pool.QueryRow(ctx, err := r.pool.QueryRow(ctx,
`SELECT id, email, created_at FROM humans WHERE id = $1`, `SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE id = $1`,
id, id,
).Scan(&h.ID, &h.Email, &h.CreatedAt) ).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound return nil, errNotFound
@@ -84,9 +89,9 @@ func (r *repositoryImpl) create(ctx context.Context, email string) (*Human, erro
var h Human var h Human
err = r.pool.QueryRow(ctx, err = r.pool.QueryRow(ctx,
`INSERT INTO humans (id, email) VALUES ($1, $2) `INSERT INTO humans (id, email) VALUES ($1, $2)
RETURNING id, email, created_at`, RETURNING id, email, email_notifications_enabled, last_email_notification_sent_at, created_at`,
id.String(), email, id.String(), email,
).Scan(&h.ID, &h.Email, &h.CreatedAt) ).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -106,3 +111,66 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error)
} }
return exists, nil return exists, nil
} }
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var humans []*Human
for rows.Next() {
var h Human
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID); err != nil {
return nil, err
}
h.EmailPrefix = emailPrefix(h.Email)
humans = append(humans, &h)
}
return humans, rows.Err()
}
func (r *repositoryImpl) updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
result, err := r.pool.Exec(ctx,
`UPDATE humans SET email_notifications_enabled = $2 WHERE id = $1`,
id, enabled,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
result, err := r.pool.Exec(ctx,
`UPDATE humans SET last_email_notification_sent_at = $2 WHERE id = $1`,
id, t,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
func (r *repositoryImpl) updateAvatarObjectID(ctx context.Context, id string, objectID *string) error {
result, err := r.pool.Exec(ctx,
`UPDATE humans SET avatar_object_id = $2 WHERE id = $1`,
id, objectID,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
+62 -2
View File
@@ -3,17 +3,30 @@ package human
import ( import (
"context" "context"
"errors" "errors"
"time"
"github.com/flowy-live/llink/internal/utils" "github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
var ErrNotFound = errors.New("human not found") //go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
var (
ErrNotFound = errors.New("human not found")
ErrInvalidParam = errors.New("invalid param")
)
type Service interface { type Service interface {
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error) GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
// GetByEmail returns ErrNotFound if no human found // GetByEmail returns ErrNotFound if no human found.
GetByEmail(ctx context.Context, email string) (*Human, error) GetByEmail(ctx context.Context, email string) (*Human, error)
// GetByID returns ErrNotFound if no human found.
GetByID(ctx context.Context, id string) (*Human, error)
ListAll(ctx context.Context) ([]*Human, error)
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
UpdateAvatar(ctx context.Context, id string, objectID string) error
DeleteAvatar(ctx context.Context, id string) error
} }
type serviceImpl struct { type serviceImpl struct {
@@ -52,3 +65,50 @@ func (s *serviceImpl) GetByEmail(ctx context.Context, email string) (*Human, err
} }
return h, err return h, err
} }
func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Human, error) {
h, err := s.repo.getByID(ctx, id)
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return h, err
}
func (s *serviceImpl) ListAll(ctx context.Context) ([]*Human, error) {
return s.repo.listAll(ctx)
}
func (s *serviceImpl) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
err := s.repo.updateEmailNotificationsEnabled(ctx, id, enabled)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
err := s.repo.updateLastEmailNotificationSentAt(ctx, id, t)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
func (s *serviceImpl) UpdateAvatar(ctx context.Context, id string, objectID string) error {
if objectID == "" {
return ErrInvalidParam
}
err := s.repo.updateAvatarObjectID(ctx, id, utils.CreateOptionalString(objectID))
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
func (s *serviceImpl) DeleteAvatar(ctx context.Context, id string) error {
err := s.repo.updateAvatarObjectID(ctx, id, nil)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
+15
View File
@@ -56,4 +56,19 @@ func TestHumanService(t *testing.T) {
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID) assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
assert.Equal(t, "another@example.com", anotherHuman.Email) assert.Equal(t, "another@example.com", anotherHuman.Email)
assert.Equal(t, "another", anotherHuman.EmailPrefix) assert.Equal(t, "another", anotherHuman.EmailPrefix)
// Test avatar handling
objectID := "obj_xxx"
err = svc.UpdateAvatar(ctx, anotherHuman.ID, objectID)
assert.NoError(t, err)
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
assert.NoError(t, err)
assert.Equal(t, objectID, *anotherHuman.AvatarObjectID)
err = svc.DeleteAvatar(ctx, anotherHuman.ID)
assert.NoError(t, err)
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
assert.NoError(t, err)
assert.Nil(t, anotherHuman.AvatarObjectID)
} }
+76
View File
@@ -0,0 +1,76 @@
package livekit
import (
"context"
"time"
"github.com/flowy-live/llink/internal/utils"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
lksdk "github.com/livekit/server-sdk-go/v2"
)
type Client interface {
// GetJoinToken mints a participant JWT; name surfaces as the display name.
GetJoinToken(roomId string, humanId string, name string) (string, error)
ServerUrl() string
ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error)
// KeyProvider is used by handlers to verify webhook signatures.
KeyProvider() auth.KeyProvider
}
type clientImpl struct {
apiSecret string
apiKey string
hostUrl string
roomService *lksdk.RoomServiceClient
keyProvider auth.KeyProvider
}
func NewClient() Client {
apiKey := utils.MustGetEnv("LIVEKIT_API_KEY")
apiSecret := utils.MustGetEnv("LIVEKIT_API_SECRET")
hostUrl := utils.MustGetEnv("LIVEKIT_URL")
roomService := lksdk.NewRoomServiceClient(hostUrl, apiKey, apiSecret)
return &clientImpl{
apiSecret: apiSecret,
apiKey: apiKey,
hostUrl: hostUrl,
roomService: roomService,
keyProvider: auth.NewSimpleKeyProvider(apiKey, apiSecret),
}
}
func (c *clientImpl) ServerUrl() string {
return c.hostUrl
}
func (c *clientImpl) GetJoinToken(room, humanId, name string) (string, error) {
at := auth.NewAccessToken(c.apiKey, c.apiSecret)
grant := &auth.VideoGrant{
RoomJoin: true,
Room: room,
}
at.SetVideoGrant(grant).
SetIdentity(humanId).
SetName(name).
SetValidFor(time.Hour)
return at.ToJWT()
}
func (c *clientImpl) ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error) {
resp, err := c.roomService.ListParticipants(ctx, &livekit.ListParticipantsRequest{
Room: roomName,
})
if err != nil {
return nil, err
}
return resp.Participants, nil
}
func (c *clientImpl) KeyProvider() auth.KeyProvider {
return c.keyProvider
}
@@ -0,0 +1,41 @@
package livestore
import (
"context"
"cloud.google.com/go/firestore"
)
//go:generate go tool mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
// MembershipPublisher fans network membership changes out to Firestore.
// Postgres is the source of truth; the reconciler heals drift, so publish
// failures are safe to log and ignore.
type MembershipPublisher interface {
Add(ctx context.Context, humanId, networkID string) error
Remove(ctx context.Context, humanId, networkID string) error
}
func NewMembershipPublisher(fs *firestore.Client) MembershipPublisher {
return &firestoreMembershipPublisher{fs: fs}
}
type firestoreMembershipPublisher struct {
fs *firestore.Client
}
func (p *firestoreMembershipPublisher) Add(ctx context.Context, humanId, networkID string) error {
_, err := p.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{
"networks": firestore.ArrayUnion(networkID),
"updated_at": firestore.ServerTimestamp,
}, firestore.MergeAll)
return err
}
func (p *firestoreMembershipPublisher) Remove(ctx context.Context, humanId, networkID string) error {
_, err := p.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{
"networks": firestore.ArrayRemove(networkID),
"updated_at": firestore.ServerTimestamp,
}, firestore.MergeAll)
return err
}
@@ -0,0 +1,69 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./membershippublisher.go
//
// Generated by this command:
//
// mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
//
// Package mock_livestore is a generated GoMock package.
package mock_livestore
import (
context "context"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockMembershipPublisher is a mock of MembershipPublisher interface.
type MockMembershipPublisher struct {
ctrl *gomock.Controller
recorder *MockMembershipPublisherMockRecorder
isgomock struct{}
}
// MockMembershipPublisherMockRecorder is the mock recorder for MockMembershipPublisher.
type MockMembershipPublisherMockRecorder struct {
mock *MockMembershipPublisher
}
// NewMockMembershipPublisher creates a new mock instance.
func NewMockMembershipPublisher(ctrl *gomock.Controller) *MockMembershipPublisher {
mock := &MockMembershipPublisher{ctrl: ctrl}
mock.recorder = &MockMembershipPublisherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockMembershipPublisher) EXPECT() *MockMembershipPublisherMockRecorder {
return m.recorder
}
// Add mocks base method.
func (m *MockMembershipPublisher) Add(ctx context.Context, humanId, networkID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Add", ctx, humanId, networkID)
ret0, _ := ret[0].(error)
return ret0
}
// Add indicates an expected call of Add.
func (mr *MockMembershipPublisherMockRecorder) Add(ctx, humanId, networkID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockMembershipPublisher)(nil).Add), ctx, humanId, networkID)
}
// Remove mocks base method.
func (m *MockMembershipPublisher) Remove(ctx context.Context, humanId, networkID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Remove", ctx, humanId, networkID)
ret0, _ := ret[0].(error)
return ret0
}
// Remove indicates an expected call of Remove.
func (mr *MockMembershipPublisherMockRecorder) Remove(ctx, humanId, networkID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockMembershipPublisher)(nil).Remove), ctx, humanId, networkID)
}
+104
View File
@@ -0,0 +1,104 @@
package media
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"strings"
"github.com/flowy-live/llink/internal/utils/flog"
)
func IsIOSPlayableMime(mime string) bool {
switch mime {
case "video/mp4", "video/quicktime", "audio/mp4", "audio/aac", "audio/x-m4a", "audio/mpeg":
return true
}
return false
}
func IsAudio(mime string) bool {
return strings.HasPrefix(mime, "audio/")
}
type TranscodeInput struct {
SourceURL string
MimeType string
}
type TranscodeOutput struct {
TempLocalFilePath string
OutputMimeType string
OutputExt string // e.g. ".m4a" or ".mp4"
}
var (
ErrInvalidInput error = errors.New("invalid input")
)
// TranscodeToMp4 writes the result to a temp file; caller is responsible
// for deleting TempLocalFilePath.
func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput, error) {
if input.SourceURL == "" || input.MimeType == "" {
return nil, ErrInvalidInput
}
isAudio := IsAudio(input.MimeType)
var outputExt, outputMime string
if isAudio {
outputExt = ".m4a"
outputMime = "audio/mp4"
} else {
outputExt = ".mp4"
outputMime = "video/mp4"
}
tmp, err := os.CreateTemp("", "transcode-*"+outputExt)
if err != nil {
flog.Error("transcode: failed to create temp file", "error", err)
return nil, err
}
tmpPath := tmp.Name()
tmp.Close()
var args []string
if isAudio {
args = []string{
"-y", "-i", input.SourceURL,
"-vn",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
tmpPath,
}
} else {
// 1440p4K screen recordings + libx264's lookahead buffers can OOM the
// worker. Bound parallelism/lookahead and downscale to 1080p; the
// original WebM stays in GCS untouched.
args = []string{
"-y", "-i", input.SourceURL,
"-vf", "scale='min(1920,iw)':-2:flags=lanczos",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-pix_fmt", "yuv420p", "-profile:v", "baseline", "-level", "3.1",
"-x264-params", "rc-lookahead=20:ref=2",
"-threads", "2", "-filter_threads", "2",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
tmpPath,
}
}
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, errors.Join(err, errors.New(stderr.String()))
}
return &TranscodeOutput{
TempLocalFilePath: tmpPath,
OutputMimeType: outputMime,
OutputExt: outputExt,
}, nil
}
+32 -10
View File
@@ -2,29 +2,51 @@ package middleware
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"strings" "strings"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal/auth" "github.com/flowy-live/llink/internal/auth"
) )
type contextKey string type contextKey string
const emailContextKey contextKey = "email" const (
emailContextKey contextKey = "email"
humanIdContextKey contextKey = "humanId"
isAdminContextKey contextKey = "isAdmin"
)
// WithEmail adds the email to the context
func WithEmail(ctx context.Context, email string) context.Context { func WithEmail(ctx context.Context, email string) context.Context {
return context.WithValue(ctx, emailContextKey, email) return context.WithValue(ctx, emailContextKey, email)
} }
// EmailFromContext extracts the email from the context
func EmailFromContext(ctx context.Context) (string, bool) { func EmailFromContext(ctx context.Context) (string, bool) {
email, ok := ctx.Value(emailContextKey).(string) email, ok := ctx.Value(emailContextKey).(string)
return email, ok return email, ok
} }
// Auth returns a middleware that validates the session token and adds the email to the context func WithHumanId(ctx context.Context, humanId string) context.Context {
return context.WithValue(ctx, humanIdContextKey, humanId)
}
func HumanIdFromContext(ctx context.Context) (string, bool) {
humanId, ok := ctx.Value(humanIdContextKey).(string)
return humanId, ok
}
func WithIsAdmin(ctx context.Context, isAdmin bool) context.Context {
return context.WithValue(ctx, isAdminContextKey, isAdmin)
}
func IsAdminFromContext(ctx context.Context) bool {
isAdmin, ok := ctx.Value(isAdminContextKey).(bool)
return ok && isAdmin
}
// Auth validates the bearer session token and populates email/humanId/isAdmin
// into the request context for downstream handlers.
func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler { func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -34,24 +56,24 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
return return
} }
email, err := authSvc.GetSession(r.Context(), token) session, err := authSvc.GetSession(r.Context(), token)
if err != nil { if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
// Auto-extend session
if err := authSvc.ExtendSession(r.Context(), token); err != nil { if err := authSvc.ExtendSession(r.Context(), token); err != nil {
slog.Warn("failed to extend session", "error", err) flog.Warn("failed to extend session", "error", err)
} }
ctx := WithEmail(r.Context(), email) ctx := WithEmail(r.Context(), session.Email)
ctx = WithHumanId(ctx, session.HumanId)
ctx = WithIsAdmin(ctx, authSvc.IsSystemAdmin(r.Context(), session.Email))
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
} }
// extractBearerToken extracts the token from the Authorization header
func extractBearerToken(r *http.Request) string { func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
+38
View File
@@ -0,0 +1,38 @@
package middleware
import "net/http"
// CORS adds CORS headers and short-circuits preflight requests.
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
originSet := make(map[string]struct{}, len(allowedOrigins))
for _, o := range allowedOrigins {
originSet[o] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Empty allowedOrigins means allow all.
allowed := len(originSet) == 0
if !allowed {
_, allowed = originSet[origin]
}
if allowed && origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
+9 -4
View File
@@ -5,9 +5,14 @@ import "time"
type Network struct { type Network struct {
ID string ID string
Name string Name string
AdminEmail string AdminHumanId string
MemberEmails []string MemberHumanIds []string
OpenStreamCapacity int CreatedAt time.Time
OpenStreamCount int }
type Invitation struct {
NetworkID string
NetworkName string
Email string
CreatedAt time.Time CreatedAt time.Time
} }
+100
View File
@@ -0,0 +1,100 @@
package network
import (
"context"
"errors"
"fmt"
"github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool"
)
type Reader interface {
// GetByID returns ErrNotFound if network doesn't exist.
GetByID(ctx context.Context, id string) (*Network, error)
// ListForHuman returns ErrInvalidHumanId if humanId is empty.
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
// IsMember returns ErrInvalidHumanId if humanId is empty.
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
// ListMembers returns an empty slice if the network doesn't exist.
ListMembers(ctx context.Context, networkID string) ([]string, error)
ListAll(ctx context.Context) ([]*Network, error)
// ListAllMemberships returns humanId -> networkIds for every human with at
// least one membership. Humans with zero memberships are absent from the map.
ListAllMemberships(ctx context.Context) (map[string][]string, error)
CountSeats(ctx context.Context, networkID string) (int, error)
// ListInvitationsForEmail returns ErrInvalidEmail if normalization fails.
ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error)
ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error)
}
type readerImpl struct {
pool *pgxpool.Pool
repo repository
}
// newReader exposes the concrete type so the service can embed it without
// hiding pool/repo behind the Reader interface.
func newReader(pool *pgxpool.Pool) *readerImpl {
return &readerImpl{
pool: pool,
repo: newRepository(pool),
}
}
func NewReader(pool *pgxpool.Pool) Reader {
return newReader(pool)
}
func (r *readerImpl) GetByID(ctx context.Context, id string) (*Network, error) {
n, err := r.repo.getByID(ctx, id)
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return n, err
}
func (r *readerImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) {
if humanId == "" {
return nil, ErrInvalidHumanId
}
return r.repo.getNetworksForHuman(ctx, humanId)
}
func (r *readerImpl) IsMember(ctx context.Context, networkID, humanId string) (bool, error) {
if humanId == "" {
return false, ErrInvalidHumanId
}
return r.repo.isMember(ctx, networkID, humanId)
}
func (r *readerImpl) ListMembers(ctx context.Context, networkID string) ([]string, error) {
// Admin is guaranteed to be in network_members: Create() calls AddMembers
// for the admin, and RemoveMemberFromNetwork rejects admin removal.
return r.repo.getMemberHumanIds(ctx, networkID)
}
func (r *readerImpl) ListAll(ctx context.Context) ([]*Network, error) {
return r.repo.listAll(ctx)
}
func (r *readerImpl) ListAllMemberships(ctx context.Context) (map[string][]string, error) {
return r.repo.listAllMemberships(ctx)
}
func (r *readerImpl) CountSeats(ctx context.Context, networkID string) (int, error) {
return r.repo.countSeats(ctx, r.pool, networkID)
}
func (r *readerImpl) ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidEmail, err)
}
return r.repo.getInvitationsByEmail(ctx, normalized)
}
func (r *readerImpl) ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) {
return r.repo.getInvitationsByNetwork(ctx, networkID)
}
+181 -86
View File
@@ -5,10 +5,19 @@ import (
"errors" "errors"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"go.jetify.com/typeid" "go.jetify.com/typeid"
) )
// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx,
// so repository helpers can run standalone or inside a transaction.
type dbtx interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
var errNotFound = errors.New("not found") var errNotFound = errors.New("not found")
type networkIDPrefix struct{} type networkIDPrefix struct{}
@@ -23,21 +32,35 @@ func newNetworkID() (networkID, error) {
return typeid.New[networkID]() return typeid.New[networkID]()
} }
var errCapacityExceeded = errors.New("capacity exceeded")
type repository interface { type repository interface {
create(ctx context.Context, name, adminEmail string) (*Network, error) create(ctx context.Context, name, adminHumanId string) (*Network, error)
getByID(ctx context.Context, id string) (*Network, error) getByID(ctx context.Context, id string) (*Network, error)
updateName(ctx context.Context, id, name string) error updateName(ctx context.Context, id, name string) error
delete(ctx context.Context, id string) error delete(ctx context.Context, id string) error
addMember(ctx context.Context, networkID, email string) error addMember(ctx context.Context, db dbtx, networkID, humanId string) error
removeMember(ctx context.Context, networkID, email string) error removeMember(ctx context.Context, db dbtx, networkID, humanId string) error
getMemberEmails(ctx context.Context, networkID string) ([]string, error) getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
getNetworksForEmail(ctx context.Context, email string) ([]*Network, error) countSeats(ctx context.Context, db dbtx, networkID string) (int, error)
isMember(ctx context.Context, networkID, email string) (bool, error) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
setOpenStreamCapacity(ctx context.Context, id string, capacity int) error isMember(ctx context.Context, networkID, humanId string) (bool, error)
incrementOpenStreamCount(ctx context.Context, id string) error listAll(ctx context.Context) ([]*Network, error)
decrementOpenStreamCount(ctx context.Context, id string) error
// listAllMemberships returns humanId -> networkIds for every human with at
// least one membership. Humans with zero memberships are absent from the map.
listAllMemberships(ctx context.Context) (map[string][]string, error)
// Invitations
createInvitation(ctx context.Context, networkID, email string) error
getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error)
getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error)
deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error
}
// Centralized so SELECTs and scanNetwork stay in sync.
const networkColumns = `id, name, admin_human_id, created_at`
func scanNetwork(row pgx.Row, n *Network) error {
return row.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt)
} }
type repositoryImpl struct { type repositoryImpl struct {
@@ -48,43 +71,44 @@ func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool} return &repositoryImpl{pool: pool}
} }
func (r *repositoryImpl) create(ctx context.Context, name, adminEmail string) (*Network, error) { func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string) (*Network, error) {
id, err := newNetworkID() id, err := newNetworkID()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var n Network var n Network
err = r.pool.QueryRow(ctx, row := r.pool.QueryRow(ctx,
`INSERT INTO networks (id, name, admin_email) VALUES ($1, $2, $3) `INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3)
RETURNING id, name, admin_email, open_stream_capacity, open_stream_count, created_at`, RETURNING `+networkColumns,
id.String(), name, adminEmail, id.String(), name, adminHumanId,
).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt) )
if err != nil { if err := scanNetwork(row, &n); err != nil {
return nil, err return nil, err
} }
n.MemberEmails = []string{} n.MemberHumanIds = []string{}
return &n, nil return &n, nil
} }
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) { func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
var n Network var n Network
err := r.pool.QueryRow(ctx, row := r.pool.QueryRow(ctx,
`SELECT id, name, admin_email, open_stream_capacity, open_stream_count, created_at FROM networks WHERE id = $1`, `SELECT `+networkColumns+` FROM networks WHERE id = $1`,
id, id,
).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt) )
if err != nil { if err := scanNetwork(row, &n); err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound return nil, errNotFound
} }
return nil, err return nil, err
} }
n.MemberEmails, err = r.getMemberEmails(ctx, id) memberIds, err := r.getMemberHumanIds(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
n.MemberHumanIds = memberIds
return &n, nil return &n, nil
} }
@@ -114,26 +138,35 @@ func (r *repositoryImpl) delete(ctx context.Context, id string) error {
return nil return nil
} }
func (r *repositoryImpl) addMember(ctx context.Context, networkID, email string) error { func (r *repositoryImpl) addMember(ctx context.Context, db dbtx, networkID, humanId string) error {
_, err := r.pool.Exec(ctx, _, err := db.Exec(ctx,
`INSERT INTO network_members (network_id, email) VALUES ($1, $2) `INSERT INTO network_members (network_id, human_id) VALUES ($1, $2)
ON CONFLICT (network_id, email) DO NOTHING`, ON CONFLICT (network_id, human_id) DO NOTHING`,
networkID, email, networkID, humanId,
) )
return err return err
} }
func (r *repositoryImpl) removeMember(ctx context.Context, networkID, email string) error { func (r *repositoryImpl) removeMember(ctx context.Context, db dbtx, networkID, humanId string) error {
_, err := r.pool.Exec(ctx, _, err := db.Exec(ctx,
`DELETE FROM network_members WHERE network_id = $1 AND email = $2`, `DELETE FROM network_members WHERE network_id = $1 AND human_id = $2`,
networkID, email, networkID, humanId,
) )
return err return err
} }
func (r *repositoryImpl) getMemberEmails(ctx context.Context, networkID string) ([]string, error) { func (r *repositoryImpl) countSeats(ctx context.Context, db dbtx, networkID string) (int, error) {
var count int
err := db.QueryRow(ctx,
`SELECT COUNT(*) FROM network_members WHERE network_id = $1`,
networkID,
).Scan(&count)
return count, err
}
func (r *repositoryImpl) getMemberHumanIds(ctx context.Context, networkID string) ([]string, error) {
rows, err := r.pool.Query(ctx, rows, err := r.pool.Query(ctx,
`SELECT email FROM network_members WHERE network_id = $1`, `SELECT human_id FROM network_members WHERE network_id = $1`,
networkID, networkID,
) )
if err != nil { if err != nil {
@@ -141,24 +174,24 @@ func (r *repositoryImpl) getMemberEmails(ctx context.Context, networkID string)
} }
defer rows.Close() defer rows.Close()
var emails []string var humanIds []string
for rows.Next() { for rows.Next() {
var email string var humanId string
if err := rows.Scan(&email); err != nil { if err := rows.Scan(&humanId); err != nil {
return nil, err return nil, err
} }
emails = append(emails, email) humanIds = append(humanIds, humanId)
} }
return emails, rows.Err() return humanIds, rows.Err()
} }
func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string) ([]*Network, error) { func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) {
rows, err := r.pool.Query(ctx, rows, err := r.pool.Query(ctx,
`SELECT n.id, n.name, n.admin_email, n.open_stream_capacity, n.open_stream_count, n.created_at `SELECT `+networkColumns+`
FROM networks n FROM networks
WHERE n.admin_email = $1 WHERE admin_human_id = $1
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.email = $1)`, OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = id AND nm.human_id = $1)`,
email, humanId,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -168,7 +201,7 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string)
var networks []*Network var networks []*Network
for rows.Next() { for rows.Next() {
var n Network var n Network
if err := rows.Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt); err != nil { if err := scanNetwork(rows, &n); err != nil {
return nil, err return nil, err
} }
networks = append(networks, &n) networks = append(networks, &n)
@@ -178,7 +211,7 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string)
} }
for _, n := range networks { for _, n := range networks {
n.MemberEmails, err = r.getMemberEmails(ctx, n.ID) n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -187,66 +220,128 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string)
return networks, nil return networks, nil
} }
func (r *repositoryImpl) isMember(ctx context.Context, networkID, email string) (bool, error) { func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string) (bool, error) {
var isMember bool var isMember bool
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
SELECT EXISTS( SELECT EXISTS(
SELECT 1 FROM networks n SELECT 1 FROM networks n
LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.email = $2 LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.human_id = $2
WHERE n.id = $1 AND (n.admin_email = $2 OR nm.email IS NOT NULL) WHERE n.id = $1 AND (n.admin_human_id = $2 OR nm.human_id IS NOT NULL)
) )
`, networkID, email).Scan(&isMember) `, networkID, humanId).Scan(&isMember)
return isMember, err return isMember, err
} }
func (r *repositoryImpl) setOpenStreamCapacity(ctx context.Context, id string, capacity int) error { func (r *repositoryImpl) listAllMemberships(ctx context.Context) (map[string][]string, error) {
result, err := r.pool.Exec(ctx, rows, err := r.pool.Query(ctx, `SELECT human_id, network_id FROM network_members`)
`UPDATE networks SET open_stream_capacity = $1 WHERE id = $2`,
capacity, id,
)
if err != nil { if err != nil {
return err return nil, err
} }
if result.RowsAffected() == 0 { defer rows.Close()
return errNotFound
out := map[string][]string{}
for rows.Next() {
var humanId, networkId string
if err := rows.Scan(&humanId, &networkId); err != nil {
return nil, err
} }
return nil out[humanId] = append(out[humanId], networkId)
}
return out, rows.Err()
} }
func (r *repositoryImpl) incrementOpenStreamCount(ctx context.Context, id string) error { func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
result, err := r.pool.Exec(ctx, rows, err := r.pool.Query(ctx,
`UPDATE networks SET open_stream_count = open_stream_count + 1 `SELECT `+networkColumns+` FROM networks`,
WHERE id = $1 AND open_stream_count < open_stream_capacity`,
id,
) )
if err != nil { if err != nil {
return err return nil, err
} }
if result.RowsAffected() == 0 { defer rows.Close()
// Check if network exists vs capacity exceeded
var exists bool var networks []*Network
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM networks WHERE id = $1)`, id).Scan(&exists) for rows.Next() {
if err != nil { var n Network
return err if err := scanNetwork(rows, &n); err != nil {
return nil, err
} }
if !exists { networks = append(networks, &n)
return errNotFound
} }
return errCapacityExceeded if err := rows.Err(); err != nil {
} return nil, err
return nil
} }
func (r *repositoryImpl) decrementOpenStreamCount(ctx context.Context, id string) error { for _, n := range networks {
result, err := r.pool.Exec(ctx, n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID)
`UPDATE networks SET open_stream_count = GREATEST(0, open_stream_count - 1) WHERE id = $1`,
id,
)
if err != nil { if err != nil {
return nil, err
}
}
return networks, nil
}
func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO network_invitations (network_id, email) VALUES ($1, $2)
ON CONFLICT (network_id, email) DO NOTHING`,
networkID, email,
)
return err return err
} }
if result.RowsAffected() == 0 {
return errNotFound func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) {
rows, err := r.pool.Query(ctx,
`SELECT ni.network_id, n.name, ni.email, ni.created_at
FROM network_invitations ni
JOIN networks n ON n.id = ni.network_id
WHERE ni.email = $1`,
email,
)
if err != nil {
return nil, err
} }
return nil defer rows.Close()
var invitations []*Invitation
for rows.Next() {
var inv Invitation
if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil {
return nil, err
}
invitations = append(invitations, &inv)
}
return invitations, rows.Err()
}
func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error) {
rows, err := r.pool.Query(ctx,
`SELECT ni.network_id, n.name, ni.email, ni.created_at
FROM network_invitations ni
JOIN networks n ON n.id = ni.network_id
WHERE ni.network_id = $1`,
networkID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var invitations []*Invitation
for rows.Next() {
var inv Invitation
if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil {
return nil, err
}
invitations = append(invitations, &inv)
}
return invitations, rows.Err()
}
func (r *repositoryImpl) deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error {
_, err := db.Exec(ctx,
`DELETE FROM network_invitations WHERE network_id = $1 AND email = $2`,
networkID, email,
)
return err
} }
+322 -74
View File
@@ -4,76 +4,114 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"html"
"slices"
"strings" "strings"
"github.com/flowy-live/llink/internal/utils" "github.com/flowy-live/llink/internal/utils/flog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/constants"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/livestore"
"github.com/flowy-live/llink/internal/utils"
) )
var ErrNotFound = errors.New("network not found") var ErrNotFound = errors.New("network not found")
var ErrInvalidName = errors.New("name cannot be empty") var ErrInvalidName = errors.New("name cannot be empty")
var ErrInvalidEmail = errors.New("invalid email")
var ErrInvalidHumanId = errors.New("invalid humanId")
var ErrCapacityExceeded = errors.New("active stream capacity exceeded") var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336")
type humanLookup interface {
GetByID(ctx context.Context, id string) (*human.Human, error)
}
type Service interface { type Service interface {
// Create creates a network and adds adminEmail as the first member. Returns ErrInvalidName if name is empty. Reader
Create(ctx context.Context, name, adminEmail string) (*Network, error)
// GetByID returns ErrNotFound if network doesn't exist. // Create adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
GetByID(ctx context.Context, id string) (*Network, error) Create(ctx context.Context, name, adminHumanId string) (*Network, error)
// SetName returns ErrNotFound or ErrInvalidName. // SetName returns ErrNotFound or ErrInvalidName.
SetName(ctx context.Context, id, name string) error SetName(ctx context.Context, id, name string) error
AddMembers(ctx context.Context, networkID string, emails []string) error // AddMembers inserts members and syncs the new seat count to billing
RemoveMember(ctx context.Context, networkID, email string) error // atomically; a Stripe failure rolls the insert back.
ListForEmail(ctx context.Context, email string) ([]*Network, error) // Returns ErrInvalidHumanId if any humanId is empty.
IsMember(ctx context.Context, networkID, email string) (bool, error) AddMembers(ctx context.Context, networkID string, humanIds []string) error
// RemoveMember returns ErrInvalidHumanId if humanId is empty.
RemoveMember(ctx context.Context, networkID, humanId string) error
// SetOpenStreamCapacity sets the max open streams for a network. Returns ErrNotFound. // Invitations (email-based, for users who haven't registered yet)
SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error
// IncrementOpenStreamCount returns ErrNotFound or ErrCapacityExceeded. // InviteByEmail returns ErrNotFound if the network doesn't exist
IncrementOpenStreamCount(ctx context.Context, networkID string) error // or ErrInvalidEmail if any email fails normalization.
// DecrementOpenStreamCount returns ErrNotFound. InviteByEmail(ctx context.Context, networkID string, emails []string) error
DecrementOpenStreamCount(ctx context.Context, networkID string) error // AcceptInvitation returns ErrInvalidEmail or ErrInvalidHumanId.
AcceptInvitation(ctx context.Context, networkID, email, humanId string) error
// RevokeInvitation returns ErrInvalidEmail.
RevokeInvitation(ctx context.Context, networkID, email string) error
} }
type serviceImpl struct { type serviceImpl struct {
repo repository *readerImpl
aeroSvc pbaero.PrimaryClient
billingSvc billing.Service
pub livestore.MembershipPublisher
humanLookup humanLookup
} }
func NewService(pool *pgxpool.Pool) Service { func NewService(
return &serviceImpl{repo: newRepository(pool)} pool *pgxpool.Pool,
aeroSvc pbaero.PrimaryClient,
billingSvc billing.Service,
pub livestore.MembershipPublisher,
humanLookup humanLookup,
) Service {
return &serviceImpl{
readerImpl: newReader(pool),
aeroSvc: aeroSvc,
billingSvc: billingSvc,
pub: pub,
humanLookup: humanLookup,
}
} }
func (s *serviceImpl) Create(ctx context.Context, name, adminEmail string) (*Network, error) { func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if name == "" { if name == "" {
return nil, ErrInvalidName return nil, ErrInvalidName
} }
adminEmail, err := utils.NormalizeEmail(adminEmail) network, err := s.repo.create(ctx, name, adminHumanId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
network, err := s.repo.create(ctx, name, adminEmail) if err := s.AddMembers(ctx, network.ID, []string{adminHumanId}); err != nil {
if err != nil {
return nil, err return nil, err
} }
err = s.AddMembers(ctx, network.ID, []string{adminEmail}) _, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: []string{constants.FlowyAdminEmail},
Subject: fmt.Sprintf("New network created: %s", network.Name),
TemplateData: &pbaero.ShootEmailRequest_GenericFlowyAdminAlertData{
GenericFlowyAdminAlertData: &pbaero.GenericFlowyAdminAlertData{
Message: "This is a simple notification that a new network was created. Please attend to them.",
},
},
})
if err != nil { if err != nil {
return nil, err flog.Warn("unable to send admin update email", "error", err)
} }
return network, nil return network, nil
} }
func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Network, error) {
n, err := s.repo.getByID(ctx, id)
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return n, err
}
func (s *serviceImpl) SetName(ctx context.Context, id, name string) error { func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if name == "" { if name == "" {
@@ -87,69 +125,279 @@ func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
return err return err
} }
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, emails []string) error { func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds []string) error {
if slices.Contains(humanIds, "") {
return ErrInvalidHumanId
}
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
for _, humanId := range humanIds {
if err := s.repo.addMember(ctx, tx, networkID, humanId); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
for _, humanId := range humanIds {
s.mirrorAddMembership(ctx, humanId, networkID)
}
return nil
}
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error {
if humanId == "" {
return ErrInvalidHumanId
}
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
return s.repo.removeMember(ctx, tx, networkID, humanId)
}); err != nil {
return err
}
s.mirrorRemoveMembership(ctx, humanId, networkID)
return nil
}
// Mirror the live store membership projection (humans/{humanId}.networks).
// Postgres is the source of truth: failures are logged and the reconciler heals drift.
func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) {
if err := s.pub.Add(ctx, humanId, networkID); err != nil {
flog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID)
}
}
func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, networkID string) {
if err := s.pub.Remove(ctx, humanId, networkID); err != nil {
flog.Error("membership publish remove failed", "error", err, "humanId", humanId, "networkID", networkID)
}
}
// Runs fn in a tx and syncs seats to billing atomically. Any error rolls back.
func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
if err := fn(tx); err != nil {
return err
}
seats, err := s.repo.countSeats(ctx, tx, networkID)
if err != nil {
return fmt.Errorf("count seats: %w", err)
}
if err := s.billingSvc.SyncSeats(ctx, networkID, seats); err != nil {
return fmt.Errorf("sync billing seats: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
network, err := s.repo.getByID(ctx, networkID)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
for _, email := range emails { for _, email := range emails {
normalized, err := utils.NormalizeEmail(email) normalized, err := utils.NormalizeEmail(email)
if err != nil { if err != nil {
return fmt.Errorf("invalid email: %w", err) return fmt.Errorf("%q: %w: %w", email, ErrInvalidEmail, err)
} }
if err := s.repo.addMember(ctx, networkID, normalized); err != nil { if err := s.repo.createInvitation(ctx, networkID, normalized); err != nil {
return err return err
} }
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: []string{email},
Subject: fmt.Sprintf("Invitation to Join %s on Flowy.llink", network.Name),
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
SimpleHtmlData: &pbaero.SimpleHtmlData{
Html: buildInvitationHTML(network.Name),
},
},
})
if err != nil {
flog.Warn("unable to send email notification", "email", email, "network", network.Name)
}
} }
return nil return nil
} }
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, email string) error { func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error {
email, err := utils.NormalizeEmail(email) network, err := s.repo.getByID(ctx, networkID)
if err != nil { if err != nil {
return fmt.Errorf("invalid email: %w", err)
}
return s.repo.removeMember(ctx, networkID, email)
}
func (s *serviceImpl) ListForEmail(ctx context.Context, email string) ([]*Network, error) {
email, err := utils.NormalizeEmail(email)
if err != nil {
return nil, fmt.Errorf("invalid email: %w", err)
}
return s.repo.getNetworksForEmail(ctx, email)
}
func (s *serviceImpl) IsMember(ctx context.Context, networkID, email string) (bool, error) {
email, err := utils.NormalizeEmail(email)
if err != nil {
return false, err
}
return s.repo.isMember(ctx, networkID, email)
}
func (s *serviceImpl) SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error {
if capacity < 0 {
capacity = 0
}
err := s.repo.setOpenStreamCapacity(ctx, networkID, capacity)
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
return ErrNotFound return ErrNotFound
} }
return err return err
} }
func (s *serviceImpl) IncrementOpenStreamCount(ctx context.Context, networkID string) error { normalized, err := utils.NormalizeEmail(email)
err := s.repo.incrementOpenStreamCount(ctx, networkID) if err != nil {
if errors.Is(err, errNotFound) { return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
return ErrNotFound
} }
if errors.Is(err, errCapacityExceeded) { if humanId == "" {
return ErrCapacityExceeded return ErrInvalidHumanId
} }
prevMembersHumanIds, membersErr := s.repo.getMemberHumanIds(ctx, networkID)
if membersErr != nil {
flog.Warn("unable to get member human ids", "error", membersErr)
}
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
if err != nil {
return err
}
return s.repo.addMember(ctx, tx, networkID, humanId)
}); err != nil {
return err return err
} }
func (s *serviceImpl) DecrementOpenStreamCount(ctx context.Context, networkID string) error { if membersErr == nil && len(prevMembersHumanIds) > 0 {
err := s.repo.decrementOpenStreamCount(ctx, networkID) emailRecipients := make([]string, 0, len(prevMembersHumanIds))
if errors.Is(err, errNotFound) { for _, memberHumanId := range prevMembersHumanIds {
return ErrNotFound if memberHumanId != "" {
human, err := s.humanLookup.GetByID(ctx, memberHumanId)
if err != nil {
flog.Warn("unable to find human", "error", err, "humanId", memberHumanId)
continue
} }
return err
if human.Email == "" || !human.EmailNotificationsEnabled {
continue
}
emailRecipients = append(emailRecipients, human.Email)
}
}
if len(emailRecipients) > 0 {
newMemberEmailPrefix := strings.Split(normalized, "@")[0]
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: emailRecipients,
Subject: fmt.Sprintf("A new member has joined %s", network.Name),
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
SimpleHtmlData: &pbaero.SimpleHtmlData{
Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix),
},
},
})
if err != nil {
flog.Warn("unable to send email notification", "email", email, "network", network.Name)
}
}
}
s.mirrorAddMembership(ctx, humanId, networkID)
return nil
}
func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
}
return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized)
}
const (
emailWebAppURL = "https://llink.flowy.live"
emailDesktopURL = "llink://"
emailDownloadURL = "https://flowylabs.ai/llink/download"
)
// emailDesktopFooter is the shared secondary line offering the desktop app.
// The web app is always the primary CTA (no install required), so desktop is
// kept quiet here and shared across templates so the two can't drift apart.
func emailDesktopFooter() string {
return fmt.Sprintf(`<tr>
<td style="font-size:13px;color:#888888;">
Prefer the desktop app? <a href="%s" style="color:#111111;">Open it</a> or <a href="%s" style="color:#111111;">download here</a>.
</td>
</tr>`, emailDesktopURL, emailDownloadURL)
}
func buildInvitationHTML(networkName string) string {
safeName := html.EscapeString(networkName)
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
<tr>
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
You&#39;ve been invited to join %s on Flowy.llink
</td>
</tr>
<tr>
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
Open the app to accept your invitation. If you&#39;re new, you&#39;ll be prompted to create a free account first.
</td>
</tr>
<tr>
<td style="padding-bottom:24px;">
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
Open Flowy.llink (web)
</a>
</td>
</tr>
%s
</table>
</td>
</tr>
</table>
</body>
</html>`, safeName, emailWebAppURL, emailDesktopFooter())
}
// buildNewMemberHTML is an email template to notify other members that a new member has joined
func buildNewMemberHTML(networkName, newMemberEmailPrefix string) string {
safeName := html.EscapeString(networkName)
safeEmail := html.EscapeString(newMemberEmailPrefix)
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
<tr>
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
A new member joined %s
</td>
</tr>
<tr>
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
<strong style="color:#111111;">%s</strong> just joined your network on Flowy.llink. Say hello and bring them up to speed.
</td>
</tr>
<tr>
<td style="padding-bottom:24px;">
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
Open Flowy.llink (web)
</a>
</td>
</tr>
%s
</table>
</td>
</tr>
</table>
</body>
</html>`, safeName, safeEmail, emailWebAppURL, emailDesktopFooter())
} }
+117 -17
View File
@@ -5,10 +5,17 @@ import (
"os" "os"
"testing" "testing"
pbaero "github.com/flowy-live/llink/genproto/aero"
mock_billing "github.com/flowy-live/llink/internal/billing/mocks"
"github.com/flowy-live/llink/internal/human"
mock_human "github.com/flowy-live/llink/internal/human/mocks"
mock_livestore "github.com/flowy-live/llink/internal/livestore/mocks"
"github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/testhelper" "github.com/flowy-live/llink/internal/testhelper"
"github.com/flowy-live/llink/internal/testhelper/mocks/aero"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
) )
var dbPool *pgxpool.Pool var dbPool *pgxpool.Pool
@@ -21,16 +28,47 @@ func TestMain(m *testing.M) {
os.Exit(ret) os.Exit(ret)
} }
func newTestService(t *testing.T) network.Service {
ctrl := gomock.NewController(t)
mockAero := aero.NewMockPrimaryClient(ctrl)
mockAero.EXPECT().
ShootEmail(gomock.Any(), gomock.Any()).
Return(&pbaero.ShootEmailResponse{}, nil).
AnyTimes()
mockBilling := mock_billing.NewMockService(ctrl)
mockBilling.EXPECT().SyncSeats(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockPub := mock_livestore.NewMockMembershipPublisher(ctrl)
mockPub.EXPECT().Add(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockPub.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockHuman := mock_human.NewMockService(ctrl)
mockHuman.EXPECT().GetByID(gomock.Any(), gomock.Any()).Return(&human.Human{
ID: "test_human",
Email: "test@gmail.com",
EmailPrefix: "test",
EmailNotificationsEnabled: false,
}, nil).AnyTimes()
return network.NewService(dbPool, mockAero, mockBilling, mockPub, mockHuman)
}
func TestNetworkService(t *testing.T) { func TestNetworkService(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := network.NewService(dbPool) svc := newTestService(t)
adminHumanId := "human_admin123"
member1HumanId := "human_member1abc"
member2HumanId := "human_member2def"
strangerHumanId := "human_stranger789"
// Test Create // Test Create
createdNetwork, err := svc.Create(ctx, "Test Network", "admin@example.com") createdNetwork, err := svc.Create(ctx, "Test Network", adminHumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, createdNetwork.ID) assert.NotEmpty(t, createdNetwork.ID)
assert.Equal(t, "Test Network", createdNetwork.Name) assert.Equal(t, "Test Network", createdNetwork.Name)
assert.Equal(t, "admin@example.com", createdNetwork.AdminEmail) assert.Equal(t, adminHumanId, createdNetwork.AdminHumanId)
assert.NotZero(t, createdNetwork.CreatedAt) assert.NotZero(t, createdNetwork.CreatedAt)
// Test GetByID // Test GetByID
@@ -38,7 +76,7 @@ func TestNetworkService(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, createdNetwork.ID, foundNetwork.ID) assert.Equal(t, createdNetwork.ID, foundNetwork.ID)
assert.Equal(t, createdNetwork.Name, foundNetwork.Name) assert.Equal(t, createdNetwork.Name, foundNetwork.Name)
assert.Equal(t, createdNetwork.AdminEmail, foundNetwork.AdminEmail) assert.Equal(t, createdNetwork.AdminHumanId, foundNetwork.AdminHumanId)
// Test GetByID with non-existent id // Test GetByID with non-existent id
_, err = svc.GetByID(ctx, "network_nonexistent") _, err = svc.GetByID(ctx, "network_nonexistent")
@@ -60,45 +98,45 @@ func TestNetworkService(t *testing.T) {
assert.ErrorIs(t, err, network.ErrNotFound) assert.ErrorIs(t, err, network.ErrNotFound)
// Test AddMembers // Test AddMembers
err = svc.AddMembers(ctx, createdNetwork.ID, []string{"member1@example.com", "member2@example.com"}) err = svc.AddMembers(ctx, createdNetwork.ID, []string{member1HumanId, member2HumanId})
assert.NoError(t, err) assert.NoError(t, err)
// Test ListForEmail - should find network for admin // Test ListForHuman - should find network for admin
networks, err := svc.ListForEmail(ctx, "admin@example.com") networks, err := svc.ListForHuman(ctx, adminHumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 1) assert.Len(t, networks, 1)
assert.Equal(t, createdNetwork.ID, networks[0].ID) assert.Equal(t, createdNetwork.ID, networks[0].ID)
// Test ListForEmail - should find network for member // Test ListForHuman - should find network for member
networks, err = svc.ListForEmail(ctx, "member1@example.com") networks, err = svc.ListForHuman(ctx, member1HumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 1) assert.Len(t, networks, 1)
assert.Equal(t, createdNetwork.ID, networks[0].ID) assert.Equal(t, createdNetwork.ID, networks[0].ID)
// Test ListForEmail - should return empty for non-member // Test ListForHuman - should return empty for non-member
networks, err = svc.ListForEmail(ctx, "stranger@example.com") networks, err = svc.ListForHuman(ctx, strangerHumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 0) assert.Len(t, networks, 0)
// Test RemoveMember // Test RemoveMember
err = svc.RemoveMember(ctx, createdNetwork.ID, "member1@example.com") err = svc.RemoveMember(ctx, createdNetwork.ID, member1HumanId)
assert.NoError(t, err) assert.NoError(t, err)
// Verify member was removed // Verify member was removed
networks, err = svc.ListForEmail(ctx, "member1@example.com") networks, err = svc.ListForHuman(ctx, member1HumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 0) assert.Len(t, networks, 0)
// member2 should still have access // member2 should still have access
networks, err = svc.ListForEmail(ctx, "member2@example.com") networks, err = svc.ListForHuman(ctx, member2HumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 1) assert.Len(t, networks, 1)
// Create another network and verify ListForEmail returns multiple // Create another network and verify ListForHuman returns multiple
network2, err := svc.Create(ctx, "Second Network", "member2@example.com") network2, err := svc.Create(ctx, "Second Network", member2HumanId)
assert.NoError(t, err) assert.NoError(t, err)
networks, err = svc.ListForEmail(ctx, "member2@example.com") networks, err = svc.ListForHuman(ctx, member2HumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 2) assert.Len(t, networks, 2)
@@ -107,3 +145,65 @@ func TestNetworkService(t *testing.T) {
assert.Contains(t, networkIDs, createdNetwork.ID) assert.Contains(t, networkIDs, createdNetwork.ID)
assert.Contains(t, networkIDs, network2.ID) assert.Contains(t, networkIDs, network2.ID)
} }
func TestNetworkInvitations(t *testing.T) {
ctx := context.Background()
svc := newTestService(t)
adminHumanId := "human_invtest_admin"
inviteeEmail := "invitee@example.com"
inviteeHumanId := "human_invitee123"
// Create a network
net, err := svc.Create(ctx, "Invitation Test Network", adminHumanId)
assert.NoError(t, err)
// Invite by email
err = svc.InviteByEmail(ctx, net.ID, []string{inviteeEmail})
assert.NoError(t, err)
// List invitations for email
invitations, err := svc.ListInvitationsForEmail(ctx, inviteeEmail)
assert.NoError(t, err)
assert.Len(t, invitations, 1)
assert.Equal(t, net.ID, invitations[0].NetworkID)
assert.Equal(t, inviteeEmail, invitations[0].Email)
// List invitations for network
invitations, err = svc.ListInvitationsForNetwork(ctx, net.ID)
assert.NoError(t, err)
assert.Len(t, invitations, 1)
// Duplicate invite is idempotent
err = svc.InviteByEmail(ctx, net.ID, []string{inviteeEmail})
assert.NoError(t, err)
invitations, err = svc.ListInvitationsForNetwork(ctx, net.ID)
assert.NoError(t, err)
assert.Len(t, invitations, 1)
// Accept invitation
err = svc.AcceptInvitation(ctx, net.ID, inviteeEmail, inviteeHumanId)
assert.NoError(t, err)
// Invitation should be removed
invitations, err = svc.ListInvitationsForEmail(ctx, inviteeEmail)
assert.NoError(t, err)
assert.Len(t, invitations, 0)
// Human should now be a member
isMember, err := svc.IsMember(ctx, net.ID, inviteeHumanId)
assert.NoError(t, err)
assert.True(t, isMember)
// Test revoke invitation
revokeEmail := "revokee@example.com"
err = svc.InviteByEmail(ctx, net.ID, []string{revokeEmail})
assert.NoError(t, err)
err = svc.RevokeInvitation(ctx, net.ID, revokeEmail)
assert.NoError(t, err)
invitations, err = svc.ListInvitationsForEmail(ctx, revokeEmail)
assert.NoError(t, err)
assert.Len(t, invitations, 0)
}
+41
View File
@@ -0,0 +1,41 @@
package network
import "strings"
// ResolveVisibility expands a stream particle's visible_to entries into the set
// of human IDs that should see (and thus be notified about) activity in that
// stream. Entries are formatted as `human:{id}` for a specific human or
// `network:{id}` to expand to every member of the surrounding network.
//
// networkMembers must contain every human currently in the network (members +
// admin). visible_to entries that point to humans no longer in the network are
// dropped — they may have been removed since the stream was created.
//
// Returns a deduped slice; ordering is not stable.
func ResolveVisibility(visibleTo []string, networkMembers []string) []string {
memberSet := make(map[string]bool, len(networkMembers))
for _, id := range networkMembers {
memberSet[id] = true
}
result := make(map[string]bool)
for _, entry := range visibleTo {
switch {
case strings.HasPrefix(entry, "human:"):
id := strings.TrimPrefix(entry, "human:")
if memberSet[id] {
result[id] = true
}
case strings.HasPrefix(entry, "network:"):
for id := range memberSet {
result[id] = true
}
}
}
out := make([]string, 0, len(result))
for id := range result {
out = append(out, id)
}
return out
}
+1
View File
@@ -16,4 +16,5 @@ var (
ErrMembersRequired = errors.New("custom visibility requires at least one member") ErrMembersRequired = errors.New("custom visibility requires at least one member")
ErrInheritedAtRoot = errors.New("root particles cannot use inherited visibility") ErrInheritedAtRoot = errors.New("root particles cannot use inherited visibility")
ErrNotAContainer = errors.New("only streams can have members") ErrNotAContainer = errors.New("only streams can have members")
ErrInvalidMember = errors.New("member is not in the network")
) )
+57
View File
@@ -0,0 +1,57 @@
package particle
import "time"
type FirestoreMediaParticle struct {
CreatedByHumanId string `firestore:"created_by_human_id"`
Type string `firestore:"type"`
Properties FirestoreMediaParticleProperties `firestore:"properties"`
CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
}
type FirestoreTranscriptWord struct {
Word string `firestore:"word"`
Start float64 `firestore:"start"`
End float64 `firestore:"end"`
}
type FirestoreTranscriptSentence struct {
Text string `firestore:"text"`
Start float64 `firestore:"start"`
End float64 `firestore:"end"`
}
type FirestoreTranscriptParagraph struct {
Sentences []FirestoreTranscriptSentence `firestore:"sentences"`
Start float64 `firestore:"start"`
End float64 `firestore:"end"`
}
type FirestoreTranscript struct {
Transcript string `firestore:"transcript"`
Words []FirestoreTranscriptWord `firestore:"words"`
Paragraphs []FirestoreTranscriptParagraph `firestore:"paragraphs"`
}
type FirestoreMediaParticleProperties struct {
ObjectId string `firestore:"object_id"`
MimeType string `firestore:"mime_type"`
DurationMs int `firestore:"duration_ms"`
SizeBytes int `firestore:"size_bytes"`
Transcript *FirestoreTranscript `firestore:"transcript,omitempty"`
TranscodedObjectId string `firestore:"transcoded_object_id,omitempty"`
TranscodedMimeType string `firestore:"transcoded_mime_type,omitempty"`
}
type FirestoreStreamParticle struct {
CreatedByHumanId string `firestore:"created_by_human_id"`
Type string `firestore:"type"`
Status string `firestore:"status"`
CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
LastChildCreatedAt *time.Time `firestore:"last_child_created_at,omitempty"`
VisibleTo []string `firestore:"visible_to"`
PlaybackMarkers map[string]time.Time `firestore:"playback_markers,omitempty"`
UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
HuddleActiveParticipants []string `firestore:"huddle_active_participants,omitempty"`
}
@@ -0,0 +1,56 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./networkmembershipchecker.go
//
// Generated by this command:
//
// mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
//
// Package mock_particle is a generated GoMock package.
package mock_particle
import (
context "context"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockNetworkMembershipChecker is a mock of NetworkMembershipChecker interface.
type MockNetworkMembershipChecker struct {
ctrl *gomock.Controller
recorder *MockNetworkMembershipCheckerMockRecorder
isgomock struct{}
}
// MockNetworkMembershipCheckerMockRecorder is the mock recorder for MockNetworkMembershipChecker.
type MockNetworkMembershipCheckerMockRecorder struct {
mock *MockNetworkMembershipChecker
}
// NewMockNetworkMembershipChecker creates a new mock instance.
func NewMockNetworkMembershipChecker(ctrl *gomock.Controller) *MockNetworkMembershipChecker {
mock := &MockNetworkMembershipChecker{ctrl: ctrl}
mock.recorder = &MockNetworkMembershipCheckerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockNetworkMembershipChecker) EXPECT() *MockNetworkMembershipCheckerMockRecorder {
return m.recorder
}
// IsMember mocks base method.
func (m *MockNetworkMembershipChecker) IsMember(ctx context.Context, networkID, humanId string) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsMember", ctx, networkID, humanId)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// IsMember indicates an expected call of IsMember.
func (mr *MockNetworkMembershipCheckerMockRecorder) IsMember(ctx, networkID, humanId any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsMember", reflect.TypeOf((*MockNetworkMembershipChecker)(nil).IsMember), ctx, networkID, humanId)
}
+4 -23
View File
@@ -6,7 +6,6 @@ import (
"time" "time"
) )
// ParticleType represents the type of particle
type ParticleType string type ParticleType string
const ( const (
@@ -20,7 +19,6 @@ const (
// TypeThink ParticleType = "think" // TypeThink ParticleType = "think"
) )
// VisibilityMode represents how access to a particle is determined
type VisibilityMode string type VisibilityMode string
const ( const (
@@ -32,7 +30,6 @@ const (
var ErrInvalidParticleType = errors.New("invalid particle type") var ErrInvalidParticleType = errors.New("invalid particle type")
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode") var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
// ParseParticleType parses a string into a ParticleType
func ParseParticleType(s string) (ParticleType, error) { func ParseParticleType(s string) (ParticleType, error) {
switch s { switch s {
case string(TypeStream): case string(TypeStream):
@@ -54,7 +51,6 @@ func ParseParticleType(s string) (ParticleType, error) {
} }
} }
// ParseVisibilityMode parses a string into a VisibilityMode
func ParseVisibilityMode(s string) (VisibilityMode, error) { func ParseVisibilityMode(s string) (VisibilityMode, error) {
switch s { switch s {
case "", string(VisibilityNetworkAll): case "", string(VisibilityNetworkAll):
@@ -68,7 +64,6 @@ func ParseVisibilityMode(s string) (VisibilityMode, error) {
} }
} }
// Stream status values
type StreamStatus string type StreamStatus string
const ( const (
@@ -76,7 +71,6 @@ const (
StreamStatusClosed StreamStatus = "closed" StreamStatusClosed StreamStatus = "closed"
) )
// Particle represents a content particle in the system
type Particle struct { type Particle struct {
ID string ID string
Type ParticleType Type ParticleType
@@ -89,7 +83,6 @@ type Particle struct {
CreatedAt time.Time CreatedAt time.Time
} }
// CreateInput represents the input for creating a new particle
type CreateInput struct { type CreateInput struct {
Type ParticleType Type ParticleType
NetworkID string NetworkID string
@@ -99,18 +92,15 @@ type CreateInput struct {
Visibility VisibilityMode Visibility VisibilityMode
} }
// ListFilter represents filtering options for listing particles
type ListFilter struct { type ListFilter struct {
Types []ParticleType Types []ParticleType
} }
// Cursor represents a pagination cursor for bidirectional pagination
type Cursor struct { type Cursor struct {
Position string // particle ID or timestamp Position string // particle ID or timestamp
Direction string // "before" or "after" Direction string // "before" or "after"
} }
// ParticleList represents a paginated list of particles
type ParticleList struct { type ParticleList struct {
Particles []*Particle Particles []*Particle
HasMore bool HasMore bool
@@ -118,57 +108,48 @@ type ParticleList struct {
PrevCursor *Cursor PrevCursor *Cursor
} }
// StreamData represents the data stored for stream particles
type StreamData struct { type StreamData struct {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` // "open" or "closed" Status string `json:"status"` // "open" or "closed"
Description *string `json:"description"` Description *string `json:"description"`
} }
// FolderData represents the data stored for folder particles
type FolderData struct { type FolderData struct {
Name string `json:"name"` Name string `json:"name"`
Color *string `json:"color"` Color *string `json:"color"`
} }
// MediaData represents the data stored for media particles
type MediaData struct { type MediaData struct {
ObjectID string `json:"object_id"` // reference to storage object ObjectID string `json:"object_id"`
MimeType string `json:"mime_type"` MimeType string `json:"mime_type"`
DurationMs int `json:"duration_ms"` DurationMs int `json:"duration_ms"`
// Caption *string `json:"caption"`
} }
// FileData represents the data stored for file particles
type FileData struct { type FileData struct {
ObjectID string `json:"object_id"` // reference to storage object ObjectID string `json:"object_id"`
Filename string `json:"filename"` Filename string `json:"filename"`
MimeType string `json:"mime_type"` MimeType string `json:"mime_type"`
Size int64 `json:"size"` // in bytes Size int64 `json:"size"` // bytes
} }
// TextData represents the data stored for text particles
type TextData struct { type TextData struct {
Content string `json:"content"` Content string `json:"content"`
} }
// QuestData represents the data stored for quest particles
type QuestData struct { type QuestData struct {
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Done bool `json:"done"` Done bool `json:"done"`
Status *string `json:"status"` Status *string `json:"status"`
AssignedTo *string `json:"assigned_to,omitempty"` // email AssignedTo *string `json:"assigned_to,omitempty"` // email
DueDate *string `json:"due_date,omitempty"` // ISO date string DueDate *string `json:"due_date,omitempty"` // ISO date
} }
// PaperData represents the data stored for paper particles
type PaperData struct { type PaperData struct {
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` // markdown Content string `json:"content"` // markdown
} }
// AckInfo represents an acknowledgment record
type AckInfo struct { type AckInfo struct {
Email string Email string
AckedAt time.Time AckedAt time.Time
@@ -0,0 +1,9 @@
package particle
import "context"
//go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
type NetworkMembershipChecker interface {
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
}
@@ -0,0 +1,37 @@
package particle
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
type ProcessingRepository interface {
IsProcessed(ctx context.Context, particleID string) (bool, error)
MarkProcessed(ctx context.Context, particleID string) error
}
type processingRepositoryImpl struct {
pool *pgxpool.Pool
}
func NewProcessingRepository(pool *pgxpool.Pool) ProcessingRepository {
return &processingRepositoryImpl{pool: pool}
}
func (r *processingRepositoryImpl) IsProcessed(ctx context.Context, particleID string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM processed_particles WHERE particle_id = $1)`,
particleID,
).Scan(&exists)
return exists, err
}
func (r *processingRepositoryImpl) MarkProcessed(ctx context.Context, particleID string) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO processed_particles (particle_id) VALUES ($1) ON CONFLICT (particle_id) DO NOTHING`,
particleID,
)
return err
}
+1 -1
View File
@@ -40,7 +40,7 @@ type repository interface {
getMembers(ctx context.Context, particleID string) ([]string, error) getMembers(ctx context.Context, particleID string) ([]string, error)
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
// getAncestorChain returns the particle and all its ancestors (for access checks) // getAncestorChain returns the particle followed by its ancestors, in order.
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error) getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
isMemberOf(ctx context.Context, particleID, email string) (bool, error) isMemberOf(ctx context.Context, particleID, email string) (bool, error)
+82 -144
View File
@@ -4,54 +4,55 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog" "fmt"
"time" "time"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/utils" "github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
const defaultPageSize = 50 const defaultPageSize = 50
// Deprecated: particle data now lives in Firestore. The Postgres-backed
// service is retained only for legacy paths.
type Service interface { type Service interface {
// Create creates a new particle. Caller must be a network member (verified by handler). // Create returns ErrInvalidType, ErrInvalidData, ErrMembersRequired,
// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. // ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. Network
// membership is verified by the handler.
Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error) Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
// GetByID returns ErrNotFound or ErrAccessDenied. // GetByID returns ErrNotFound or ErrAccessDenied.
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error) GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
// Update updates the particle's data. Returns ErrNotFound, ErrAccessDenied, or ErrInvalidData. // Update returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error) Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
// Delete returns ErrNotFound or ErrAccessDenied. // Delete returns ErrNotFound or ErrAccessDenied.
Delete(ctx context.Context, id, requesterEmail string) error Delete(ctx context.Context, id, requesterEmail string) error
// List returns particles in a network. Use parentID=nil for root particles. // List uses parentID=nil for root particles. Returns ErrNotFound or
// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible. // ErrAccessDenied when parentID is given but inaccessible.
List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error) List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)
// OpenStream opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded. // OpenStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream,
// ErrStreamAlreadyOpen, or ErrCapacityExceeded.
OpenStream(ctx context.Context, id, requesterEmail string) error OpenStream(ctx context.Context, id, requesterEmail string) error
// CloseStream closes an open stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or ErrStreamAlreadyClosed. // CloseStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or
// ErrStreamAlreadyClosed.
CloseStream(ctx context.Context, id, requesterEmail string) error CloseStream(ctx context.Context, id, requesterEmail string) error
// SetVisibility changes the particle's visibility mode. Returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion. // SetVisibility returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
// AddMembers adds members to a custom visibility particle. Returns ErrNotFound or ErrAccessDenied. // AddMembers / RemoveMembers operate on custom-visibility streams only.
// Both return ErrNotFound or ErrAccessDenied.
AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
// RemoveMembers removes members from a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
// Seen tracking (private) // Seen tracking is private per human; Ack is public and permanent.
MarkSeen(ctx context.Context, id, requesterEmail string) error MarkSeen(ctx context.Context, id, requesterEmail string) error
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
// Ack tracking (public, permanent)
Ack(ctx context.Context, id, requesterEmail string) error Ack(ctx context.Context, id, requesterEmail string) error
// Unseen counts for stream list view
GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error) GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error)
// Bulk lookups for handler enrichment // Bulk lookups for batch hydration.
GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error) GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
@@ -59,20 +60,19 @@ type Service interface {
type serviceImpl struct { type serviceImpl struct {
repo repository repo repository
networkSvc network.Service networkMembershipChecker NetworkMembershipChecker
} }
func NewService(pool *pgxpool.Pool, networkSvc network.Service) Service { func NewService(pool *pgxpool.Pool, networkReader NetworkMembershipChecker) Service {
return &serviceImpl{ return &serviceImpl{
repo: newRepository(pool), repo: newRepository(pool),
networkSvc: networkSvc, networkMembershipChecker: networkReader,
} }
} }
// checkAccess verifies that the email has access to the particle based on visibility. // Walks the ancestor chain when visibility is inherited, stopping at the
// Assumes the caller is already verified as a network member (handler responsibility). // first network_all or custom node. Assumes network membership is already
// Walks up the ancestor chain only when visibility is inherited, stopping at the first // verified by the handler.
// network_all or custom node.
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) { func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
ancestors, err := s.repo.getAncestorChain(ctx, particleID) ancestors, err := s.repo.getAncestorChain(ctx, particleID)
if err != nil { if err != nil {
@@ -83,13 +83,12 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
return false, errNotFound return false, errNotFound
} }
// Build lookup map by ID
byID := make(map[string]*Particle, len(ancestors)) byID := make(map[string]*Particle, len(ancestors))
for _, p := range ancestors { for _, p := range ancestors {
byID[p.ID] = p byID[p.ID] = p
} }
// Start from the target particle (first in chain) and walk up on inherited // ancestors[0] is the target; walk up only on inherited.
current := ancestors[0] current := ancestors[0]
for { for {
switch current.Visibility { switch current.Visibility {
@@ -99,7 +98,7 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
return s.repo.isMemberOf(ctx, current.ID, email) return s.repo.isMemberOf(ctx, current.ID, email)
case VisibilityInherited: case VisibilityInherited:
if current.ParentID == nil { if current.ParentID == nil {
// inherited at root is invalid state, deny access // inherited-at-root is invalid; deny.
return false, nil return false, nil
} }
parent, ok := byID[*current.ParentID] parent, ok := byID[*current.ParentID]
@@ -119,30 +118,24 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
return nil, err return nil, err
} }
// Validate particle type
if !isValidParticleType(input.Type) { if !isValidParticleType(input.Type) {
return nil, ErrInvalidType return nil, ErrInvalidType
} }
// Validate data matches type requirements
if err := validateParticleData(input.Type, input.Data); err != nil { if err := validateParticleData(input.Type, input.Data); err != nil {
return nil, err return nil, err
} }
// MVP visibility rules: // MVP visibility: children always inherit; roots cannot inherit and
// - Child particles (have parent) → always inherited // default to network_all. Streams/folders are root-only.
// - Root particles (no parent) → cannot be inherited, default network_all
if input.ParentID != nil { if input.ParentID != nil {
// Children always inherit from parent
input.Visibility = VisibilityInherited input.Visibility = VisibilityInherited
input.Members = nil // no members on inherited particles input.Members = nil
// Reject streams and folders as children (MVP: streams are root-level only)
if input.Type == TypeStream || input.Type == TypeFolder { if input.Type == TypeStream || input.Type == TypeFolder {
return nil, ErrInvalidParent return nil, ErrInvalidParent
} }
} else { } else {
// Root particles cannot be inherited
if input.Visibility == VisibilityInherited { if input.Visibility == VisibilityInherited {
return nil, ErrInheritedAtRoot return nil, ErrInheritedAtRoot
} }
@@ -151,7 +144,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
} }
} }
// Custom visibility requires at least one member and must be a stream var customMembers []string
if input.Visibility == VisibilityCustom { if input.Visibility == VisibilityCustom {
if input.Type != TypeStream { if input.Type != TypeStream {
return nil, ErrNotAContainer return nil, ErrNotAContainer
@@ -159,10 +152,33 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
if len(input.Members) == 0 { if len(input.Members) == 0 {
return nil, ErrMembersRequired return nil, ErrMembersRequired
} }
// Validate every member upfront so DB writes are all-or-nothing.
customMembers = make([]string, 0, len(input.Members)+1)
customMembers = append(customMembers, requesterEmail)
seen := map[string]bool{requesterEmail: true}
for _, email := range input.Members {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
return nil, errors.Join(ErrInvalidData, err)
}
if seen[normalized] {
continue
}
seen[normalized] = true
isMember, err := s.networkMembershipChecker.IsMember(ctx, input.NetworkID, normalized)
if err != nil {
return nil, err
}
if !isMember {
return nil, fmt.Errorf("%w: %s", ErrInvalidMember, normalized)
}
customMembers = append(customMembers, normalized)
}
} }
// Network membership is verified by handler - we only check particle visibility // Network membership is verified by the handler; only particle visibility is checked here.
// If parent specified, check parent access (visibility-based)
if input.ParentID != nil { if input.ParentID != nil {
hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail) hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
if err != nil { if err != nil {
@@ -176,7 +192,6 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
} }
} }
// Build the particle
p := &Particle{ p := &Particle{
Type: input.Type, Type: input.Type,
NetworkID: input.NetworkID, NetworkID: input.NetworkID,
@@ -190,53 +205,22 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
p.Data = json.RawMessage("{}") p.Data = json.RawMessage("{}")
} }
// For streams, set initial status to open and check capacity // New streams default to open.
if input.Type == TypeStream { if input.Type == TypeStream {
// Set status to open in the data JSON
data, err := setStreamStatus(p.Data, string(StreamStatusOpen)) data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
if err != nil { if err != nil {
return nil, err return nil, err
} }
p.Data = data p.Data = data
// Check and increment capacity
err = s.networkSvc.IncrementOpenStreamCount(ctx, input.NetworkID)
if err != nil {
if errors.Is(err, network.ErrCapacityExceeded) {
return nil, ErrCapacityExceeded
}
return nil, err
}
} }
// Create the particle
created, err := s.repo.create(ctx, p) created, err := s.repo.create(ctx, p)
if err != nil { if err != nil {
// If we incremented the stream count but creation failed, decrement it
if input.Type == TypeStream {
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, input.NetworkID); decErr != nil {
slog.Warn("failed to rollback stream count after particle creation failure", "error", decErr, "network_id", input.NetworkID)
}
}
return nil, err return nil, err
} }
// Add members if custom visibility (only streams for MVP) if len(customMembers) > 0 {
if input.Visibility == VisibilityCustom && len(input.Members) > 0 { if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil {
normalizedEmails := make([]string, 0, len(input.Members)+1)
// Always include the creator
normalizedEmails = append(normalizedEmails, requesterEmail)
for _, email := range input.Members {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
continue // Skip invalid emails
}
if normalized == requesterEmail {
continue // Already added
}
normalizedEmails = append(normalizedEmails, normalized)
}
if err := s.repo.addMembers(ctx, created.ID, normalizedEmails); err != nil {
return nil, err return nil, err
} }
} }
@@ -250,7 +234,6 @@ func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*
return nil, err return nil, err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -278,7 +261,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
return nil, err return nil, err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -290,7 +272,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
return nil, ErrAccessDenied return nil, ErrAccessDenied
} }
// Get the particle to validate data against its type
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -299,7 +280,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
return nil, err return nil, err
} }
// Validate data matches type requirements
if err := validateParticleData(p.Type, data); err != nil { if err := validateParticleData(p.Type, data); err != nil {
return nil, err return nil, err
} }
@@ -321,7 +301,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -333,8 +312,7 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle to check if it's an open stream _, err = s.repo.getByID(ctx, id)
p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
return ErrNotFound return ErrNotFound
@@ -342,13 +320,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
return err return err
} }
// If it's an open stream, decrement the count
if p.Type == TypeStream && getStreamStatus(p.Data) == string(StreamStatusOpen) {
if err := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); err != nil {
return err
}
}
err = s.repo.delete(ctx, id) err = s.repo.delete(ctx, id)
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
return ErrNotFound return ErrNotFound
@@ -362,8 +333,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
return nil, err return nil, err
} }
// Network membership is verified by handler - we only check particle visibility // Network membership is verified by the handler; only particle visibility is checked here.
// If parentID specified, check access to parent (visibility-based)
if parentID != nil { if parentID != nil {
hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail) hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
if err != nil { if err != nil {
@@ -377,8 +347,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
} }
} }
// Fetch one extra to determine if there are more // Fetch limit+1 to detect a next page; visibility filtering lives in the query.
// Access filtering is done in the query itself (network_all OR user is member)
if limit == 0 { if limit == 0 {
limit = defaultPageSize limit = defaultPageSize
} }
@@ -425,7 +394,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -437,7 +405,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -454,30 +421,13 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
return ErrStreamAlreadyOpen return ErrStreamAlreadyOpen
} }
// Check and increment capacity
err = s.networkSvc.IncrementOpenStreamCount(ctx, p.NetworkID)
if err != nil {
if errors.Is(err, network.ErrCapacityExceeded) {
return ErrCapacityExceeded
}
return err
}
// Update stream status in data
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen)) newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
if err != nil { if err != nil {
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
slog.Warn("failed to rollback stream count after status update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
}
return err return err
} }
err = s.repo.update(ctx, id, newData, time.Now()) err = s.repo.update(ctx, id, newData, time.Now())
if err != nil { if err != nil {
// Rollback the capacity increment
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
slog.Warn("failed to rollback stream count after particle update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
}
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
return ErrNotFound return ErrNotFound
} }
@@ -493,7 +443,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -505,7 +454,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -522,7 +470,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return ErrStreamAlreadyClosed return ErrStreamAlreadyClosed
} }
// Update stream status in data
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed)) newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
if err != nil { if err != nil {
return err return err
@@ -536,8 +483,7 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return err return err
} }
// Decrement capacity return nil
return s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID)
} }
func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error { func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error {
@@ -546,7 +492,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -558,7 +503,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle to check constraints
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -567,12 +511,11 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return err return err
} }
// Root particles cannot be inherited
if mode == VisibilityInherited && p.ParentID == nil { if mode == VisibilityInherited && p.ParentID == nil {
return ErrInheritedAtRoot return ErrInheritedAtRoot
} }
// If expanding to network_all, check that parent's effective visibility allows it // Expanding to network_all is rejected if any ancestor restricts to custom.
if mode == VisibilityNetworkAll && p.ParentID != nil { if mode == VisibilityNetworkAll && p.ParentID != nil {
parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID) parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
if err != nil { if err != nil {
@@ -590,7 +533,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return err return err
} }
// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode. // Walks up the inherited chain to the concrete visibility node.
func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) { func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
ancestors, err := s.repo.getAncestorChain(ctx, particleID) ancestors, err := s.repo.getAncestorChain(ctx, particleID)
if err != nil { if err != nil {
@@ -627,7 +570,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -639,7 +581,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle to check type and parent access
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -648,25 +589,30 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
return err return err
} }
// Only streams can have members
if p.Type != TypeStream { if p.Type != TypeStream {
return ErrNotAContainer return ErrNotAContainer
} }
// Validate and normalize emails, check network membership // Validate every email upfront so any failure aborts before DB writes.
normalizedEmails := make([]string, 0, len(emails)) normalizedEmails := make([]string, 0, len(emails))
seen := make(map[string]bool, len(emails))
for _, email := range emails { for _, email := range emails {
normalized, err := utils.NormalizeEmail(email) normalized, err := utils.NormalizeEmail(email)
if err != nil { if err != nil {
return errors.Join(ErrInvalidData, err)
}
if seen[normalized] {
continue continue
} }
seen[normalized] = true
// Root stream - check network membership isMember, err := s.networkMembershipChecker.IsMember(ctx, p.NetworkID, normalized)
isMember, err := s.networkSvc.IsMember(ctx, p.NetworkID, normalized) if err != nil {
if err != nil || !isMember { return err
continue }
if !isMember {
return fmt.Errorf("%w: %s", ErrInvalidMember, normalized)
} }
normalizedEmails = append(normalizedEmails, normalized) normalizedEmails = append(normalizedEmails, normalized)
} }
@@ -683,7 +629,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -695,7 +640,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle to check type
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -704,7 +648,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
return err return err
} }
// Only streams can have members
if p.Type != TypeStream { if p.Type != TypeStream {
return ErrNotAContainer return ErrNotAContainer
} }
@@ -731,7 +674,6 @@ func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) e
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -752,17 +694,17 @@ func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requester
return err return err
} }
// Check access for each particle and mark seen // Silently skip particles that are missing or inaccessible.
for _, id := range ids { for _, id := range ids {
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
continue // Skip non-existent particles continue
} }
return err return err
} }
if !hasAccess { if !hasAccess {
continue // Skip inaccessible particles continue
} }
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil { if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
@@ -779,7 +721,6 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -791,7 +732,7 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
return ErrAccessDenied return ErrAccessDenied
} }
// Ack also marks as seen // Ack implies seen.
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil { if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
return err return err
} }
@@ -834,7 +775,6 @@ func isValidParticleType(t ParticleType) bool {
} }
} }
// getStreamStatus extracts the status from a stream particle's data
func getStreamStatus(data json.RawMessage) string { func getStreamStatus(data json.RawMessage) string {
var d StreamData var d StreamData
if err := json.Unmarshal(data, &d); err != nil { if err := json.Unmarshal(data, &d); err != nil {
@@ -843,7 +783,6 @@ func getStreamStatus(data json.RawMessage) string {
return d.Status return d.Status
} }
// setStreamStatus updates the status in a stream particle's data
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) { func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
var d StreamData var d StreamData
if err := json.Unmarshal(data, &d); err != nil { if err := json.Unmarshal(data, &d); err != nil {
@@ -853,10 +792,9 @@ func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, erro
return json.Marshal(d) return json.Marshal(d)
} }
// validateParticleData validates that the data field contains valid JSON // Returns ErrInvalidData if data is not valid JSON or is missing required
// and has required fields for the given particle type. // fields for pType. Empty/null data is allowed and treated as {}.
func validateParticleData(pType ParticleType, data json.RawMessage) error { func validateParticleData(pType ParticleType, data json.RawMessage) error {
// Empty or null data is allowed - will default to {}
if len(data) == 0 || string(data) == "null" || string(data) == "{}" { if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
return nil return nil
} }
+215 -112
View File
@@ -3,14 +3,16 @@ package particle_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"os" "os"
"testing" "testing"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle" "github.com/flowy-live/llink/internal/particle"
mock_particle "github.com/flowy-live/llink/internal/particle/mocks"
"github.com/flowy-live/llink/internal/testhelper" "github.com/flowy-live/llink/internal/testhelper"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
) )
var dbPool *pgxpool.Pool var dbPool *pgxpool.Pool
@@ -31,27 +33,46 @@ func getStreamStatus(data json.RawMessage) string {
return d.Status return d.Status
} }
// newTestService returns a particle.Service backed by the test DB and a fresh
// MockNetworkMembershipChecker. The mock is returned so each test can set its
// own EXPECT() calls for whatever membership behavior it needs. Tests that
// never trigger AddMembers (the only path that consults the checker) can
// discard the mock with _ — gomock will fail the test if it's called
// unexpectedly.
func newTestService(t *testing.T) (particle.Service, *mock_particle.MockNetworkMembershipChecker) {
ctrl := gomock.NewController(t)
mockChecker := mock_particle.NewMockNetworkMembershipChecker(ctrl)
return particle.NewService(dbPool, mockChecker), mockChecker
}
// expectIsMember configures the mock so that IsMember(networkID, email) returns
// the given result. Each entry is matched once — the test fails if a configured
// email is never queried, or if any unconfigured email is.
func expectIsMember(mock *mock_particle.MockNetworkMembershipChecker, networkID string, results map[string]bool) {
for email, isMember := range results {
mock.EXPECT().
IsMember(gomock.Any(), networkID, email).
Return(isMember, nil)
}
}
func TestParticleService_CreateAndGet(t *testing.T) { func TestParticleService_CreateAndGet(t *testing.T) {
ctx := context.Background() ctx := context.Background()
networkSvc := network.NewService(dbPool) svc, _ := newTestService(t)
svc := particle.NewService(dbPool, networkSvc)
// Create a network first
net, err := networkSvc.Create(ctx, "Test Network", "admin@example.com")
assert.NoError(t, err)
networkID := "net_123"
// Test Create stream particle // Test Create stream particle
data := json.RawMessage(`{"name":"My Stream","status":"open","description":"A test stream"}`) data := json.RawMessage(`{"name":"My Stream","status":"open","description":"A test stream"}`)
input := particle.CreateInput{ input := particle.CreateInput{
Type: particle.TypeStream, Type: particle.TypeStream,
NetworkID: net.ID, NetworkID: networkID,
Data: data, Data: data,
} }
created, err := svc.Create(ctx, input, "admin@example.com") created, err := svc.Create(ctx, input, "admin@example.com")
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, created.ID) assert.NotEmpty(t, created.ID)
assert.Equal(t, particle.TypeStream, created.Type) assert.Equal(t, particle.TypeStream, created.Type)
assert.Equal(t, net.ID, created.NetworkID) assert.Equal(t, networkID, created.NetworkID)
assert.Nil(t, created.ParentID) assert.Nil(t, created.ParentID)
assert.Equal(t, particle.VisibilityNetworkAll, created.Visibility) assert.Equal(t, particle.VisibilityNetworkAll, created.Visibility)
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(created.Data)) assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(created.Data))
@@ -70,71 +91,16 @@ func TestParticleService_CreateAndGet(t *testing.T) {
// Service assumes caller is already verified as network member // Service assumes caller is already verified as network member
} }
func TestParticleService_StreamCapacity(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network with capacity 2
net, err := networkSvc.Create(ctx, "Capacity Test Network", "admin@example.com")
assert.NoError(t, err)
err = networkSvc.SetOpenStreamCapacity(ctx, net.ID, 2)
assert.NoError(t, err)
// Create first stream - should succeed
input := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: net.ID,
Data: json.RawMessage(`{"name":"Stream 1","status":"open"}`),
}
stream1, err := svc.Create(ctx, input, "admin@example.com")
assert.NoError(t, err)
// Create second stream - should succeed
input.Data = json.RawMessage(`{"name":"Stream 2","status":"open"}`)
stream2, err := svc.Create(ctx, input, "admin@example.com")
assert.NoError(t, err)
// Create third stream - should fail with capacity exceeded
input.Data = json.RawMessage(`{"name":"Stream 3","status":"open"}`)
_, err = svc.Create(ctx, input, "admin@example.com")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrCapacityExceeded)
// Close a stream
err = svc.CloseStream(ctx, stream1.ID, "admin@example.com")
assert.NoError(t, err)
// Now we can create another stream
stream3, err := svc.Create(ctx, input, "admin@example.com")
assert.NoError(t, err)
assert.NotEmpty(t, stream3.ID)
// Verify stream2 is still open
found, err := svc.GetByID(ctx, stream2.ID, "admin@example.com")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data))
// Verify stream1 is closed
found, err = svc.GetByID(ctx, stream1.ID, "admin@example.com")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data))
}
func TestParticleService_NestedParticles(t *testing.T) { func TestParticleService_NestedParticles(t *testing.T) {
ctx := context.Background() ctx := context.Background()
networkSvc := network.NewService(dbPool) svc, _ := newTestService(t)
svc := particle.NewService(dbPool, networkSvc)
// Create a network networkID := "net_123"
net, err := networkSvc.Create(ctx, "Nested Test Network", "admin@example.com")
assert.NoError(t, err)
// Create a parent stream // Create a parent stream
streamInput := particle.CreateInput{ streamInput := particle.CreateInput{
Type: particle.TypeStream, Type: particle.TypeStream,
NetworkID: net.ID, NetworkID: networkID,
Data: json.RawMessage(`{"name":"Parent Stream","status":"open"}`), Data: json.RawMessage(`{"name":"Parent Stream","status":"open"}`),
} }
stream, err := svc.Create(ctx, streamInput, "admin@example.com") stream, err := svc.Create(ctx, streamInput, "admin@example.com")
@@ -143,7 +109,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
// Create a text particle as child // Create a text particle as child
textInput := particle.CreateInput{ textInput := particle.CreateInput{
Type: particle.TypeText, Type: particle.TypeText,
NetworkID: net.ID, NetworkID: networkID,
ParentID: &stream.ID, ParentID: &stream.ID,
Data: json.RawMessage(`{"content":"Hello world"}`), Data: json.RawMessage(`{"content":"Hello world"}`),
} }
@@ -154,7 +120,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
// Create a file as child of stream // Create a file as child of stream
fileInput := particle.CreateInput{ fileInput := particle.CreateInput{
Type: particle.TypeFile, Type: particle.TypeFile,
NetworkID: net.ID, NetworkID: networkID,
ParentID: &stream.ID, ParentID: &stream.ID,
Data: json.RawMessage(`{"object_id":"obj_abc123","filename":"test.pdf","mime_type":"application/pdf","size":1024}`), Data: json.RawMessage(`{"object_id":"obj_abc123","filename":"test.pdf","mime_type":"application/pdf","size":1024}`),
} }
@@ -163,27 +129,26 @@ func TestParticleService_NestedParticles(t *testing.T) {
assert.Equal(t, stream.ID, *file.ParentID) assert.Equal(t, stream.ID, *file.ParentID)
// List children of stream // List children of stream
children, err := svc.List(ctx, net.ID, &stream.ID, "admin@example.com", particle.ListFilter{}, nil, 50) children, err := svc.List(ctx, networkID, &stream.ID, "admin@example.com", particle.ListFilter{}, nil, 50)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, children.Particles, 2) assert.Len(t, children.Particles, 2)
} }
func TestParticleService_CustomVisibility(t *testing.T) { func TestParticleService_CustomVisibility(t *testing.T) {
ctx := context.Background() ctx := context.Background()
networkSvc := network.NewService(dbPool) svc, mockChecker := newTestService(t)
svc := particle.NewService(dbPool, networkSvc)
// Create a network with a member networkID := "net_123"
net, err := networkSvc.Create(ctx, "Visibility Test Network", "admin@example.com")
assert.NoError(t, err)
err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "other@example.com"}) // admin is the creator (exempt from the check); member is a network member.
assert.NoError(t, err) expectIsMember(mockChecker, networkID, map[string]bool{
"member@example.com": true,
})
// Create a stream with custom visibility including only admin and member // Create a stream with custom visibility including only admin and member
streamInput := particle.CreateInput{ streamInput := particle.CreateInput{
Type: particle.TypeStream, Type: particle.TypeStream,
NetworkID: net.ID, NetworkID: networkID,
Visibility: particle.VisibilityCustom, Visibility: particle.VisibilityCustom,
Members: []string{"admin@example.com", "member@example.com"}, Members: []string{"admin@example.com", "member@example.com"},
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`), Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
@@ -212,17 +177,14 @@ func TestParticleService_CustomVisibility(t *testing.T) {
func TestParticleService_UpdateAndDelete(t *testing.T) { func TestParticleService_UpdateAndDelete(t *testing.T) {
ctx := context.Background() ctx := context.Background()
networkSvc := network.NewService(dbPool) svc, _ := newTestService(t)
svc := particle.NewService(dbPool, networkSvc)
// Create a network networkID := "net_123"
net, err := networkSvc.Create(ctx, "Update Test Network", "admin@example.com")
assert.NoError(t, err)
// Create a text particle // Create a text particle
input := particle.CreateInput{ input := particle.CreateInput{
Type: particle.TypeText, Type: particle.TypeText,
NetworkID: net.ID, NetworkID: networkID,
Data: json.RawMessage(`{"content":"Original content"}`), Data: json.RawMessage(`{"content":"Original content"}`),
} }
created, err := svc.Create(ctx, input, "admin@example.com") created, err := svc.Create(ctx, input, "admin@example.com")
@@ -250,18 +212,15 @@ func TestParticleService_UpdateAndDelete(t *testing.T) {
func TestParticleService_ListRootParticles(t *testing.T) { func TestParticleService_ListRootParticles(t *testing.T) {
ctx := context.Background() ctx := context.Background()
networkSvc := network.NewService(dbPool) svc, _ := newTestService(t)
svc := particle.NewService(dbPool, networkSvc)
// Create a network networkID := "net_123"
net, err := networkSvc.Create(ctx, "List Test Network", "admin@example.com")
assert.NoError(t, err)
// Create multiple root particles // Create multiple root particles
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
input := particle.CreateInput{ input := particle.CreateInput{
Type: particle.TypeStream, Type: particle.TypeStream,
NetworkID: net.ID, NetworkID: networkID,
Data: json.RawMessage(`{"name":"Stream","status":"open"}`), Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
} }
_, err := svc.Create(ctx, input, "admin@example.com") _, err := svc.Create(ctx, input, "admin@example.com")
@@ -269,24 +228,21 @@ func TestParticleService_ListRootParticles(t *testing.T) {
} }
// List root particles (parentID = nil) // List root particles (parentID = nil)
list, err := svc.List(ctx, net.ID, nil, "admin@example.com", particle.ListFilter{}, nil, 50) list, err := svc.List(ctx, networkID, nil, "admin@example.com", particle.ListFilter{}, nil, 50)
assert.NoError(t, err) assert.NoError(t, err)
assert.GreaterOrEqual(t, len(list.Particles), 3) assert.GreaterOrEqual(t, len(list.Particles), 3)
} }
func TestParticleService_OpenCloseStream(t *testing.T) { func TestParticleService_OpenCloseStream(t *testing.T) {
ctx := context.Background() ctx := context.Background()
networkSvc := network.NewService(dbPool) svc, _ := newTestService(t)
svc := particle.NewService(dbPool, networkSvc)
// Create a network networkID := "net_123"
net, err := networkSvc.Create(ctx, "Open Close Test Network", "admin@example.com")
assert.NoError(t, err)
// Create a stream // Create a stream
input := particle.CreateInput{ input := particle.CreateInput{
Type: particle.TypeStream, Type: particle.TypeStream,
NetworkID: net.ID, NetworkID: networkID,
Data: json.RawMessage(`{"name":"Test Stream","status":"open"}`), Data: json.RawMessage(`{"name":"Test Stream","status":"open"}`),
} }
stream, err := svc.Create(ctx, input, "admin@example.com") stream, err := svc.Create(ctx, input, "admin@example.com")
@@ -324,17 +280,14 @@ func TestParticleService_OpenCloseStream(t *testing.T) {
func TestParticleService_NotAStream(t *testing.T) { func TestParticleService_NotAStream(t *testing.T) {
ctx := context.Background() ctx := context.Background()
networkSvc := network.NewService(dbPool) svc, _ := newTestService(t)
svc := particle.NewService(dbPool, networkSvc)
// Create a network networkID := "net_123"
net, err := networkSvc.Create(ctx, "Not Stream Test Network", "admin@example.com")
assert.NoError(t, err)
// Create a text particle // Create a text particle
input := particle.CreateInput{ input := particle.CreateInput{
Type: particle.TypeText, Type: particle.TypeText,
NetworkID: net.ID, NetworkID: networkID,
Data: json.RawMessage(`{"content":"Hello"}`), Data: json.RawMessage(`{"content":"Hello"}`),
} }
text, err := svc.Create(ctx, input, "admin@example.com") text, err := svc.Create(ctx, input, "admin@example.com")
@@ -353,20 +306,19 @@ func TestParticleService_NotAStream(t *testing.T) {
func TestParticleService_AccessInheritance(t *testing.T) { func TestParticleService_AccessInheritance(t *testing.T) {
ctx := context.Background() ctx := context.Background()
networkSvc := network.NewService(dbPool) svc, mockChecker := newTestService(t)
svc := particle.NewService(dbPool, networkSvc)
// Create a network with members networkID := "net_123"
net, err := networkSvc.Create(ctx, "Access Inheritance Test Network", "admin@example.com")
assert.NoError(t, err)
err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "other@example.com"}) // admin is the creator (exempt); member is a network member.
assert.NoError(t, err) expectIsMember(mockChecker, networkID, map[string]bool{
"member@example.com": true,
})
// Create a stream with custom visibility (admin and member only) // Create a stream with custom visibility (admin and member only)
streamInput := particle.CreateInput{ streamInput := particle.CreateInput{
Type: particle.TypeStream, Type: particle.TypeStream,
NetworkID: net.ID, NetworkID: networkID,
Visibility: particle.VisibilityCustom, Visibility: particle.VisibilityCustom,
Members: []string{"admin@example.com", "member@example.com"}, Members: []string{"admin@example.com", "member@example.com"},
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`), Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
@@ -377,7 +329,7 @@ func TestParticleService_AccessInheritance(t *testing.T) {
// Create a child text (network_all visibility) // Create a child text (network_all visibility)
textInput := particle.CreateInput{ textInput := particle.CreateInput{
Type: particle.TypeText, Type: particle.TypeText,
NetworkID: net.ID, NetworkID: networkID,
ParentID: &stream.ID, ParentID: &stream.ID,
Data: json.RawMessage(`{"content":"Child text"}`), Data: json.RawMessage(`{"content":"Child text"}`),
} }
@@ -397,3 +349,154 @@ func TestParticleService_AccessInheritance(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrAccessDenied) assert.ErrorIs(t, err, particle.ErrAccessDenied)
} }
func TestParticleService_AddMembers(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkID := "net_addmembers"
// Create a custom-visibility stream owned by admin.
stream, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
Visibility: particle.VisibilityCustom,
Members: []string{"admin@example.com"},
Data: json.RawMessage(`{"name":"Members Stream","status":"open"}`),
}, "admin@example.com")
assert.NoError(t, err)
expectIsMember(mockChecker, networkID, map[string]bool{
"alice@example.com": true,
"bob@example.com": true,
})
err = svc.AddMembers(ctx, stream.ID,
[]string{"alice@example.com", "bob@example.com"},
"admin@example.com")
assert.NoError(t, err)
members, err := svc.GetMembersMap(ctx, []string{stream.ID})
assert.NoError(t, err)
assert.Contains(t, members[stream.ID], "alice@example.com")
assert.Contains(t, members[stream.ID], "bob@example.com")
}
func TestParticleService_AddMembers_RejectsNonNetworkMember(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkID := "net_addmembers_strict"
stream, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
Visibility: particle.VisibilityCustom,
Members: []string{"admin@example.com"},
Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`),
}, "admin@example.com")
assert.NoError(t, err)
// alice is in the network and is processed first; stranger is not, so the
// whole operation must abort and neither row should be persisted.
expectIsMember(mockChecker, networkID, map[string]bool{
"alice@example.com": true,
"stranger@example.com": false,
})
err = svc.AddMembers(ctx, stream.ID,
[]string{"alice@example.com", "stranger@example.com"},
"admin@example.com")
assert.ErrorIs(t, err, particle.ErrInvalidMember)
members, err := svc.GetMembersMap(ctx, []string{stream.ID})
assert.NoError(t, err)
assert.NotContains(t, members[stream.ID], "alice@example.com")
assert.NotContains(t, members[stream.ID], "stranger@example.com")
}
func TestParticleService_AddMembers_BubblesCheckerError(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkID := "net_addmembers_checker_err"
stream, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
Visibility: particle.VisibilityCustom,
Members: []string{"admin@example.com"},
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
}, "admin@example.com")
assert.NoError(t, err)
checkerErr := errors.New("checker unavailable")
mockChecker.EXPECT().
IsMember(gomock.Any(), networkID, "alice@example.com").
Return(false, checkerErr)
err = svc.AddMembers(ctx, stream.ID,
[]string{"alice@example.com"}, "admin@example.com")
assert.ErrorIs(t, err, checkerErr)
}
func TestParticleService_Create_RejectsNonNetworkMember(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkID := "net_create_strict"
// stranger is not in the network — Create must reject the call before
// writing anything to the DB.
expectIsMember(mockChecker, networkID, map[string]bool{
"stranger@example.com": false,
})
_, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
Visibility: particle.VisibilityCustom,
Members: []string{"admin@example.com", "stranger@example.com"},
Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`),
}, "admin@example.com")
assert.ErrorIs(t, err, particle.ErrInvalidMember)
}
func TestParticleService_Create_BubblesCheckerError(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkID := "net_create_checker_err"
checkerErr := errors.New("checker unavailable")
mockChecker.EXPECT().
IsMember(gomock.Any(), networkID, "alice@example.com").
Return(false, checkerErr)
_, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
Visibility: particle.VisibilityCustom,
Members: []string{"alice@example.com"},
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
}, "admin@example.com")
assert.ErrorIs(t, err, checkerErr)
}
func TestParticleService_AddMembers_NotAStream(t *testing.T) {
ctx := context.Background()
// No EXPECT calls — AddMembers should fail on type check before hitting the checker.
svc, _ := newTestService(t)
networkID := "net_addmembers_notstream"
text, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeText,
NetworkID: networkID,
Data: json.RawMessage(`{"content":"hi"}`),
}, "admin@example.com")
assert.NoError(t, err)
err = svc.AddMembers(ctx, text.ID, []string{"alice@example.com"}, "admin@example.com")
assert.ErrorIs(t, err, particle.ErrNotAContainer)
}
+142
View File
@@ -0,0 +1,142 @@
package particle
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/media"
)
const (
SkipReasonNotMedia = "not_media"
SkipReasonAlreadyTranscoded = "already_transcoded"
SkipReasonIOSPlayable = "ios_playable"
)
// TranscodeResult reports the outcome of a single Transcode call. Skipped is
// true for any of the three idempotency short-circuits (with SkipReason set);
// Err is non-nil for real failures (download URL, ffmpeg, GCS upload, Firestore
// write). On success both flags are zero-value and TranscodedObjectID +
// OutputMimeType are populated.
type TranscodeResult struct {
Skipped bool
SkipReason string
TranscodedObjectID string
OutputMimeType string
Err error
}
// Transcode produces an iOS-playable MP4/m4a derivative for media particles
// whose original mime type AVPlayer can't decode (notably the WebM the desktop
// recorder emits today). Skips when the particle is not of type media, when a
// transcoded variant has already been written, or when the source is already
// in an iOS-playable family.
//
// ffmpeg reads directly from the GCS signed URL and writes to a local temp
// file — `+faststart` requires seekable output, so a stdout pipe wouldn't
// work. The temp file is then streamed to GCS via depotSvc.CreateFromReader.
func Transcode(ctx context.Context, depotSvc depot.Service, doc *firestore.DocumentSnapshot) TranscodeResult {
var mp FirestoreMediaParticle
if err := doc.DataTo(&mp); err != nil {
flog.Error("transcode: unable to marshal particle data", "error", err)
return TranscodeResult{Err: fmt.Errorf("unmarshal particle: %w", err)}
}
particleType, err := ParseParticleType(mp.Type)
if err != nil {
flog.Error("transcode: invalid particle type", "error", err)
return TranscodeResult{Err: fmt.Errorf("parse type: %w", err)}
}
if particleType != TypeMedia {
flog.Info("transcode: particle is not of type media")
return TranscodeResult{Skipped: true, SkipReason: SkipReasonNotMedia}
}
if mp.Properties.TranscodedObjectId != "" {
return TranscodeResult{Skipped: true, SkipReason: SkipReasonAlreadyTranscoded}
}
if media.IsIOSPlayableMime(mp.Properties.MimeType) {
flog.Info("transcode: skipping because already playable on ios")
return TranscodeResult{Skipped: true, SkipReason: SkipReasonIOSPlayable}
}
sourceURL, err := depotSvc.GetDownloadURL(ctx, mp.Properties.ObjectId)
if err != nil {
flog.Error("transcode: failed to get download URL", "error", err, "object_id", mp.Properties.ObjectId)
return TranscodeResult{Err: fmt.Errorf("download URL: %w", err)}
}
networkID, err := NetworkIDFromParticlePath(doc.Ref.Path)
if err != nil {
flog.Error("transcode: failed to derive network id", "error", err, "path", doc.Ref.Path)
return TranscodeResult{Err: fmt.Errorf("network id: %w", err)}
}
transcodeCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
transcodeOutput, err := media.TranscodeToMp4(transcodeCtx, media.TranscodeInput{
SourceURL: sourceURL,
MimeType: mp.Properties.MimeType,
})
if err != nil {
flog.Error("transcode: ffmpeg failed", "error", err)
return TranscodeResult{Err: fmt.Errorf("ffmpeg: %w", err)}
}
defer os.Remove(transcodeOutput.TempLocalFilePath)
f, err := os.Open(transcodeOutput.TempLocalFilePath)
if err != nil {
flog.Error("transcode: failed to open transcoded file", "error", err, "path", transcodeOutput.TempLocalFilePath)
return TranscodeResult{Err: fmt.Errorf("open temp file: %w", err)}
}
defer f.Close()
newObj, err := depotSvc.CreateFromReader(ctx, depot.CreateFromReaderInput{
Prefix: networkID,
Name: "transcoded" + transcodeOutput.OutputExt,
ContentType: transcodeOutput.OutputMimeType,
}, f)
if err != nil {
flog.Error("transcode: failed to upload transcoded object", "error", err, "particleID", doc.Ref.ID)
return TranscodeResult{Err: fmt.Errorf("upload: %w", err)}
}
if _, err := doc.Ref.Set(ctx, map[string]interface{}{
"properties": map[string]interface{}{
"transcoded_object_id": newObj.ID,
"transcoded_mime_type": transcodeOutput.OutputMimeType,
},
}, firestore.MergeAll); err != nil {
flog.Error("transcode: failed to update particle in firestore", "error", err, "particleID", doc.Ref.ID)
return TranscodeResult{Err: fmt.Errorf("firestore update: %w", err)}
}
flog.Info("transcoded media particle", "particleID", doc.Ref.ID, "transcoded_object_id", newObj.ID, "mime", transcodeOutput.OutputMimeType)
return TranscodeResult{
TranscodedObjectID: newObj.ID,
OutputMimeType: transcodeOutput.OutputMimeType,
}
}
// NetworkIDFromParticlePath extracts the network id from a Firestore particle
// document path. Particles live at `networks/{network_id}/children/.../children/{id}`
// at arbitrary nesting depth, so the network id is always the segment directly
// following the "networks" collection name in the full resource path.
func NetworkIDFromParticlePath(path string) (string, error) {
segments := strings.Split(path, "/")
for i, seg := range segments {
if seg == "networks" && i+1 < len(segments) {
return segments[i+1], nil
}
}
return "", fmt.Errorf("no networks segment in path: %s", path)
}
+70
View File
@@ -0,0 +1,70 @@
package pusher
import (
"context"
"errors"
"strings"
"github.com/flowy-live/llink/internal/network"
)
var ErrUnauthorized = errors.New("unauthorized")
type Authorizer struct {
networkReader network.Reader
}
func NewAuthorizer(networkReader network.Reader) *Authorizer {
return &Authorizer{networkReader: networkReader}
}
// Authorize accepts channel IDs of the form:
//
// network:{networkId}
// stream:{networkId}:{streamId}
// _presence:{humanId}
func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) error {
parts := strings.SplitN(channelID, ":", 2)
if len(parts) < 2 {
return ErrUnauthorized
}
channelType := parts[0]
rest := parts[1]
switch channelType {
case "network":
return a.authorizeNetwork(ctx, rest, humanID)
case "stream":
return a.authorizeStream(ctx, rest, humanID)
case "_presence":
// Only the owning human may subscribe to their presence channel.
if rest != humanID {
return ErrUnauthorized
}
return nil
default:
return ErrUnauthorized
}
}
func (a *Authorizer) authorizeNetwork(ctx context.Context, networkID, humanID string) error {
isMember, err := a.networkReader.IsMember(ctx, networkID, humanID)
if err != nil {
return err
}
if !isMember {
return ErrUnauthorized
}
return nil
}
// rest is "{networkId}:{streamId}"; stream-level visibility is enforced by network access.
func (a *Authorizer) authorizeStream(ctx context.Context, rest, humanID string) error {
parts := strings.SplitN(rest, ":", 2)
if len(parts) < 2 {
return ErrUnauthorized
}
networkID := parts[0]
return a.authorizeNetwork(ctx, networkID, humanID)
}
+57
View File
@@ -0,0 +1,57 @@
package pusher
// Channel tracks the local connections subscribed on this pod.
// State is only mutated by the Hub goroutine, so no locks are needed.
type Channel struct {
id string
members map[*Conn]string // conn → humanID
}
func newChannel(id string) *Channel {
return &Channel{
id: id,
members: make(map[*Conn]string),
}
}
func (ch *Channel) addMember(conn *Conn, humanID string) {
ch.members[conn] = humanID
}
func (ch *Channel) removeMember(conn *Conn) {
delete(ch.members, conn)
}
func (ch *Channel) isEmpty() bool {
return len(ch.members) == 0
}
// Deduplicated set; the same human may have multiple connections.
func (ch *Channel) localHumanIDs() []string {
seen := make(map[string]bool, len(ch.members))
ids := make([]string, 0, len(ch.members))
for _, hid := range ch.members {
if !seen[hid] {
seen[hid] = true
ids = append(ids, hid)
}
}
return ids
}
func (ch *Channel) hasHumanID(humanID string) bool {
for _, hid := range ch.members {
if hid == humanID {
return true
}
}
return false
}
func (ch *Channel) broadcast(msg ServerMessage, exclude *Conn) {
for conn := range ch.members {
if conn != exclude {
conn.Send(msg)
}
}
}
+128
View File
@@ -0,0 +1,128 @@
package pusher
import (
"context"
"encoding/json"
"sync"
"github.com/flowy-live/llink/internal/utils/flog"
"nhooyr.io/websocket"
)
const sendBufferSize = 256
type Conn struct {
id string
humanID string
ws *websocket.Conn
send chan []byte
once sync.Once // guards Close
}
func newConn(id, humanID string, ws *websocket.Conn) *Conn {
return &Conn{
id: id,
humanID: humanID,
ws: ws,
send: make(chan []byte, sendBufferSize),
}
}
// ReadPump forwards inbound frames to the hub; blocks until the connection
// closes or ctx is cancelled.
func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
defer hub.disconnect(c)
for {
_, data, err := c.ws.Read(ctx)
if err != nil {
if ctx.Err() != nil {
flog.Info("websocket context cancelled", "connId", c.id, "humanId", c.humanID, "error", ctx.Err())
return
}
flog.Warn("websocket read error", "connId", c.id, "humanId", c.humanID, "error", err)
return
}
if string(data) == "ping" {
if err := c.ws.Write(ctx, websocket.MessageText, []byte("pong")); err != nil {
flog.Warn("websocket pong write error", "connId", c.id, "error", err)
}
continue
}
var msg ClientMessage
if err := json.Unmarshal(data, &msg); err != nil {
c.sendError("invalid message format")
continue
}
switch msg.Type {
case TypeSubscribe:
if msg.Channel == "" {
c.sendError("channel is required")
continue
}
hub.subscribeCh <- &subscribeRequest{conn: c, channelID: msg.Channel}
case TypeUnsubscribe:
if msg.Channel == "" {
c.sendError("channel is required")
continue
}
hub.unsubscribeCh <- &unsubscribeRequest{conn: c, channelID: msg.Channel}
case TypeMessage:
if msg.Channel == "" {
c.sendError("channel is required")
continue
}
hub.broadcastCh <- &broadcastRequest{conn: c, channelID: msg.Channel, payload: msg.Payload}
default:
c.sendError("unknown message type: " + msg.Type)
}
}
}
func (c *Conn) WritePump(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case data, ok := <-c.send:
if !ok {
return
}
if err := c.ws.Write(ctx, websocket.MessageText, data); err != nil {
flog.Debug("websocket write error", "connId", c.id, "error", err)
return
}
}
}
}
// A full send buffer closes the connection (slow client policy).
func (c *Conn) Send(msg ServerMessage) {
data, err := json.Marshal(msg)
if err != nil {
flog.Error("failed to marshal server message", "error", err)
return
}
select {
case c.send <- data:
default:
flog.Warn("slow client, closing connection", "connId", c.id, "humanId", c.humanID)
c.Close()
}
}
func (c *Conn) Close() {
c.once.Do(func() {
c.ws.Close(websocket.StatusNormalClosure, "closing")
close(c.send)
})
}
func (c *Conn) sendError(msg string) {
c.Send(ServerMessage{Type: TypeError, Message: msg})
}
+259
View File
@@ -0,0 +1,259 @@
package pusher
import (
"context"
"encoding/json"
"github.com/flowy-live/llink/internal/utils/flog"
)
type subscribeRequest struct {
conn *Conn
channelID string
}
type unsubscribeRequest struct {
conn *Conn
channelID string
}
type broadcastRequest struct {
conn *Conn
channelID string
payload json.RawMessage
}
type remoteEvent struct {
channelID string
event redisEvent
}
// Hub manages all local WebSocket connections and channels on this pod.
// All state mutations happen in a single goroutine via Go channels — no locks.
type Hub struct {
channels map[string]*Channel
connChannels map[*Conn]map[string]bool // reverse index: conn → set of channel IDs
bridge *RedisBridge
authorizer *Authorizer
subscribeCh chan *subscribeRequest
unsubscribeCh chan *unsubscribeRequest
broadcastCh chan *broadcastRequest
disconnectCh chan *Conn
remoteEventCh chan *remoteEvent
}
func NewHub(bridge *RedisBridge, authorizer *Authorizer) *Hub {
return &Hub{
channels: make(map[string]*Channel),
connChannels: make(map[*Conn]map[string]bool),
bridge: bridge,
authorizer: authorizer,
subscribeCh: make(chan *subscribeRequest, 256),
unsubscribeCh: make(chan *unsubscribeRequest, 256),
broadcastCh: make(chan *broadcastRequest, 256),
disconnectCh: make(chan *Conn, 256),
remoteEventCh: make(chan *remoteEvent, 256),
}
}
// Run starts the hub event loop. Blocks until the context is cancelled.
func (h *Hub) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case req := <-h.subscribeCh:
h.handleSubscribe(ctx, req)
case req := <-h.unsubscribeCh:
h.handleUnsubscribe(ctx, req)
case req := <-h.broadcastCh:
h.handleBroadcast(ctx, req)
case conn := <-h.disconnectCh:
h.handleDisconnect(ctx, conn)
case evt := <-h.remoteEventCh:
h.handleRemoteEvent(evt)
}
}
}
func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
if err := h.authorizer.Authorize(ctx, req.channelID, req.conn.humanID); err != nil {
req.conn.Send(ServerMessage{
Type: TypeError,
Channel: req.channelID,
Message: "unauthorized",
})
return
}
ch, ok := h.channels[req.channelID]
if !ok {
ch = newChannel(req.channelID)
h.channels[req.channelID] = ch
}
// Capture before addMember so multi-tab joins don't emit a spurious join.
wasPresentLocally := ch.hasHumanID(req.conn.humanID)
ch.addMember(req.conn, req.conn.humanID)
if h.connChannels[req.conn] == nil {
h.connChannels[req.conn] = make(map[string]bool)
}
h.connChannels[req.conn][req.channelID] = true
presence, err := h.bridge.Subscribe(ctx, req.channelID, req.conn.id, req.conn.humanID)
if err != nil {
flog.Error("redis subscribe failed", "channelId", req.channelID, "error", err)
// Fall back to local-only presence.
presence = ch.localHumanIDs()
}
req.conn.Send(ServerMessage{
Type: TypeSubscribed,
Channel: req.channelID,
Presence: presence,
})
// Redis self-filter drops our own echo, so same-pod peers need a direct nudge.
if !wasPresentLocally {
ch.broadcast(ServerMessage{
Type: TypeJoin,
Channel: req.channelID,
HumanID: req.conn.humanID,
}, req.conn)
}
}
func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) {
ch, ok := h.channels[req.channelID]
if !ok {
return
}
ch.removeMember(req.conn)
if chans, ok := h.connChannels[req.conn]; ok {
delete(chans, req.channelID)
}
if err := h.bridge.Unsubscribe(ctx, req.channelID, req.conn.id, req.conn.humanID); err != nil {
flog.Error("redis unsubscribe failed", "channelId", req.channelID, "error", err)
}
// Only emit leave once the humanID has no remaining tabs on this pod.
if !ch.hasHumanID(req.conn.humanID) {
ch.broadcast(ServerMessage{
Type: TypeLeave,
Channel: req.channelID,
HumanID: req.conn.humanID,
}, req.conn)
}
if ch.isEmpty() {
delete(h.channels, req.channelID)
}
}
func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) {
ch, ok := h.channels[req.channelID]
if !ok {
return
}
if _, isMember := ch.members[req.conn]; !isMember {
req.conn.sendError("not subscribed to channel: " + req.channelID)
return
}
// Local fanout (excluding sender), then publish for other pods.
ch.broadcast(ServerMessage{
Type: TypeMessage,
Channel: req.channelID,
HumanID: req.conn.humanID,
Payload: req.payload,
}, req.conn)
h.bridge.Broadcast(ctx, req.channelID, req.conn.humanID, req.payload)
}
func (h *Hub) handleDisconnect(ctx context.Context, conn *Conn) {
chans, ok := h.connChannels[conn]
if !ok {
return
}
for channelID := range chans {
ch, ok := h.channels[channelID]
if !ok {
continue
}
ch.removeMember(conn)
if err := h.bridge.Unsubscribe(ctx, channelID, conn.id, conn.humanID); err != nil {
flog.Error("redis unsubscribe on disconnect failed", "channelId", channelID, "error", err)
}
if !ch.hasHumanID(conn.humanID) {
ch.broadcast(ServerMessage{
Type: TypeLeave,
Channel: channelID,
HumanID: conn.humanID,
}, conn)
}
if ch.isEmpty() {
delete(h.channels, channelID)
}
}
delete(h.connChannels, conn)
}
func (h *Hub) handleRemoteEvent(evt *remoteEvent) {
ch, ok := h.channels[evt.channelID]
if !ok {
// No local subscribers — drop the event.
return
}
switch evt.event.Type {
case TypeJoin:
ch.broadcast(ServerMessage{
Type: TypeJoin,
Channel: evt.channelID,
HumanID: evt.event.HumanID,
}, nil)
case TypeLeave:
ch.broadcast(ServerMessage{
Type: TypeLeave,
Channel: evt.channelID,
HumanID: evt.event.HumanID,
}, nil)
case TypeMessage:
ch.broadcast(ServerMessage{
Type: TypeMessage,
Channel: evt.channelID,
HumanID: evt.event.HumanID,
Payload: evt.event.Payload,
}, nil)
}
}
func (h *Hub) Subscribe(conn *Conn, channelID string) {
h.subscribeCh <- &subscribeRequest{conn: conn, channelID: channelID}
}
func (h *Hub) disconnect(conn *Conn) {
h.disconnectCh <- conn
}
+395
View File
@@ -0,0 +1,395 @@
package pusher
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/redis/go-redis/v9"
)
const (
podHeartbeatInterval = 30 * time.Second
podHeartbeatTTL = 60 * time.Second
cleanupInterval = 60 * time.Second
// Redis key prefixes
channelConnsPrefix = "pusher:ch:"
channelConnsSuffix = ":conns"
podKeyPrefix = "pusher:pod:"
pubsubPrefix = "pusher:events:"
)
// Wire format for cross-pod Pub/Sub.
type redisEvent struct {
Type string `json:"type"` // "join", "leave", "message"
HumanID string `json:"humanId,omitempty"` // who triggered the event
PodID string `json:"podId,omitempty"` // originating pod
Payload json.RawMessage `json:"payload,omitempty"` // message events only
}
// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence
// tracking.
type RedisBridge struct {
client *redis.Client
podID string
hub *Hub // wired post-construction; see SetHub
}
func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
return &RedisBridge{
client: client,
podID: podID,
}
}
// SetHub resolves the circular dependency between Hub and RedisBridge.
func (rb *RedisBridge) SetHub(hub *Hub) {
rb.hub = hub
}
// --- Presence management ---
// Subscribe records the connection in Redis and returns the channel's
// current deduplicated presence set.
func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID string) ([]string, error) {
key := channelConnsKey(channelID)
field := rb.connField(connID)
// Snapshot before the insert so multi-tab joins don't double-emit.
existingMembers, err := rb.client.HVals(ctx, key).Result()
if err != nil && err != redis.Nil {
return nil, fmt.Errorf("failed to get channel members: %w", err)
}
wasPresent := containsString(existingMembers, humanID)
if err := rb.client.HSet(ctx, key, field, humanID).Err(); err != nil {
return nil, fmt.Errorf("failed to add connection to channel: %w", err)
}
if !wasPresent {
rb.publishEvent(ctx, channelID, redisEvent{
Type: TypeJoin,
HumanID: humanID,
PodID: rb.podID,
})
}
allMembers, err := rb.client.HVals(ctx, key).Result()
if err != nil {
return nil, fmt.Errorf("failed to get channel members: %w", err)
}
return deduplicateStrings(allMembers), nil
}
func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, humanID string) error {
key := channelConnsKey(channelID)
field := rb.connField(connID)
if err := rb.client.HDel(ctx, key, field).Err(); err != nil {
return fmt.Errorf("failed to remove connection from channel: %w", err)
}
// Only emit leave once this humanID has no tabs left in the channel.
remainingMembers, err := rb.client.HVals(ctx, key).Result()
if err != nil && err != redis.Nil {
return fmt.Errorf("failed to get remaining members: %w", err)
}
if !containsString(remainingMembers, humanID) {
rb.publishEvent(ctx, channelID, redisEvent{
Type: TypeLeave,
HumanID: humanID,
PodID: rb.podID,
})
}
if len(remainingMembers) == 0 {
rb.client.Del(ctx, key)
}
return nil
}
func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, payload json.RawMessage) {
rb.publishEvent(ctx, channelID, redisEvent{
Type: TypeMessage,
HumanID: humanID,
PodID: rb.podID,
Payload: payload,
})
}
func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (map[string][]string, error) {
result := make(map[string][]string, len(channelIDs))
for _, chID := range channelIDs {
members, err := rb.client.HVals(ctx, channelConnsKey(chID)).Result()
if err != nil && err != redis.Nil {
return nil, fmt.Errorf("failed to get presence for %s: %w", chID, err)
}
result[chID] = deduplicateStrings(members)
}
return result, nil
}
// Returns every humanID with at least one active connection cluster-wide.
func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) {
allHumanIDs := make(map[string]bool)
var cursor uint64
for {
keys, nextCursor, err := rb.client.Scan(ctx, cursor, channelConnsPrefix+"*"+channelConnsSuffix, 100).Result()
if err != nil {
return nil, fmt.Errorf("failed to scan channel keys: %w", err)
}
for _, key := range keys {
members, err := rb.client.HVals(ctx, key).Result()
if err != nil && err != redis.Nil {
continue
}
for _, humanID := range members {
allHumanIDs[humanID] = true
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
result := make([]string, 0, len(allHumanIDs))
for id := range allHumanIDs {
result = append(result, id)
}
return result, nil
}
// --- Pub/Sub listener ---
// Listen forwards Redis Pub/Sub events to the local hub; blocks until ctx is cancelled.
func (rb *RedisBridge) Listen(ctx context.Context) {
pubsub := rb.client.PSubscribe(ctx, pubsubPrefix+"*")
defer pubsub.Close()
ch := pubsub.Channel()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
rb.handlePubSubMessage(msg)
}
}
}
func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
// Topic: "pusher:events:{channelID}".
channelID := strings.TrimPrefix(msg.Channel, pubsubPrefix)
if channelID == "" {
return
}
var event redisEvent
if err := json.Unmarshal([]byte(msg.Payload), &event); err != nil {
flog.Error("failed to parse pub/sub event", "error", err)
return
}
// Same-pod events were already handled by the local hub.
if event.PodID == rb.podID {
return
}
if rb.hub == nil {
return
}
rb.hub.remoteEventCh <- &remoteEvent{
channelID: channelID,
event: event,
}
}
// --- Heartbeat + cleanup ---
// Heartbeat refreshes this pod's liveness key and reaps stale pods on a tick.
func (rb *RedisBridge) Heartbeat(ctx context.Context) {
podKey := podKeyPrefix + rb.podID
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
heartbeatTicker := time.NewTicker(podHeartbeatInterval)
cleanupTicker := time.NewTicker(cleanupInterval)
defer heartbeatTicker.Stop()
defer cleanupTicker.Stop()
for {
select {
case <-ctx.Done():
// On shutdown, drop our pod key and reclaim our connection slots.
rb.client.Del(context.Background(), podKey)
rb.cleanupPod(context.Background(), rb.podID)
return
case <-heartbeatTicker.C:
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
case <-cleanupTicker.C:
rb.cleanupStalePods(ctx)
}
}
}
func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
// Collect every pod referenced in channel-conn hashes, then drop those
// whose liveness key has expired.
var cursor uint64
knownPods := make(map[string]bool)
alivePods := make(map[string]bool)
for {
keys, nextCursor, err := rb.client.Scan(ctx, cursor, channelConnsPrefix+"*"+channelConnsSuffix, 100).Result()
if err != nil {
flog.Error("failed to scan channel keys", "error", err)
return
}
for _, key := range keys {
fields, err := rb.client.HKeys(ctx, key).Result()
if err != nil {
continue
}
for _, field := range fields {
podID := extractPodID(field)
if podID != "" {
knownPods[podID] = true
}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
for podID := range knownPods {
exists, err := rb.client.Exists(ctx, podKeyPrefix+podID).Result()
if err != nil {
continue
}
if exists > 0 {
alivePods[podID] = true
}
}
for podID := range knownPods {
if !alivePods[podID] {
flog.Info("cleaning up stale pod", "podId", podID)
rb.cleanupPod(ctx, podID)
}
}
}
func (rb *RedisBridge) cleanupPod(ctx context.Context, podID string) {
var cursor uint64
for {
keys, nextCursor, err := rb.client.Scan(ctx, cursor, channelConnsPrefix+"*"+channelConnsSuffix, 100).Result()
if err != nil {
return
}
for _, key := range keys {
fields, err := rb.client.HGetAll(ctx, key).Result()
if err != nil {
continue
}
channelID := extractChannelID(key)
for field, humanID := range fields {
if extractPodID(field) == podID {
rb.client.HDel(ctx, key, field)
remaining, _ := rb.client.HVals(ctx, key).Result()
if !containsString(remaining, humanID) {
rb.publishEvent(ctx, channelID, redisEvent{
Type: TypeLeave,
HumanID: humanID,
PodID: rb.podID,
})
}
}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
}
// --- Helpers ---
func (rb *RedisBridge) connField(connID string) string {
return rb.podID + ":" + connID
}
func (rb *RedisBridge) publishEvent(ctx context.Context, channelID string, event redisEvent) {
data, err := json.Marshal(event)
if err != nil {
flog.Error("failed to marshal event", "error", err)
return
}
if err := rb.client.Publish(ctx, pubsubPrefix+channelID, data).Err(); err != nil {
flog.Error("failed to publish event", "channelId", channelID, "error", err)
}
}
func channelConnsKey(channelID string) string {
return channelConnsPrefix + channelID + channelConnsSuffix
}
// "pusher:ch:{channelID}:conns" → channelID
func extractChannelID(redisKey string) string {
s := strings.TrimPrefix(redisKey, channelConnsPrefix)
s = strings.TrimSuffix(s, channelConnsSuffix)
return s
}
// "{podID}:{connID}" → podID
func extractPodID(field string) string {
parts := strings.SplitN(field, ":", 2)
if len(parts) == 2 {
return parts[0]
}
return ""
}
func containsString(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
func deduplicateStrings(slice []string) []string {
seen := make(map[string]bool, len(slice))
result := make([]string, 0, len(slice))
for _, s := range slice {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
return result
}
+115
View File
@@ -0,0 +1,115 @@
package pusher
import (
"context"
"net/http"
"github.com/google/uuid"
"nhooyr.io/websocket"
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
"github.com/flowy-live/llink/internal/auth"
"github.com/flowy-live/llink/internal/utils/flog"
)
// Server handles WebSocket upgrades and gRPC presence queries.
type Server struct {
pbpusher.UnimplementedPusherServiceServer
ctx context.Context // server-scoped; cancelling closes all WebSockets gracefully
hub *Hub
bridge *RedisBridge
authSvc auth.SessionReader
}
func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.SessionReader) *Server {
return &Server{
ctx: ctx,
hub: hub,
bridge: bridge,
authSvc: authSvc,
}
}
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
// Token rides in the query string — WebSocket upgrades can't carry custom headers.
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "token required", http.StatusUnauthorized)
return
}
session, err := s.authSvc.GetSession(r.Context(), token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// CORS is enforced at the gateway.
InsecureSkipVerify: true,
})
if err != nil {
flog.Error("websocket accept failed", "error", err)
return
}
connID := uuid.New().String()
conn := newConn(connID, session.HumanId, ws)
flog.Info("websocket connected", "connId", connID, "humanId", session.HumanId)
// Use the server context, not r.Context(): after upgrade the HTTP request
// context can be cancelled by load balancers and nhooyr/websocket would
// then permanently close the conn.
ctx, cancel := context.WithCancel(s.ctx)
defer cancel()
// Auto-subscribe to the presence channel so this user appears online.
s.hub.Subscribe(conn, "_presence:"+session.HumanId)
go conn.WritePump(ctx)
conn.ReadPump(ctx, s.hub)
flog.Info("websocket disconnected", "connId", connID, "humanId", session.HumanId)
}
func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHumanIdsRequest) (*pbpusher.GetOnlineHumanIdsResponse, error) {
humanIDs, err := s.bridge.GetAllConnectedHumanIDs(ctx)
if err != nil {
return nil, err
}
return &pbpusher.GetOnlineHumanIdsResponse{HumanIds: humanIDs}, nil
}
func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*pbpusher.IsOnlineResponse, error) {
allOnline, err := s.bridge.GetAllConnectedHumanIDs(ctx)
if err != nil {
return nil, err
}
onlineSet := make(map[string]bool, len(allOnline))
for _, id := range allOnline {
onlineSet[id] = true
}
result := make(map[string]bool, len(req.HumanIds))
for _, id := range req.HumanIds {
result[id] = onlineSet[id]
}
return &pbpusher.IsOnlineResponse{Online: result}, nil
}
func (s *Server) GetChannelPresence(ctx context.Context, req *pbpusher.GetChannelPresenceRequest) (*pbpusher.GetChannelPresenceResponse, error) {
presence, err := s.bridge.GetPresence(ctx, req.ChannelIds)
if err != nil {
return nil, err
}
resp := &pbpusher.GetChannelPresenceResponse{
Presences: make(map[string]*pbpusher.ChannelPresence, len(presence)),
}
for chID, humanIDs := range presence {
resp.Presences[chID] = &pbpusher.ChannelPresence{
HumanIds: humanIDs,
}
}
return resp, nil
}
+34
View File
@@ -0,0 +1,34 @@
package pusher
import "encoding/json"
// Client → Server message types
const (
TypeSubscribe = "subscribe"
TypeUnsubscribe = "unsubscribe"
TypeMessage = "message"
)
// Server → Client message types
const (
TypeSubscribed = "subscribed"
TypeJoin = "join"
TypeLeave = "leave"
// TypeMessage is reused for server → client messages
TypeError = "error"
)
type ClientMessage struct {
Type string `json:"type"`
Channel string `json:"channel,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
type ServerMessage struct {
Type string `json:"type"`
Channel string `json:"channel,omitempty"`
HumanID string `json:"humanId,omitempty"`
Presence []string `json:"presence,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
Message string `json:"message,omitempty"`
}
+7 -5
View File
@@ -3,23 +3,25 @@ package internal
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"os" "os"
"github.com/flowy-live/llink/internal/utils" "github.com/flowy-live/llink/internal/utils/flog"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/flowy-live/llink/internal/utils"
) )
func ConnectAndTestRedis(db int) *redis.Client { func ConnectAndTestRedis(db int) *redis.Client {
redisHost := utils.MustGetEnv("REDIS_HOST") redisHost := utils.MustGetEnv("REDIS_HOST")
if redisHost == "" { if redisHost == "" {
slog.Error("must provide REDIS_HOST") flog.Error("must provide REDIS_HOST")
os.Exit(1) os.Exit(1)
} }
redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379") redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379")
rdb := redis.NewClient(&redis.Options{ rdb := redis.NewClient(&redis.Options{
Addr: redisAddr, Addr: redisAddr,
Password: "", // no password set Password: "",
DB: db, DB: db,
}) })
@@ -32,7 +34,7 @@ func ConnectAndTestRedis(db int) *redis.Client {
if err != nil { if err != nil {
panic(err) panic(err)
} }
slog.Debug("redis test", "key", val) flog.Debug("redis test", "key", val)
if val != "value" { if val != "value" {
panic("unexpected value") panic("unexpected value")
} }
+100
View File
@@ -0,0 +1,100 @@
package speech
import (
"context"
"github.com/flowy-live/llink/internal/utils/flog"
dgapi "github.com/deepgram/deepgram-go-sdk/v3/pkg/api/listen/v1/rest"
interfaces "github.com/deepgram/deepgram-go-sdk/v3/pkg/client/interfaces"
client "github.com/deepgram/deepgram-go-sdk/v3/pkg/client/listen"
)
type TranscriptWord struct {
Word string
Start float64
End float64
}
type TranscriptSentence struct {
Text string
Start float64
End float64
}
type TranscriptParagraph struct {
Sentences []TranscriptSentence
Start float64
End float64
}
type TranscriptResult struct {
Transcript string
Words []TranscriptWord
Paragraphs []TranscriptParagraph
}
type SpeechService interface {
Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error)
}
type speechServiceImpl struct {
deepgramClient *dgapi.Client
}
func NewSpeechService(ctx context.Context) SpeechService {
return &speechServiceImpl{
deepgramClient: dgapi.New(client.NewRESTWithDefaults()),
}
}
func (s *speechServiceImpl) Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error) {
options := &interfaces.PreRecordedTranscriptionOptions{
Model: "nova-3",
SmartFormat: true,
Paragraphs: true,
}
response, err := s.deepgramClient.FromURL(ctx, mediaUrl, options)
if err != nil {
flog.Error("failed to transcribe prerecorded media", "error", err)
return nil, err
}
alt := response.Results.Channels[0].Alternatives[0]
words := make([]TranscriptWord, len(alt.Words))
for i, w := range alt.Words {
words[i] = TranscriptWord{
Word: w.PunctuatedWord,
Start: w.Start,
End: w.End,
}
}
var paragraphs []TranscriptParagraph
if alt.Paragraphs != nil {
paragraphs = make([]TranscriptParagraph, len(alt.Paragraphs.Paragraphs))
for i, p := range alt.Paragraphs.Paragraphs {
sentences := make([]TranscriptSentence, len(p.Sentences))
for j, s := range p.Sentences {
sentences[j] = TranscriptSentence{
Text: s.Text,
Start: s.Start,
End: s.End,
}
}
paragraphs[i] = TranscriptParagraph{
Sentences: sentences,
Start: p.Start,
End: p.End,
}
}
}
return &TranscriptResult{
Transcript: alt.Transcript,
Words: words,
Paragraphs: paragraphs,
}, nil
}
+6
View File
@@ -0,0 +1,6 @@
// Package aero hosts a mock for the third-party aero gRPC client. The
// interface is generated protobuf code we don't own, so the //go:generate
// directive lives here rather than next to the source.
package aero
//go:generate go tool mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient
@@ -0,0 +1,63 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/flowy-live/llink/genproto/aero (interfaces: PrimaryClient)
//
// Generated by this command:
//
// mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient
//
// Package aero is a generated GoMock package.
package aero
import (
context "context"
reflect "reflect"
pbaero "github.com/flowy-live/llink/genproto/aero"
gomock "go.uber.org/mock/gomock"
grpc "google.golang.org/grpc"
)
// MockPrimaryClient is a mock of PrimaryClient interface.
type MockPrimaryClient struct {
ctrl *gomock.Controller
recorder *MockPrimaryClientMockRecorder
isgomock struct{}
}
// MockPrimaryClientMockRecorder is the mock recorder for MockPrimaryClient.
type MockPrimaryClientMockRecorder struct {
mock *MockPrimaryClient
}
// NewMockPrimaryClient creates a new mock instance.
func NewMockPrimaryClient(ctrl *gomock.Controller) *MockPrimaryClient {
mock := &MockPrimaryClient{ctrl: ctrl}
mock.recorder = &MockPrimaryClientMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPrimaryClient) EXPECT() *MockPrimaryClientMockRecorder {
return m.recorder
}
// ShootEmail mocks base method.
func (m *MockPrimaryClient) ShootEmail(ctx context.Context, in *pbaero.ShootEmailRequest, opts ...grpc.CallOption) (*pbaero.ShootEmailResponse, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, in}
for _, a := range opts {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "ShootEmail", varargs...)
ret0, _ := ret[0].(*pbaero.ShootEmailResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ShootEmail indicates an expected call of ShootEmail.
func (mr *MockPrimaryClientMockRecorder) ShootEmail(ctx, in any, opts ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, in}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShootEmail", reflect.TypeOf((*MockPrimaryClient)(nil).ShootEmail), varargs...)
}
+9 -8
View File
@@ -3,10 +3,11 @@ package testhelper
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"os" "os"
"time" "time"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file" _ "github.com/golang-migrate/migrate/v4/source/file"
@@ -39,20 +40,20 @@ func SetupTestDB() *pgxpool.Pool {
), ),
) )
if err != nil { if err != nil {
slog.Error("failed to start postgres container", "error", err) flog.Error("failed to start postgres container", "error", err)
os.Exit(1) os.Exit(1)
} }
// Get connection URL // Get connection URL
host, err := container.Host(ctx) host, err := container.Host(ctx)
if err != nil { if err != nil {
slog.Error("failed to get container host", "error", err) flog.Error("failed to get container host", "error", err)
os.Exit(1) os.Exit(1)
} }
port, err := container.MappedPort(ctx, "5432") port, err := container.MappedPort(ctx, "5432")
if err != nil { if err != nil {
slog.Error("failed to get container port", "error", err) flog.Error("failed to get container port", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -62,20 +63,20 @@ func SetupTestDB() *pgxpool.Pool {
// Run migrations // Run migrations
m, err := migrate.New("file://../../migrations", connectionURL) m, err := migrate.New("file://../../migrations", connectionURL)
if err != nil { if err != nil {
slog.Error("failed to create migrate instance", "error", err) flog.Error("failed to create migrate instance", "error", err)
os.Exit(1) os.Exit(1)
} }
defer m.Close() defer m.Close()
if err := m.Up(); err != nil && err != migrate.ErrNoChange { if err := m.Up(); err != nil && err != migrate.ErrNoChange {
slog.Error("failed to run migrations", "error", err) flog.Error("failed to run migrations", "error", err)
os.Exit(1) os.Exit(1)
} }
// Create connection pool // Create connection pool
dbPool, err = pgxpool.New(ctx, connectionURL) dbPool, err = pgxpool.New(ctx, connectionURL)
if err != nil { if err != nil {
slog.Error("failed to create connection pool", "error", err) flog.Error("failed to create connection pool", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -89,7 +90,7 @@ func TeardownTestDB() {
} }
if container != nil { if container != nil {
if err := container.Terminate(ctx); err != nil { if err := container.Terminate(ctx); err != nil {
slog.Error("failed to terminate container", "error", err) flog.Error("failed to terminate container", "error", err)
} }
} }
} }
+6 -8
View File
@@ -3,33 +3,31 @@ package utils
import ( import (
"os" "os"
"github.com/sirupsen/logrus" "github.com/flowy-live/llink/internal/utils/flog"
) )
// enum of environment variables // EnvVar enumerates the env vars referenced via this package.
type EnvVar string type EnvVar string
const () const ()
// MustGetEnv returns the value of the environment variable with the given key. // MustGetEnv panics if the variable is unset.
// panics if the variable is not set.
func MustGetEnv[T string | EnvVar](key T) string { func MustGetEnv[T string | EnvVar](key T) string {
keyString := string(key) keyString := string(key)
value := os.Getenv(keyString) value := os.Getenv(keyString)
if value == "" { if value == "" {
logrus.Errorf("Missing required environment variable %s", key) flog.Error("missing required environment variable", "key", key)
panic("Missing required environment variable") panic("Missing required environment variable")
} }
return value return value
} }
// GetEnv returns the value of the environment variable with the given key. // GetEnv returns "" if the variable is unset (and logs a warning).
// returns an empty string if the variable is not set.
func GetEnv(key string) string { func GetEnv(key string) string {
value := os.Getenv(key) value := os.Getenv(key)
if value == "" { if value == "" {
logrus.Warnf("Missing optional environment variable %s", key) flog.Warn("missing optional environment variable", "key", key)
} }
return value return value
} }

Some files were not shown because too many files have changed in this diff Show More