← All docs

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:

  1. 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.
  2. Cross-vault binding match. The DynamoDB record stores vaultId. The handler rejects (404) any token whose stored vaultId differs 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.
  3. File-level read permission. The handler runs evaluatePermission(..., 'read', '/' + relPath, ...) against the recipient's own permission set. Failure returns 404 (not 403) 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'. See share-bridge/index.html.
  • No tracking. <meta name="robots" content="noindex,nofollow"> and referrer="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:

  • MintcopyShareLinkForPath(path) (file-explorer right-click → "VaultGuard: Copy share link") calls apiClient.createShare({ relPath }) and writes the URL to the clipboard.
  • ManageopenShareManagementModal() (command palette → "Manage share links") opens ShareManagementModal (src/plugin/share-management-modal.ts) which lists active shares and lets the user revoke them.
  • ReceiveregisterShareProtocolHandler() registers obsidian://vaultguard-share and dispatches to handleShareLink({ token, vault }). The handler:
    1. Refuses if not logged in or unbound (no network call).
    2. Compares linkVaultId against boundVaultId; mismatch produces a "switch to the right Obsidian vault" Notice and stops.
    3. Calls apiClient.resolveShare(boundVaultId, token).
    4. Looks up the file with app.vault.getAbstractFileByPath(...) and workspace.getLeaf(false).openFile(...).
    5. 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} — the vaultId is in the URL and is checked by requireVaultMember.
  • The handler additionally compares record.vaultId !== vault.vaultId and responds 404. 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 Gone after expiry, after the permission check, so an unauthorised recipient still sees 404.
  • Cold path — DynamoDB native TTL on expiresAtTtl purges 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:

  1. CORS — both the parent resource (shares) and the child (shares_id) must be in cors_resources in terraform/modules/apigateway/cors.tf. They already are; verify after any CORS refactor.
  2. SHARE_BASE_URL — Set on the shares Lambda in terraform/modules/lambda/main.tf. Production derives it from var.domain_name; local dev defaults to http://localhost:5176.
  3. DynamoDB TTLexpiresAtTtl is the epoch-second mirror of expiresAt. Don't drop one without the other.
  4. Bridge deployshare-bridge/ is a separate Amplify app driven by terraform/share-bridge.tf (preferred: git-connected; fallback: manual npm run build → drag-drop dist/ in the Amplify console). The SPA rewrite rule /s/<*> → /index.html (200) is encoded in Terraform so terraform apply is enough — don't hand-edit it in the console.
  5. Plugin protocol handlerobsidian://vaultguard-share is registered in registerShareProtocolHandler(). Deep-link integration tests live in tests/.
  6. 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.md and KEY-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