VaultGuard Share Links — End-to-End Reference
VaultGuard share links let a vault member hand a teammate a clickable URL that opens a specific file in their own Obsidian vault. They are internal-only team links: the URL itself is opaque and carries no authority — resolving it to a path still requires authenticated vault membership and file-level read permission.
This document is the canonical reference. Read it before touching anything in
infrastructure/lambda/shares/, share-bridge/, the share-related plugin code
(registerShareProtocolHandler, handleShareLink, copyShareLinkForPath,
ShareManagementModal), or the /vaults/{vaultId}/shares API surface.
1. Mental model
A share link is a pure pointer:
shareId → (orgId, vaultId, relPath, createdBy, createdAt, expiresAt?)
It is not a capability token. Three independent gates must pass before a recipient learns the path the token points to:
- Vault membership. The resolve endpoint is mounted under
/vaults/{vaultId}/shares/{shareId}.requireVaultMember(user, vaultId, 'viewer')runs before any handler logic, so a non-member never even reaches the lookup. A permanent membership can satisfy this gate, as can an active viewer-only authenticated guest membership for that selected vault. An expired or malformed guest membership is treated as absent. - Cross-vault binding match. The DynamoDB record stores
vaultId. The handler rejects (404) any token whose storedvaultIddiffers from the one in the URL path — even if a recipient guesses a token from vault A, they can't smuggle it into a vault B request. - File-level read permission. The handler runs
evaluatePermission(..., 'read', '/' + relPath, ...)against the recipient's own permission set. Failure returns404(not403) so a token can't be probed for existence by an unauthorised member.
If any gate fails the response is indistinguishable from "token doesn't exist". A leaked link in Slack therefore reveals nothing to anyone outside the team that owns the source vault.
Guest and link expiries are independent, fail-closed boundaries. A share token may still be within its own lifetime after a guest membership expires, but the guest can no longer resolve it or renew a key lease. The link never extends the membership or carries decryption authority.
2. URL anatomy
https://share.vaultguard.cloud/s/{shareId}?v={vaultId}
▲ ▲ ▲
│ │ └─ Hint: server vault the
│ │ link was minted for. Lets
│ │ the plugin tell the user
│ │ "switch to vault X" without
│ │ a network round trip.
│ └─ 24-byte (192-bit) URL-safe random token,
│ base64url-encoded. Generated in
│ `generateShareToken()` —
│ `infrastructure/lambda/shares/handler.ts:434`.
└─ `SHARE_BASE_URL` env var on the shares Lambda. Defaults to
`http://localhost:5176` for local dev. Production:
`https://share.${domain_name}`, set in
`terraform/modules/lambda/main.tf`.
The token's 192 bits of entropy are above the cap that DynamoDB conditional
writes can collide on at any reasonable issuance rate. The
ConditionExpression: 'attribute_not_exists(shareId)' on insert turns the
otherwise-silent collision case into a 5xx surfaced in CloudWatch logs.
3. End-to-end flow
sequenceDiagram
autonumber
actor Sender as Sender (vault member)
participant Plugin1 as Sender's Plugin
participant API as Shares Lambda
participant DDB as DynamoDB (Shares)
participant Slack as Slack/Email
actor Receiver as Receiver (vault member)
participant Browser as Receiver's Browser
participant Bridge as share.vaultguard.cloud (SPA)
participant Plugin2 as Receiver's Plugin
Sender->>Plugin1: Right-click file → "Copy share link"
Plugin1->>API: POST /vaults/{vaultId}/shares { relPath }
API->>API: requireVaultMember(viewer)
API->>API: evaluatePermission(read, /relPath)
API->>DDB: PutItem ShareRecord (cond. attribute_not_exists)
API-->>Plugin1: { share, url: https://share.../s/abc?v=vid }
Plugin1->>Plugin1: navigator.clipboard.writeText(url)
Sender->>Slack: Paste & send
Receiver->>Browser: Click link
Browser->>Bridge: GET /s/abc?v=vid (HTML+JS only)
Bridge->>Browser: window.location = obsidian://vaultguard-share?...
Browser->>Plugin2: Protocol handoff
Plugin2->>Plugin2: registerShareProtocolHandler → handleShareLink
Plugin2->>Plugin2: Verify boundVaultId === linkVaultId
Plugin2->>API: GET /vaults/{vaultId}/shares/{shareId}
API->>API: requireVaultMember(viewer)
API->>DDB: GetItem
API->>API: evaluatePermission(read, /relPath)
API->>API: Check expiresAt
API-->>Plugin2: { vaultId, vaultName, relPath, ... }
Plugin2->>Plugin2: openFile(relPath) in active workspace
If the receiver's currently bound vault doesn't match the ?v= hint, the
plugin shows a Notice telling them which Obsidian vault to switch to and
does no API call — the link reveals neither path nor existence.
4. Components
4.1 Shares Lambda
infrastructure/lambda/shares/handler.ts. Mounted under
/vaults/{vaultId}/shares via terraform/modules/apigateway/shares.tf.
| Method | Path | Auth gate | Permission gate | Notes |
|---|---|---|---|---|
POST |
/vaults/{vaultId}/shares |
vault viewer |
read on /relPath |
Mints token. Returns { share, url }. |
GET |
/vaults/{vaultId}/shares |
vault viewer |
— | Lists active (non-expired) share records. |
GET |
/vaults/{vaultId}/shares/{shareId} |
vault viewer |
read on /relPath |
Resolves to (vaultId, vaultName, vaultSlug, relPath, ...). Returns 404 on permission fail (not 403) and 410 on expiry. |
DELETE |
/vaults/{vaultId}/shares/{shareId} |
creator OR vault admin |
— | Revokes. Anyone-but-creator must satisfy requireVaultMember(admin). |
Audit events on every path (create / create.denied / list / resolve /
resolve.denied / revoke). See logAudit() calls in the handler.
4.2 Shares DynamoDB table
Defined in terraform/modules/dynamodb/main.tf.
PK = shareId — random 192-bit URL-safe token
GSI vaultId-index — Hash: vaultId, Range: createdAt (newest first)
TTL attribute — expiresAtTtl (epoch seconds; native DynamoDB cleanup)
expiresAt (ISO) and expiresAtTtl (epoch seconds) mirror each other.
The Lambda enforces the application semantics (410 on the resolve path);
DynamoDB's TTL eventually purges the row server-side so leaked tokens age out
even if no one calls DELETE.
4.3 Share Bridge SPA
share-bridge/ — a Vite + TypeScript single-page app deployed at
share.vaultguard.cloud via AWS Amplify. The Amplify app, SPA-rewrite
rule, and domain association are all provisioned by Terraform
(terraform/share-bridge.tf).
What it does:
- Parses
/s/{shareId}?v={vaultId}from the URL. - Builds
obsidian://vaultguard-share?token=...&vault=.... - Triggers the redirect immediately, then surfaces a fallback link after 1.5 s in case Obsidian isn't installed.
What it deliberately doesn't do:
- No API calls. The bridge never talks to the VaultGuard backend, so a scraper can't probe token existence by hitting the page.
- No cookies, no localStorage. Hosting on a separate subdomain isolates it
from
admin.vaultguard.cloud— a leaked token can't be combined with an admin-panel session. - No dynamic HTML rendering. CSP is locked to
default-src 'self',script-src 'self',frame-ancestors 'none',base-uri 'none',form-action 'none'. Seeshare-bridge/index.html. - No tracking.
<meta name="robots" content="noindex,nofollow">andreferrer="no-referrer"keep links out of search and prevent the destination URL from leaking via Referer headers.
Deployment: see share-bridge/README.md. The
Amplify rewrite rule /s/<*> → /index.html (200) is required so SPA-style
paths fall back to the bundle.
4.4 Plugin
src/plugin/main.ts:
- Mint —
copyShareLinkForPath(path)(file-explorer right-click → "VaultGuard: Copy share link") callsapiClient.createShare({ relPath })and writes the URL to the clipboard. - Manage —
openShareManagementModal()(command palette → "Manage share links") opensShareManagementModal(src/plugin/share-management-modal.ts) which lists active shares and lets the user revoke them. - Receive —
registerShareProtocolHandler()registersobsidian://vaultguard-shareand dispatches tohandleShareLink({ token, vault }). The handler:- Refuses if not logged in or unbound (no network call).
- Compares
linkVaultIdagainstboundVaultId; mismatch produces a "switch to the right Obsidian vault" Notice and stops. - Calls
apiClient.resolveShare(boundVaultId, token). - Looks up the file with
app.vault.getAbstractFileByPath(...)andworkspace.getLeaf(false).openFile(...). - If the file isn't in the local vault yet (not synced, renamed, or deleted), surfaces a clear "isn't available in this vault" Notice instead of opening a blank tab.
src/api/client.ts:
createShare({ relPath, expiresAt? }): Promise<ShareRecord>
listShares(): Promise<ShareRecord[]>
resolveShare(vaultId, shareId): Promise<ResolvedShare>
revokeShare(shareId): Promise<void>
ShareRecord and ResolvedShare are exported types — see lines 74–100 of
src/api/client.ts.
5. Security properties
What a leaked link reveals
To a non-member of the source vault: nothing. The bridge renders no
file-specific content; the resolve endpoint refuses with 404.
To a vault member who lacks read on the file: nothing. Same 404 —
indistinguishable from a non-existent token.
To a vault member who has read on the file: the path. They could already
read the file directly — the share link is a UX shortcut for them, not an
escalation.
Token entropy
192 bits via Node's crypto.randomBytes(24) (generateShareToken()).
Brute-forcing the keyspace requires significantly more queries than the
API + WAF rate limits will allow before the token expires or is revoked.
Cross-vault forgery
Both layers refuse a token-vault mismatch:
- API Gateway resource path is
/vaults/{vaultId}/shares/{shareId}— thevaultIdis in the URL and is checked byrequireVaultMember. - The handler additionally compares
record.vaultId !== vault.vaultIdand responds404. So even a vault A admin who happens to know a vault B token can't resolve it from a vault A request.
Time-bound shares
Optional expiresAt on create, capped at one year (MAX_EXPIRY_SECONDS).
Two enforcement paths:
- Hot path — handler returns
410 Goneafter expiry, after the permission check, so an unauthorised recipient still sees404. - Cold path — DynamoDB native TTL on
expiresAtTtlpurges the row eventually. Doesn't drive the user-facing semantics; just ages out the table.
Revocation
Creator can always revoke their own. Vault admins can revoke any share in
the vault (the resolver gate is requireVaultMember(admin) for non-creator
deletes). Revocation is immediate — the row is deleted, so the next resolve
attempt returns 404.
Revoke is authorization-gated (creator or vault admin), not read-gated — by
design, and distinct from resolve/list which run a file-level evaluatePermission
read check. Revoke returns no path or content (only {message, shareId}), and
read-gating it would wrongly stop an admin/creator from killing a leaked share for
a file they can't currently read. (Recorded as SD-04-F3; the summary rule in
CLAUDE.md was narrowed to match this.)
Audit
Every create/list/resolve/revoke is logged via logAudit(). Failed
attempts (shares.create.denied, shares.resolve.denied) are also logged
with the matched-rule ID, so an admin can reconstruct who tried what after
the fact.
6. Operational checklist
When adding/touching share endpoints:
- CORS — both the parent resource (
shares) and the child (shares_id) must be incors_resourcesinterraform/modules/apigateway/cors.tf. They already are; verify after any CORS refactor. SHARE_BASE_URL— Set on the shares Lambda interraform/modules/lambda/main.tf. Production derives it fromvar.domain_name; local dev defaults tohttp://localhost:5176.- DynamoDB TTL —
expiresAtTtlis the epoch-second mirror ofexpiresAt. Don't drop one without the other. - Bridge deploy —
share-bridge/is a separate Amplify app driven byterraform/share-bridge.tf(preferred: git-connected; fallback: manualnpm run build→ drag-dropdist/in the Amplify console). The SPA rewrite rule/s/<*> → /index.html (200)is encoded in Terraform soterraform applyis enough — don't hand-edit it in the console. - Plugin protocol handler —
obsidian://vaultguard-shareis registered inregisterShareProtocolHandler(). Deep-link integration tests live intests/. - Audit retention — share audit rows go through the same audit pipeline as everything else; no special configuration needed beyond the standard audit log retention.
7. What's not a share link
- Public links to non-members. Out of scope. Anyone clicking a link must
have active authenticated membership in the source vault, either permanently
or through an unexpired viewer-only guest grant; that's what makes share
links "internal team only".
Adding public read access would require a different primitive (signed S3
URLs + a per-vault setting), tracked in
docs/SAAS-ROADMAP.md. - Cross-vault sharing. Out of scope by design — see
docs/VAULTS.md§4.E. Add the recipient as a permanent member of the source vault, or invite them as a time-bounded authenticated guest for that vault, instead. - Link previews. The bridge intentionally renders no metadata; an unfurler that hits the URL gets a generic title and no destination preview. Required to keep paths out of preview caches in Slack/email.
- Cryptographic capability. A share link does not carry a key. Files
are still decrypted via the normal vault-scoped key lease the recipient
already holds (or doesn't — see
AT-REST-ENCRYPTION.mdandKEY-LEASE-AND-ZK-IMPLEMENTATION.md).
8. Quick reference
| Concern | Where it lives |
|---|---|
| Lambda handler | infrastructure/lambda/shares/handler.ts |
| API Gateway routes | terraform/modules/apigateway/shares.tf |
| DynamoDB table | terraform/modules/dynamodb/main.tf (aws_dynamodb_table.shares) |
Lambda env (SHARE_BASE_URL) |
terraform/modules/lambda/main.tf |
| CORS | terraform/modules/apigateway/cors.tf (shares, shares_id) |
| OpenAPI | docs/openapi.yaml (tag Shares) |
| Bridge SPA | share-bridge/ |
| Plugin client | src/api/client.ts (createShare / listShares / resolveShare / revokeShare) |
| Plugin URI handler | src/plugin/main.ts (registerShareProtocolHandler, handleShareLink) |
| Plugin context-menu mint | src/plugin/main.ts (copyShareLinkForPath) |
| Plugin management UI | src/plugin/share-management-modal.ts |
| Tests | tests/shares-handler.test.ts, tests/api-client-surface.test.ts |