Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 16 additions & 23 deletions docs/locks.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,26 @@ A lock post looks like a normal post (a short / image / link / … teaser) with
const isLock = !!postDetails.lock; // PostContentBase.tsx
```

`lock` is **provisional** on `NexusPostDetails` / `PostDetailsModel` — Nexus and
pubky-app-specs do not send it yet (only the local demo seeds it). Tagged
`TODO:[Locks] #1998`; remove the note once the spec / Nexus deliver it.
`lock` is written by the publish flow (`useCreateLockContent` → `services/local/post`) and
persisted on `NexusPostDetails` / `PostDetailsModel`. Whether Nexus serves it back on read is
not confirmed here.

## Data shape

| Field | Owner | Meaning |
| ------------------------ | ------------------------------------------ | --------------------------------------------------------------------------------- |
| top-level `kind` | Nexus / specs | teaser display type (`image` / `link` / `short` / …; never `long` / `collection`) |
| top-level `lock` | Lock server (provisional; FE-mock for now) | URL of the public `lock.json` — the detection seam |
| `content` (string) | **FE-owned** | stringified teaser JSON, Zod-validated: `lock_title`, `teaser_description` |
| `lock.json` → `LockFile` | **Lock server** | the public content-lock contract (see below) |
| Field | Owner | Meaning |
| ------------------------ | --------------- | --------------------------------------------------------------------------------- |
| top-level `kind` | Nexus / specs | teaser display type (`image` / `link` / `short` / …; never `long` / `collection`) |
| top-level `lock` | Lock server | URL of the public `lock.json` — the detection seam |
| `content` (string) | **FE-owned** | stringified teaser JSON, Zod-validated: `lock_title`, `teaser_description` |
| `lock.json` → `LockFile` | **Lock server** | the public content-lock contract (see below) |

- `content` is FE-owned (pubky-app-specs does not manage it) and validated at runtime
with Zod (`lockPostContentSchema`, `core/services/locks/locks.types.ts`). Bad / missing
fields degrade to empty strings so the teaser still renders.
- `LockFile` mirrors the Lock server's public `lock.json` (`version`, `creator`,
`primary_resource`, `secondary_resources`, `criteria`, `lock_logic`, `access_policy`,
`lock_server`). It is the **Lock server's contract**, not FE-owned — it should come from
the Lock SDK once that ships a typed reader API (`TODO:[Locks] #1998`). Until then it is
the Lock SDK once that exports one (`TODO:[Locks] locks#22`). Until then it is
hand-mirrored in `locks.types.ts`.

## Render flow (shared by feed and detail)
Expand Down Expand Up @@ -112,23 +112,16 @@ Notes:
factory logs + reports to Sentry once). Hooks catch and degrade to the lock card.
- **Read path.** This is a read flow; there is no local-first `commit*` write.

## Release-gate markers
## Marker tracking

Every dev / temporary shortcut is tagged so it can be audited out before ship. The gate
(#2040): `grep -rn "TODO:\[Locks\]" src/` must return **zero** before release.

| Marker | Removed when | Examples |
| -------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------- |
| `TODO:[Locks] #1998` | Phase-1 (password-only) / provisional resolved | provisional `.lock` field; `LockFile` type → Lock SDK; inline test fixtures |
| `TODO:[Locks] #2001` | auth sandbox verified against the live flow | the `/connect` iframe sandbox set |
| `TODO:[Locks] #2039` | upload robustness landed | orphaned resources after a partial publish; no size checks before upload |
| `TODO:[Locks] #2040` | Lock Server ships the password verifier | `dev-static` proofs (every criterion passes); unvalidated `any` from lock-sdk |
| `TODO:[Locks] #2181` | announcement failure rolls the lock back | a lock left unreferenced when its announcement post fails |
Every dev / temporary shortcut carries the ticket number that owns it —
`grep -rn "TODO:\[Locks\]" src/` lists them, and each number is the issue to read.
Use `grep -rniE "TODO.*lock" src/` to catch one that lost its tag.

## Testing & local demo

- Tests are co-located with each file. Sample test data (a `LockFile` + an author pubky)
is **inlined per test** — there is no mock-data module.
- Tests are co-located with each file. Shared sample data (a `LockFile` + an author pubky)
lives in `src/test-utils/locks.ts` (`mockLockFile()`, `MOCK_LOCK_AUTHOR_PUBKY`).
- No integration test spans UI → application → SDK for the unlock flow; the local stack
(testnet + Lock Server + nexus) is driven manually.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ export function DialogUnlockContent({
onOpenChange(next);
};

// Gate is only "non-empty": Phase 1 has no server password verifier (dev-static passes everything).
// TODO:[Locks] #2040 — enforce the real password once it lands.
// TODO:[Locks] #2369 — password and `dev-static` all go away here.
const handleSubmit = () => {
if (!password || loading) return;
onSubmit(password);
Expand Down
4 changes: 2 additions & 2 deletions src/components/molecules/LockedPostCard/LockedPostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ export function LockedPostCard({
)}
</div>

{/* TODO:[Locks] #1998Phase 1 is password-only, so the indicator is hardcoded. The payment
variant (price, from `verifierType`) arrives with the payment verifier. */}
{/* TODO:[Locks] #2369password and `dev-static` all go away here; the price variant reads
from `verifierType` instead. */}
<div
className={cn(
'relative flex w-fit items-center gap-1 rounded-full bg-card p-1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* Steps 1 and 3 are rendered by the parent; only step 2 is the Lock Server iframe.
*/
import { type ReactNode, useEffect } from 'react';
import type { Session as LocksSdkSession } from '@pubky/locks-sdk';

Check failure on line 15 in src/components/organisms/DialogLocksAuth/DialogLocksAuth.tsx

View workflow job for this annotation

GitHub Actions / Check Code Quality

Cannot find module '@pubky/locks-sdk' or its corresponding type declarations.
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button, ButtonVariant } from '@/atoms/Button/Button';
Expand Down Expand Up @@ -131,7 +131,6 @@
// The parent accepts the auth code only from the configured Lock Server origin.
// Without allow-same-origin, sandboxed postMessage uses origin "null" and gets rejected.
// The other flags let /connect run JS, submit its approval form, and open Pubky Ring.
// TODO:[Locks] #2001 — verify this sandbox set against the live /connect flow.
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
className="h-[420px] w-full"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ export function LockedPostContent({
const tToast = useTranslations('toast.post');
const tLock = useTranslations('post.lock');

// TODO:[Locks] #1998 — `lockContent` is null when the teaser content can't be parsed. Rendering
// nothing matches the previous behaviour; the unparseable-lock UX is still undecided.
if (!lockContent) return null;

const handleViewContent = async (password: string) => {
Expand Down
23 changes: 6 additions & 17 deletions src/core/application/locks/locks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Session as LocksSdkSession } from '@pubky/locks-sdk';

Check failure on line 1 in src/core/application/locks/locks.ts

View workflow job for this annotation

GitHub Actions / Check Code Quality

Cannot find module '@pubky/locks-sdk' or its corresponding type declarations.
import { ServerErrorCode, ValidationErrorCode } from '@/libs/error/error.codes';
import { Err } from '@/libs/error/error.factories';
import { ErrorService } from '@/libs/error/error.types';
Expand Down Expand Up @@ -37,14 +37,7 @@

const isVerifying = (status: TVerificationStatus) => status === 'pending' || status === 'in_progress';

// TODO:[Locks] #2040 — Phase 1 ships the `password` verifier, but the Lock Server does not implement
// one yet: `VerifierType` (locks-core/src/lock_policy.rs) only has `DevStatic`, and unknown verifier
// strings are rejected while parsing, so `password` cannot be sent today. `dev-static` is a
// placeholder that always satisfies — it MUST be replaced (and this constant deleted) before ship.
// Blocked on the Lock Server adding a password verifier.
//
// When it lands: the public lock file (`/pub/locks.app/<lock_id>.json`) carries `criteria[].params`
// verbatim, so the creator's password must never be put there in plaintext.
// TODO:[Locks] #2369 — password and `dev-static` all go away here.
const CRITERION_ID = 'criterion-1';
const VERIFIER_TYPE = 'dev-static';
const VERIFIER_PARAMS = { satisfied: true };
Expand Down Expand Up @@ -160,10 +153,8 @@
* A validation error (permanent data fault) drops only that attachment; any other failure rejects
* the whole read so no caller persists a partial result — see the catch below.
*
* TODO:[Locks] #2040 — both failure kinds surface only after the unlock is already paid for (credential
* issued), so the reader needs a user-facing retry UI that does not charge again — re-download for
* transient failures, re-fetch of the dropped attachment for permanent ones. Decide with the real
* payment verifier.
* TODO:[Locks] #2374 — a permanently dropped attachment still lets the marker land, so the lock
* reads as fully unlocked and the attachment is unrecoverable.
*/
private static async readAttachments(
lockFile: LockFile,
Expand All @@ -175,6 +166,9 @@
uris.map(async (uri) => {
try {
const path = GuardedContentParser.attachmentUriToPath(uri);
// TODO:[Locks] locks#10 — bytes come from the homeserver but the type comes from
// the public `lock.json`, so the two can disagree. Both reads return bytes only today; take
// the type from the response header once the SDK exposes it.
const contentType = lockFile.secondary_resources?.[path]?.content_type;
// No descriptor = a permanent data-integrity error (the bytes live on a HS with no content
// type, so they can never render). Report to Sentry, then drop this one attachment.
Expand Down Expand Up @@ -282,10 +276,6 @@
* (`/priv/locks.app/content/`) — no unlock, no credential, no replication.
* Only valid when the lock owner is the signed-in account (a == b); the caller
* verifies that before calling.
*
* TODO:[Locks] #1998 — content types still come from the public `lock.json` (`secondary_resources`),
* since direct-read/proxy-read return bytes only. pubky/locks#25 makes the SDK preserve the
* response's content-type header; once it's integrated, read the type from there and drop this.
*/
static async fetchOwnContent({ lockFile }: TFetchOwnContentParams): Promise<TUnlockedContent> {
const primaryPath = lockFile.primary_resource?.path;
Expand Down Expand Up @@ -398,7 +388,6 @@
});
}

// TODO:[Locks] #2040 — lock-sdk returns `any`; validate with Zod instead of casting.
return (await LocksService.readContentLock(lockUrl)) as LockFile;
}
}
22 changes: 3 additions & 19 deletions src/core/controllers/locks/locks.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { Session as LocksSdkSession } from '@pubky/locks-sdk';

Check failure on line 1 in src/core/controllers/locks/locks.test.ts

View workflow job for this annotation

GitHub Actions / Check Code Quality

Cannot find module '@pubky/locks-sdk' or its corresponding type declarations.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LocksApplication } from '@/application/locks/locks';
import { AuthErrorCode } from '@/libs/error/error.codes';
import { Err } from '@/libs/error/error.factories';
import { ErrorService } from '@/libs/error/error.types';
import { type LockFile, VerifierType } from '@/services/locks/locks.types';
import { VerifierType } from '@/services/locks/locks.types';
import { useLocksAuthStore } from '@/stores/locksAuth/locksAuth.store';
import { locksAuthInitialState } from '@/stores/locksAuth/locksAuth.types';
import { MOCK_LOCK_AUTHOR_PUBKY, mockLockFile } from '@/test-utils/locks';
import { asOpaque } from '@/test-utils/type-assertions';
import { LocksController } from './locks';

Expand Down Expand Up @@ -251,24 +252,7 @@
});
});

// TODO:[Locks] #1998 — inline test fixtures (sample lock file + author pubky) are
// duplicated across the lock tests; consider extracting a shared test util/fixture.
const MOCK_LOCK_AUTHOR_PUBKY = 'qr3xqyz3e5cyf9npgxc5zfp15ehhcis6gqsxob4une7bwwazekry';
const MOCK_LOCK_FILE: LockFile = {
version: 1,
creator: 'pubkycreator123',
primary_resource: {
path: '/priv/locks.app/content/example.txt',
hash: '<hash>',
content_type: 'text/plain',
size: 13,
},
secondary_resources: {},
criteria: [{ criterion_id: 'criterion-1', verifier_type: 'password', params: { satisfied: true } }],
lock_logic: { type: 'all', criteria: ['criterion-1'] },
access_policy: { requested_credential_ttl_seconds: 900 },
lock_server: { override: 'pubkyserver123' },
};
const MOCK_LOCK_FILE = mockLockFile();

const VALID_LOCK_URL = `pubky://${MOCK_LOCK_AUTHOR_PUBKY}/pub/locks/lock.json`;

Expand Down
3 changes: 0 additions & 3 deletions src/core/models/post/details/postDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ export class PostDetailsModel
kind: string;
uri: string;
attachments: string[] | null;
// TODO:[Locks] #1998 — top-level lock URL; see NexusPostDetails.lock. Optional
// (matches the schema), but assigned in the constructor so `new PostDetailsModel(row)`
// (findById) doesn't drop it on read.
lock?: string | null;

constructor(postDetails: PostDetailsModelSchema) {
Expand Down
22 changes: 3 additions & 19 deletions src/core/pipes/locks/locks.parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,9 @@
import { describe, expect, it } from 'vitest';
import { type GuardedPost, type LockFile, VerifierType } from '@/services/locks/locks.types';
import { type GuardedPost, VerifierType } from '@/services/locks/locks.types';
import { MOCK_LOCK_AUTHOR_PUBKY, mockLockFile } from '@/test-utils/locks';
import { GuardedContentParser, LockContentParser, LockFileParser, LockProofBundler } from './locks.parser';

// TODO:[Locks] #1998 — inline test fixtures (sample lock file + author pubky) are
// duplicated across the lock tests; consider extracting a shared test util/fixture.
const MOCK_LOCK_AUTHOR_PUBKY = 'qr3xqyz3e5cyf9npgxc5zfp15ehhcis6gqsxob4une7bwwazekry';
const MOCK_LOCK_FILE: LockFile = {
version: 1,
creator: 'pubkycreator123',
primary_resource: {
path: '/priv/locks.app/content/example.txt',
hash: '<hash>',
content_type: 'text/plain',
size: 13,
},
secondary_resources: {},
criteria: [{ criterion_id: 'criterion-1', verifier_type: 'password', params: { satisfied: true } }],
lock_logic: { type: 'all', criteria: ['criterion-1'] },
access_policy: { requested_credential_ttl_seconds: 900 },
lock_server: { override: 'pubkyserver123' },
};
const MOCK_LOCK_FILE = mockLockFile();

describe('LockContentParser', () => {
describe('parse', () => {
Expand Down
3 changes: 1 addition & 2 deletions src/core/pipes/locks/locks.parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,7 @@ export class LockProofBundler {
private constructor() {}

static build(lockFile: LockFile, lockUrl: string, bundleId: string): TSubmittedProofBundle {
// TODO:[Locks] #2040 — every criterion is dev-static in Phase 1, so each proof just asserts
// satisfied; real verifier payloads (password/payment) land with the server verifiers.
// TODO:[Locks] #2369 — password and `dev-static` all go away here.
const proofs: TProof[] = lockFile.criteria.map((criterion) => ({
criterion_id: criterion.criterion_id,
verifier_type: criterion.verifier_type,
Expand Down
10 changes: 3 additions & 7 deletions src/core/services/locks/locks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
SetLockServicePointerOptions,
VerificationTaskHandleOptions,
type Viewer,
} from '@pubky/locks-sdk';

Check failure on line 12 in src/core/services/locks/locks.ts

View workflow job for this annotation

GitHub Actions / Check Code Quality

Cannot find module '@pubky/locks-sdk' or its corresponding type declarations.
import { AuthErrorCode } from '@/libs/error/error.codes';
import { Err } from '@/libs/error/error.factories';
import { ErrorService } from '@/libs/error/error.types';
Expand Down Expand Up @@ -109,7 +109,6 @@

/**
* Reads + validates a public lock file via the SDK (pkarr resolve + GET inside `readContentLock`).
* TODO:[Locks] #2040 — lock-sdk returns `any`; validate this response with Zod instead of casting.
*/
static async readContentLock(lockUrl: string): Promise<unknown> {
try {
Expand All @@ -133,8 +132,7 @@
}

// Reader calls are public (no session) → `toAppError`, not `toLocksError` (a 401 isn't an expired session).
// TODO:[Locks] #2040 — lock-sdk returns `any`; validate this response with Zod instead of casting.
// TODO:[Locks] #2040 — `password` reaches here but isn't forwarded to lock-sdk (no password verifier yet).
// TODO:[Locks] #2369 — password and `dev-static` all go away here.
static async submitProofBundle(bundle: TSubmittedProofBundle, _password: string): Promise<TVerificationTask> {
try {
const viewer = await this.getViewer();
Expand All @@ -144,7 +142,6 @@
}
}

// TODO:[Locks] #2040 — lock-sdk returns `any`; validate this response with Zod instead of casting.
static async lookupVerificationTask(creator: string, bundleId: string): Promise<TVerificationTask> {
try {
const viewer = await this.getViewer();
Expand All @@ -156,7 +153,6 @@
}
}

// TODO:[Locks] #2040 — lock-sdk returns `any`; validate this response with Zod instead of casting.
static async issueAccessCredential(creator: string, bundleId: string): Promise<TAccessCredential> {
try {
const viewer = await this.getViewer();
Expand Down Expand Up @@ -263,7 +259,6 @@
const session = getLockSession();
try {
await ensureLocksSdkReady();
// TODO:[Locks] #2040 — lock-sdk returns `any`; validate this response with Zod instead of casting.
const response = (await session.creator.registerGuardedResource(
new RegisterGuardedResourceOptions(path, contentType, bytes),
)) as { creator: string; guarded_resource: TGuardedResource };
Expand Down Expand Up @@ -312,7 +307,8 @@
.lockServer({ override: session.lockServer() })
.build();

// TODO:[Locks] #2040 — lock-sdk returns `any`; validate this response with Zod instead of casting.
// TODO:[Locks] the SDK hands creator responses to JS unparsed (locks#22). Viewer
// responses are parsed on the Rust side, so only the creator ones need a guard until that lands.
const response = (await session.creator.createContentLock(body)) as {
lock_id: string;
content_lock_path: string;
Expand Down
10 changes: 2 additions & 8 deletions src/core/services/locks/locks.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Session as LocksSdkSession } from '@pubky/locks-sdk';

Check failure on line 1 in src/core/services/locks/locks.types.ts

View workflow job for this annotation

GitHub Actions / Check Code Quality

Cannot find module '@pubky/locks-sdk' or its corresponding type declarations.
import { z } from 'zod';
import { POST_KINDS } from '@/models/models.types';

Expand Down Expand Up @@ -46,13 +46,7 @@
size: number;
};

/**
* One unlock criterion.
*
* TODO:[Locks] #2040 — Phase 1 ships a `password` verifier, but the Lock Server only registers
* `dev-static` today, so that placeholder is sent instead — see the content methods on
* `LocksController`. Drop this note once the password verifier lands and the placeholder is gone.
*/
/** One unlock criterion. */
type TLockCriterion = {
criterion_id: string;
verifier_type: string;
Expand Down Expand Up @@ -168,7 +162,7 @@
* The Lock server is a standalone service (not pubky.app-specific), so this type
* belongs to the Lock SDK — hand-mirrored here until that ships a typed reader API.
* The password/payment distinction is read from each criterion's `verifier_type`.
* TODO:[Locks] #1998 — replace with the Lock SDK's type once available.
* TODO:[Locks] locks#22 — replace with the SDK's own type once it exports one.
*/
export interface LockFile {
version: number;
Expand Down
2 changes: 1 addition & 1 deletion src/core/services/locks/locks.utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Locks, LocksOptions, type Session as LocksSdkSession } from '@pubky/locks-sdk';

Check failure on line 1 in src/core/services/locks/locks.utils.ts

View workflow job for this annotation

GitHub Actions / Check Code Quality

Cannot find module '@pubky/locks-sdk' or its corresponding type declarations.
import { getLockServer, getPkarrRelays } from '@/config/network';
import { AuthErrorCode, ValidationErrorCode } from '@/libs/error/error.codes';
import { Err } from '@/libs/error/error.factories';
Expand Down Expand Up @@ -33,7 +33,7 @@
* build of the SDK (wasm-pack `--target web`) requires this before any SDK class is used —
* unlike `@synonymdev/pubky` / `pubky-app-specs`, which self-initialize on import.
*
* TODO: This only exists because the SDK is shipped as the web build. If the SDK is published
* TODO:[Locks] #2372 — This only exists because the SDK is shipped as the web build. If the SDK is published
* as a bundler or self-contained (base64-inlined) build instead, wasm initializes on import and
* this becomes unnecessary — the app would just `import` the SDK like the other wasm deps. Prefer
* that; ask the SDK maintainers to ship it self-contained. Reference for the self-contained
Expand All @@ -42,7 +42,7 @@
*/
export function ensureLocksSdkReady(): Promise<void> {
if (!sdkReady) {
sdkReady = import('@pubky/locks-sdk')

Check failure on line 45 in src/core/services/locks/locks.utils.ts

View workflow job for this annotation

GitHub Actions / Check Code Quality

Cannot find module '@pubky/locks-sdk' or its corresponding type declarations.
.then(async ({ default: init }) => {
await init();
})
Expand Down
7 changes: 1 addition & 6 deletions src/core/services/nexus/nexus.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,7 @@ export type NexusPostDetails = {
kind: string;
uri: string;
attachments: string[] | null;
/**
* TODO:[Locks] #1998 — top-level URL to the post's public `lock.json`; marks the
* post as a lock teaser and is the single source for lock detection
* (`!!postDetails.lock`). Nexus does not return this yet (pubky-app-specs#136) —
* only the FE mock seeds it today. Remove this note once spec/Nexus deliver it.
*/
/** URL of the post's public `lock.json`. Present = lock teaser; the sole lock-detection signal. */
lock?: string | null;
};

Expand Down
Loading
Loading