mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
Merge pull request #1873 from selfxyz/release/staging-2026-03-27
Release to Staging v2.9.16 - 2026-03-27
This commit is contained in:
94
.claude/skills/gaps-to-issues/SKILL.md
Normal file
94
.claude/skills/gaps-to-issues/SKILL.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: gaps-to-issues
|
||||
description: Create Linear issues from a PR audit doc — one issue per PR bucket with acceptance criteria and linked audit findings.
|
||||
disable-model-invocation: false
|
||||
user-invocable: true
|
||||
argument-hint: '[path-to-audit-doc]'
|
||||
---
|
||||
|
||||
# Gaps to Issues
|
||||
|
||||
You take a PR audit document (produced by `/pr-audit`) and create Linear issues from its PR buckets.
|
||||
|
||||
This skill **stops at issue creation**. Use `/spec-from-audit` to generate specs for the created issues.
|
||||
|
||||
## Input
|
||||
|
||||
`$ARGUMENTS` — Path to the audit document (e.g., `docs/reviews/2026-03-23-branch-audit.md`). If not provided, look for the most recently modified file in `docs/reviews/`.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Read the Audit Document
|
||||
|
||||
Read the audit doc. Verify it has:
|
||||
|
||||
- A "PR Buckets" section with at least one bucket
|
||||
- Each bucket has: name, estimated LOC, findings, files, acceptance criteria
|
||||
|
||||
If the audit doc is missing buckets or acceptance criteria, tell the user and stop.
|
||||
|
||||
### Step 2: Ask for Linear Context
|
||||
|
||||
Ask the user:
|
||||
|
||||
1. Which **Linear team** to create issues under (suggest the team from recent issues if detectable)
|
||||
2. Which **Linear project** to add issues to (optional)
|
||||
3. Whether to set **priority** per bucket or use a default
|
||||
4. Whether to **consolidate** any buckets before creating issues
|
||||
|
||||
Wait for confirmation before proceeding.
|
||||
|
||||
### Step 3: Create Issues
|
||||
|
||||
For each bucket, create a Linear issue using `mcp__linear-server__save_issue`:
|
||||
|
||||
- **Title:** Bucket name (concise, under 70 characters)
|
||||
- **Team:** From Step 2
|
||||
- **Project:** From Step 2 (if provided)
|
||||
- **Priority:** Based on the highest severity finding in the bucket:
|
||||
- Contains Critical findings → Urgent (1)
|
||||
- Contains High findings → High (2)
|
||||
- Contains Medium findings → Medium (3)
|
||||
- Contains only Low findings → Low (4)
|
||||
- **Description:** Keep it lightweight — the spec (created later by `/spec-from-audit`) is the source of truth. The issue body should contain:
|
||||
- One-sentence goal
|
||||
- List of finding IDs from the audit (e.g., "Covers findings #1, #3, #7")
|
||||
- Dependencies on other issues if any
|
||||
- A note: "See attached spec document for full implementation plan."
|
||||
- Do NOT duplicate scope, file lists, or acceptance criteria — that belongs in the spec
|
||||
- **Links:** Add the PR URL if one exists
|
||||
|
||||
If the user asked to consolidate buckets, merge the relevant findings.
|
||||
|
||||
### Step 4: Update the Audit Document
|
||||
|
||||
Add a "Follow-up Issues" section to the top of the audit doc (below the header metadata) with a table of all created issues:
|
||||
|
||||
```markdown
|
||||
## Follow-up Issues
|
||||
|
||||
| Issue | Priority | Title |
|
||||
|-------|----------|-------|
|
||||
| [SELF-NNNN](url) | Urgent | Bucket name |
|
||||
| [SELF-NNNN](url) | High | Bucket name |
|
||||
```
|
||||
|
||||
Update the audit doc status to: `Audit complete — issues created`
|
||||
|
||||
### Step 5: Present Results
|
||||
|
||||
Show the user:
|
||||
|
||||
1. Table of created issues with IDs, priorities, and URLs
|
||||
2. Any buckets that were skipped or consolidated
|
||||
3. Remind them: "Use `/spec-from-audit` to generate specs for these issues."
|
||||
|
||||
**Stop here.** Do not create specs.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Never run `git commit` or `git push`.
|
||||
- Ask before creating — never create issues without user confirmation on team/project/priority.
|
||||
- If a bucket seems too large (>2k LOC estimate), suggest splitting before creating.
|
||||
- If buckets have dependencies between them, note this in the issue descriptions and use Linear's `blockedBy` field.
|
||||
- **Issue descriptions are set once at creation.** After creating an issue, never use `save_issue` to overwrite its description. All follow-up context, corrections, or status updates go in **comments** via `save_comment`. Use `save_issue` only to change structured fields (status, priority, assignee, labels).
|
||||
210
.claude/skills/pr-audit/SKILL.md
Normal file
210
.claude/skills/pr-audit/SKILL.md
Normal file
@@ -0,0 +1,210 @@
|
||||
---
|
||||
name: pr-audit
|
||||
description: Multi-agent PR review — component, integration, and routing analysis merged into a structured audit doc with severity and PR buckets.
|
||||
disable-model-invocation: false
|
||||
user-invocable: true
|
||||
argument-hint: '[base-branch (default: dev)]'
|
||||
---
|
||||
|
||||
# PR Audit
|
||||
|
||||
You run a multi-pass review of the current branch, pull in external PR feedback, and produce a structured audit document with findings grouped into PR-sized fix buckets.
|
||||
|
||||
This skill **stops at the audit doc**. It does not create issues or specs — use `/gaps-to-issues` and `/spec-from-audit` for those steps.
|
||||
|
||||
## Input
|
||||
|
||||
`$ARGUMENTS` — Optional base branch name (default: `dev`).
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Gather the Diff
|
||||
|
||||
Use `$ARGUMENTS` if provided, otherwise default to `dev`.
|
||||
|
||||
Verify the base branch exists:
|
||||
|
||||
```bash
|
||||
git rev-parse --verify <base-branch>
|
||||
```
|
||||
|
||||
If it does not exist, ask the user which branch to diff against.
|
||||
|
||||
Gather the full diff and context:
|
||||
|
||||
```bash
|
||||
git log <base-branch>..HEAD --oneline --no-decorate
|
||||
git diff <base-branch>...HEAD --stat
|
||||
git diff <base-branch>...HEAD
|
||||
git diff <base-branch>...HEAD --name-only
|
||||
git status -s
|
||||
```
|
||||
|
||||
Read the full diff carefully. You will reference it throughout the review passes.
|
||||
|
||||
### Step 2: Multi-Agent Review (3 Parallel Passes)
|
||||
|
||||
Run all three review passes **in parallel** using the Agent tool. Each agent receives the diff context and reviews through a specific lens.
|
||||
|
||||
#### Pass A: Component-Level Review
|
||||
|
||||
For every new or changed screen/component file, check:
|
||||
|
||||
- Missing or incorrect imports (especially cross-package imports that don't resolve)
|
||||
- Wrong or missing props vs the component's actual interface — read the component definition, don't guess
|
||||
- Missing Lottie/animation assets or props that the component supports but aren't passed
|
||||
- Placeholder logic left in production code (hardcoded values, no-op callbacks, `TODO`/`FIXME`)
|
||||
- Missing error/loading/empty states
|
||||
- Raw hex colors instead of design tokens
|
||||
- Files exceeding 800 LOC
|
||||
|
||||
#### Pass B: Integration-Level Review
|
||||
|
||||
For the full set of changed files, check:
|
||||
|
||||
- Provider/contract flow violations — does data flow match what specs define?
|
||||
- State propagation between screens — is state passed correctly through navigation, or lost on refresh/direct-entry?
|
||||
- Persistence gaps — state that should survive reload but only lives in `useState`
|
||||
- Event ordering issues — race conditions, guards that swallow later events
|
||||
- Breaking changes to shared interfaces consumed by other packages
|
||||
- Build-time vs runtime config that should be dynamic
|
||||
|
||||
#### Pass C: Route Wiring Review
|
||||
|
||||
For all navigation-related changes, check:
|
||||
|
||||
- Every `navigate()` / `push()` / `replace()` target has a corresponding route definition
|
||||
- Every new route definition is reachable from at least one navigation call
|
||||
- Orphaned routes — defined but unreachable
|
||||
- Screens that receive `location.state` but have no guard for direct-entry (missing state)
|
||||
- Deep link configuration consistency (if applicable)
|
||||
|
||||
### Step 3: Pull in PR Feedback
|
||||
|
||||
Check if a PR exists for this branch:
|
||||
|
||||
```bash
|
||||
gh pr view --json number,url 2>/dev/null
|
||||
```
|
||||
|
||||
If a PR exists, fetch external review comments:
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate
|
||||
gh api repos/{owner}/{repo}/pulls/{number}/reviews --paginate
|
||||
gh api repos/{owner}/{repo}/issues/{number}/comments --paginate
|
||||
```
|
||||
|
||||
Extract actionable items from CodeRabbit, Codex, and human reviewers. Ignore resolved conversations and pure acknowledgment comments.
|
||||
|
||||
If no PR exists, skip this step.
|
||||
|
||||
### Step 4: Merge and Deduplicate
|
||||
|
||||
Combine findings from all three passes and PR feedback. Deduplicate — if Pass A and a CodeRabbit comment flag the same issue, keep one entry and note both sources.
|
||||
|
||||
Assign severity:
|
||||
|
||||
| Severity | Criteria |
|
||||
|----------|----------|
|
||||
| **Critical** | Build-breaking, crashes, data loss, security holes, blocked user flows |
|
||||
| **High** | Incorrect behavior visible to users, contract violations, missing error handling on critical paths |
|
||||
| **Medium** | Missing states, prop mismatches, design token violations, placeholder logic |
|
||||
| **Low** | Style, minor code quality, non-blocking TODOs |
|
||||
|
||||
### Step 5: Group into PR-Sized Buckets
|
||||
|
||||
Group findings into fix buckets. Each bucket:
|
||||
|
||||
- Targets ≤2,000 LOC changed
|
||||
- Groups related findings touching the same files or logical area
|
||||
- Is independently mergeable (note dependencies if unavoidable)
|
||||
- Has a clear name, estimated LOC, list of files, and acceptance criteria
|
||||
|
||||
### Step 6: Write the Audit Document
|
||||
|
||||
Determine the branch slug from the current branch name (strip `feat/`, `fix/`, etc., replace `/` with `-`).
|
||||
|
||||
Create the audit document at:
|
||||
|
||||
```
|
||||
docs/reviews/YYYY-MM-DD-<branch-slug>-audit.md
|
||||
```
|
||||
|
||||
Create `docs/reviews/` if it does not exist.
|
||||
|
||||
Structure:
|
||||
|
||||
```markdown
|
||||
# PR Audit — <branch-name>
|
||||
|
||||
> Date: YYYY-MM-DD
|
||||
> Branch: `<branch-name>`
|
||||
> Base: `<base-branch>`
|
||||
> PR: #NNN (if exists)
|
||||
> Reviewers: Claude Code (component, integration, routing passes), [external reviewers]
|
||||
> Status: Audit complete — ready for review
|
||||
|
||||
## Summary
|
||||
|
||||
[2-3 sentences describing what the branch does and the overall health assessment.]
|
||||
|
||||
## Findings
|
||||
|
||||
### Critical
|
||||
|
||||
- [ ] [finding] — `path/to/file.ts:NN`
|
||||
- **Source:** [Pass A | Pass B | Pass C | CodeRabbit | reviewer]
|
||||
- **Fix:** [concrete action]
|
||||
|
||||
### High
|
||||
|
||||
- [ ] ...
|
||||
|
||||
### Medium
|
||||
|
||||
- [ ] ...
|
||||
|
||||
### Low
|
||||
|
||||
- [ ] ...
|
||||
|
||||
## PR Buckets
|
||||
|
||||
### Bucket 1: [name]
|
||||
- **Estimated LOC:** ~NNN
|
||||
- **Findings:** #1, #3, #7
|
||||
- **Files:** [list]
|
||||
- **Dependencies:** [none | Bucket N must land first]
|
||||
- **Acceptance criteria:**
|
||||
- [ ] [criterion]
|
||||
|
||||
### Bucket 2: [name]
|
||||
...
|
||||
|
||||
## What Works Well
|
||||
|
||||
[List things the branch does correctly — not just problems.]
|
||||
```
|
||||
|
||||
### Step 7: Present to User
|
||||
|
||||
Show the user:
|
||||
|
||||
1. Total finding count by severity
|
||||
2. Each bucket with name, estimated LOC, and findings covered
|
||||
3. Any findings that didn't fit cleanly into a bucket
|
||||
|
||||
Tell the user:
|
||||
|
||||
> "Audit complete. Review the findings and buckets above. When ready, use `/gaps-to-issues` to create Linear issues from these buckets, then `/spec-from-audit` to generate specs."
|
||||
|
||||
**Stop here.** Do not create issues or specs.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Never run `git commit` or `git push`. Stage the audit doc but stop there.
|
||||
- Read the full diff — do not summarize from file names alone.
|
||||
- Findings must be specific and actionable with file paths and line numbers.
|
||||
- When estimating LOC for buckets, be conservative.
|
||||
- If the diff is very large (>10k LOC), warn the user and suggest focusing on critical/high only.
|
||||
224
.claude/skills/pr-summary/SKILL.md
Normal file
224
.claude/skills/pr-summary/SKILL.md
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
name: pr-summary
|
||||
description: Generate a GitHub PR title and summary from all branch changes, including Linear issue and Figma links when available.
|
||||
disable-model-invocation: false
|
||||
user-invocable: true
|
||||
argument-hint: '[base-branch (default: main)]'
|
||||
---
|
||||
|
||||
# Generate PR Title and Summary
|
||||
|
||||
You generate a concise GitHub PR title and a structured summary covering ALL changes on the current branch compared to the base branch.
|
||||
|
||||
## Input
|
||||
|
||||
`$ARGUMENTS` — Optional base branch name (default: `main`).
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Determine the Base Branch
|
||||
|
||||
Use `$ARGUMENTS` if provided, otherwise default to `main`.
|
||||
|
||||
Verify the base branch exists:
|
||||
|
||||
```bash
|
||||
git rev-parse --verify <base-branch>
|
||||
```
|
||||
|
||||
If it doesn't exist, ask the user which branch to diff against.
|
||||
|
||||
### Step 2: Gather All Branch Changes
|
||||
|
||||
Run these commands to understand the FULL scope of changes on this branch (not just the latest commit):
|
||||
|
||||
1. **Commit log** — all commits since diverging from the base branch:
|
||||
|
||||
```bash
|
||||
git log <base-branch>..HEAD --oneline --no-decorate
|
||||
```
|
||||
|
||||
2. **Full diff stat** — files changed summary:
|
||||
|
||||
```bash
|
||||
git diff <base-branch>...HEAD --stat
|
||||
```
|
||||
|
||||
3. **Full diff** — the actual changes (read this carefully, it is the source of truth for the summary):
|
||||
|
||||
```bash
|
||||
git diff <base-branch>...HEAD
|
||||
```
|
||||
|
||||
4. **Unstaged/untracked changes** — anything not yet committed:
|
||||
|
||||
```bash
|
||||
git status -s
|
||||
```
|
||||
|
||||
If there are uncommitted changes, note them separately in the output so the user knows they won't be in the PR.
|
||||
|
||||
### Step 3: Scan for Linear Issue References
|
||||
|
||||
Search for Linear issue references in:
|
||||
|
||||
1. **Commit messages** — look for patterns like `SELF-XXXX`, `[SELF-XXXX]`, or Linear URLs
|
||||
2. **Branch name** — the current branch name may contain an issue ID or slug
|
||||
3. **Changed file content** — scan diff for Linear issue URLs or IDs in comments/docs
|
||||
4. **Spec docs** — scan `specs/` for markdown files that reference the files changed on this branch. Cross-reference the changed file paths from `git diff --name-only` against the spec content to find the relevant issues.
|
||||
|
||||
For each Linear issue found, fetch its details:
|
||||
|
||||
- Call `mcp__linear-server__get_issue` with the issue ID
|
||||
- Extract: title, status, URL, parent issue (if any)
|
||||
|
||||
If no Linear issues are found, skip this section in the output.
|
||||
|
||||
### Step 4: Scan for Figma References
|
||||
|
||||
Search for Figma resource links in:
|
||||
|
||||
1. **Changed file content** — scan the diff for `figma.com` URLs
|
||||
2. **Linear issues found in Step 3** — check their descriptions for Figma links
|
||||
3. **Commit messages** — look for Figma URLs
|
||||
|
||||
Collect unique Figma URLs with brief context (which screen/component they relate to).
|
||||
|
||||
If no Figma links are found, skip this section in the output.
|
||||
|
||||
### Step 5: Categorize the Changes
|
||||
|
||||
Group changes into categories based on what was modified:
|
||||
|
||||
- **React Native app** — changes under `app/`
|
||||
- **SDK core** — changes under `packages/mobile-sdk-alpha/`
|
||||
- **WebView app** — changes under `packages/webview-app/`
|
||||
- **WebView bridge** — changes under `packages/webview-bridge/`
|
||||
- **KMP SDK** — changes under `packages/kmp-sdk/`
|
||||
- **Native shells** — changes under `packages/native-shell-android/` or `packages/native-shell-ios/`
|
||||
- **RN SDK** — changes under `packages/rn-sdk/`
|
||||
- **Noir circuits** — changes under `noir/` or `circuits/`
|
||||
- **Contracts** — changes under `contracts/`
|
||||
- **Common/shared** — changes under `common/` or `new-common/`
|
||||
- **Tests** — test files added or modified (`.test.ts`, `.spec.ts`, etc.)
|
||||
- **Assets** — images, Lottie JSON, sounds added or renamed
|
||||
- **Docs/specs** — changes to `docs/`, `specs/`, `CLAUDE.md`, or other markdown
|
||||
- **Config/infra** — `package.json`, `tsconfig`, CI, scripts, patches, etc.
|
||||
|
||||
Only include categories that have changes. Collapse small categories (1-2 files) together if it aids readability.
|
||||
|
||||
### Step 6: Generate the PR Title
|
||||
|
||||
Rules:
|
||||
|
||||
- Under 70 characters
|
||||
- Lead with the action verb: `add`, `update`, `fix`, `remove`, `refactor`, `rename`
|
||||
- Describe the overall intent, not individual files
|
||||
- Do not include issue IDs in the title
|
||||
|
||||
### Step 7: Generate the PR Summary
|
||||
|
||||
Output the complete PR body using this format:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
[2-4 bullet points covering the most important changes. Focus on the "what" and "why", not file-by-file details.]
|
||||
|
||||
## Changes
|
||||
|
||||
### [Category Name]
|
||||
|
||||
- [change description]
|
||||
- [change description]
|
||||
|
||||
### [Category Name]
|
||||
|
||||
- [change description]
|
||||
|
||||
## Linear Issues
|
||||
|
||||
- Closes [SELF-XXXX](url) — [title]
|
||||
|
||||
## Figma
|
||||
|
||||
- [brief context](figma-url)
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] `yarn lint && yarn types` passes
|
||||
- [ ] [package-specific validation commands as relevant]
|
||||
- [ ] [any manual verification steps specific to these changes]
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
Omit the "Linear Issues" section if none were found.
|
||||
Omit the "Figma" section if no Figma links were found.
|
||||
|
||||
If the diff touches native paths (`app/`, `packages/native-shell-*`, `packages/kmp-sdk/`, iOS/Android project files), append this checklist to the Test Plan section:
|
||||
|
||||
```markdown
|
||||
### Native Consolidation Checklist
|
||||
|
||||
- [ ] CONTRACTS.md reviewed - no unintended contract changes
|
||||
- [ ] Layer 1 bridge contract tests pass (`cd app && yarn jest:run` / `yarn workspace @selfxyz/rn-sdk-test-app test`)
|
||||
- [ ] Layer 3 builds pass (app iOS, RN test app iOS, RN test app Android)
|
||||
- [ ] Layer 4 manual smoke test signed off (if consolidation PR)
|
||||
- [ ] No new native business logic added (logic belongs in TypeScript)
|
||||
```
|
||||
|
||||
### Step 8: Check for Existing PR
|
||||
|
||||
Check if a PR already exists for the current branch:
|
||||
|
||||
```bash
|
||||
gh pr view --json number,title,url 2>/dev/null
|
||||
```
|
||||
|
||||
**If a PR exists:**
|
||||
|
||||
First, fetch the current PR title and body:
|
||||
|
||||
```bash
|
||||
gh pr view --json number,title,body,url
|
||||
```
|
||||
|
||||
If the PR body is empty/whitespace, the title matches the branch name, or the title is a generic default (slug-style text with slashes/hyphens, auto-generated patterns), note that it appears auto-generated.
|
||||
|
||||
Show the generated title and body, then ask: "PR #NNN appears to have a template/auto-generated description. Update it?"
|
||||
|
||||
If the PR has a meaningful hand-written title and body, show both the current and newly generated versions, then ask: "PR #NNN already has a custom title/description. Replace it?"
|
||||
|
||||
If the user confirms, run:
|
||||
|
||||
```bash
|
||||
gh pr edit <number> --title "<title>" --body "<body>"
|
||||
```
|
||||
|
||||
**If no PR exists:**
|
||||
|
||||
1. Show the generated title and body clearly:
|
||||
|
||||
```
|
||||
Title: [the title]
|
||||
```
|
||||
|
||||
Then the full body in a fenced code block.
|
||||
|
||||
2. Ask the user: "Create this PR?"
|
||||
3. If the user confirms, run:
|
||||
|
||||
```bash
|
||||
gh pr create --base <base-branch> --title "<title>" --body "<body>"
|
||||
```
|
||||
|
||||
In both cases, if there were uncommitted changes found in Step 2, warn the user about them before proceeding.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Always diff against the base branch, not just the previous commit. The PR should describe ALL work on the branch.
|
||||
- Read the actual diff content — don't just summarize file names. Understand what changed semantically.
|
||||
- Keep the summary concise. The diff is available in the PR; the summary should help reviewers understand intent and scope quickly.
|
||||
- Always ask before creating or updating a PR. Never run `gh pr create` or `gh pr edit` without user confirmation.
|
||||
- Never run `git push`. The user handles pushing themselves. If the branch is not pushed, remind them to push first.
|
||||
139
.claude/skills/spec-from-audit/SKILL.md
Normal file
139
.claude/skills/spec-from-audit/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: spec-from-audit
|
||||
description: Generate one spec per issue — repo file (canonical) + Linear document (mirror). Agent-executable implementation plans with file paths, validation commands, and acceptance criteria.
|
||||
disable-model-invocation: false
|
||||
user-invocable: true
|
||||
argument-hint: '[issue IDs or path-to-audit-doc]'
|
||||
---
|
||||
|
||||
# Spec from Audit
|
||||
|
||||
You take Linear issues (created by `/gaps-to-issues`) and generate one spec per issue. Each spec is written to the repo (`specs/`) as the canonical version, then mirrored to a Linear document for cross-tool access. A new Claude Code session with no prior context should be able to pick up the repo spec and produce a correct PR.
|
||||
|
||||
## Input
|
||||
|
||||
`$ARGUMENTS` — Either:
|
||||
- A list of Linear issue IDs (e.g., `SELF-2357 SELF-2358 SELF-2359`)
|
||||
- A path to an audit doc that has a "Follow-up Issues" section
|
||||
|
||||
If not provided, look for the most recently modified audit doc in `docs/reviews/` and extract issue IDs from it.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Gather Issue Context
|
||||
|
||||
For each issue ID, fetch the issue details:
|
||||
|
||||
- Use `mcp__linear-server__get_issue` to read the title, description, and acceptance criteria
|
||||
- If an audit doc path is available, read the full audit doc for additional context on findings
|
||||
|
||||
### Step 2: Read the Codebase
|
||||
|
||||
For each issue, read the files referenced in its description/scope. You need to understand the **current state** of the code to write accurate specs with line numbers.
|
||||
|
||||
Do not guess file contents — read them. Specs with wrong line numbers or stale code references are worse than no spec.
|
||||
|
||||
### Step 3: Generate Specs
|
||||
|
||||
For each issue, write the spec to **both** locations:
|
||||
|
||||
1. **Repo file** — Write to `specs/projects/sdk/workstreams/<scope>/plans/<ID>-<slug>.md` using the Write tool. Determine `<scope>` from the workstream the issue belongs to (e.g., `webview`, `sdk-core`, `build-pipeline`). Always create or update the backlog row in the workstream's `SPEC.md` — if `SPEC.md` doesn't exist yet, create it.
|
||||
2. **Linear document** — Create using `mcp__linear-server__create_document`, linked to the **issue** (not the project). This is the cross-tool access copy.
|
||||
|
||||
**Title format:** `SPEC: <issue title>`
|
||||
|
||||
**Spec structure:**
|
||||
|
||||
```markdown
|
||||
## Overview
|
||||
|
||||
You are [doing what] in [which area]. [1-2 sentences on why this matters.]
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [What must be true before starting — dependencies on other PRs, packages published, etc.]
|
||||
|
||||
## [Problem 1 / Area 1]: [descriptive name]
|
||||
|
||||
**File:** `path/to/file.ts`
|
||||
|
||||
[Description of the current problem with exact line numbers.]
|
||||
|
||||
### Fix
|
||||
|
||||
[Step-by-step instructions. Be explicit about what to change and why.]
|
||||
|
||||
## [Problem 2 / Area 2]: [descriptive name]
|
||||
|
||||
...
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `path/to/file.ts` — [what changes]
|
||||
- `path/to/other.ts` — [what changes]
|
||||
|
||||
## Files NOT to modify
|
||||
|
||||
- `path/to/untouched/` — [why]
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [Other issues/PRs that must land first, if any]
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
[relevant validation commands from the repo]
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] [acceptance criterion from the issue]
|
||||
- [ ] [acceptance criterion]
|
||||
- [ ] All validation commands pass
|
||||
```
|
||||
|
||||
### Spec-Writing Rules
|
||||
|
||||
The cardinal rule: **if two reasonable engineers could implement different solutions from the same spec, the spec is too open.** Specs must contain decisions, not options.
|
||||
|
||||
Follow these strictly:
|
||||
|
||||
1. **Decisions, not options** — "Use local wrappers" not "Consider adding to Euclid or using local wrappers." Every ambiguous implementation choice must be resolved in the spec. If you genuinely can't decide, flag it as a blocker and ask the user — don't embed it as an option.
|
||||
2. **Second person** — "You are fixing...", "You will modify..."
|
||||
3. **Exact file paths with line numbers** — `src/utils/sumsubProvider.ts:118`, not "the provider file"
|
||||
4. **Current code, not stale references** — you read the files in Step 2, use what you actually saw
|
||||
5. **Explicit constraints** — "You will NOT modify..." sections prevent scope creep
|
||||
6. **Required vs optional** — mark every item. Don't let agents infer priority.
|
||||
7. **Validation command** — agents will run it. If it's not there, they'll skip validation.
|
||||
8. **One spec = one PR ≤2k LOC** — if a spec feels like it would produce >2k LOC, flag it and suggest splitting
|
||||
9. **Self-contained** — the spec must include enough context to execute without reading other specs or the full audit doc
|
||||
|
||||
Every spec should follow this structure:
|
||||
1. State the goal in one sentence
|
||||
2. List decisions already made
|
||||
3. Define scope and out-of-scope
|
||||
4. Name the files to modify
|
||||
5. Define done criteria in behavior terms
|
||||
|
||||
### Step 4: Present Results
|
||||
|
||||
Show the user:
|
||||
|
||||
1. Table of created specs with issue IDs and document URLs
|
||||
2. Any issues that were too complex and need manual spec-writing
|
||||
3. Any issues where codebase reading revealed the problem is already fixed or different than described
|
||||
|
||||
**Stop here.**
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Never run `git commit` or `git push`.
|
||||
- Always read the actual files before writing specs — stale line numbers are actively harmful.
|
||||
- If an issue references files that don't exist, flag it rather than guessing.
|
||||
- If you discover the issue description is wrong (e.g., the code was already fixed), note this and ask the user whether to still create the spec. If the user wants the issue updated, add a **comment** via `save_comment` explaining what changed — never overwrite the issue description.
|
||||
- Specs are for agents, not humans. Write them as precise instructions, not explanatory documents.
|
||||
- The spec is the source of truth, not the issue body. Issue bodies are lightweight pointers — the spec must be fully self-contained. Do not assume the agent has read the issue description.
|
||||
- The repo file (`specs/`) is the canonical version. The Linear document is a copy for cross-tool access. Both should have identical content.
|
||||
- Link Linear documents to issues (not projects). Use `mcp__linear-server__create_document` with the `issue` parameter.
|
||||
- **Never overwrite issue descriptions.** Use `save_comment` for all updates, corrections, and status notes. Use `save_issue` only to change structured fields (status, priority, assignee).
|
||||
20
.github/pull_request_template.md
vendored
20
.github/pull_request_template.md
vendored
@@ -1,11 +1,19 @@
|
||||
### Description
|
||||
## Summary
|
||||
|
||||
_A brief description of the changes, what and how is being changed._
|
||||
<!-- Brief description of changes -->
|
||||
|
||||
### Tested
|
||||
## Test plan
|
||||
|
||||
_Explain how the change has been tested (for example by manual testing, unit tests etc) or why it's not necessary (for example version bump)._
|
||||
<!-- How was this tested? -->
|
||||
|
||||
### How to QA
|
||||
---
|
||||
|
||||
_How can the change be tested in a repeatable manner?_
|
||||
### Native Consolidation Checklist
|
||||
|
||||
<!-- Check items that apply to this PR. Delete section if not touching native code. -->
|
||||
|
||||
- [ ] CONTRACTS.md reviewed - no unintended contract changes
|
||||
- [ ] Layer 1 bridge contract tests pass (`cd app && yarn jest:run` / `yarn workspace @selfxyz/rn-sdk-test-app test`)
|
||||
- [ ] Layer 3 builds pass (app iOS, RN test app iOS, RN test app Android)
|
||||
- [ ] Layer 4 manual smoke test signed off (if consolidation PR)
|
||||
- [ ] No new native business logic added (logic belongs in TypeScript)
|
||||
|
||||
4
.github/workflows/mobile-deploy.yml
vendored
4
.github/workflows/mobile-deploy.yml
vendored
@@ -715,7 +715,7 @@ jobs:
|
||||
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
|
||||
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SUMSUB_TEE_URL: ${{ secrets.SUMSUB_TEE_URL }}
|
||||
DIDIT_TEE_URL: ${{ secrets.DIDIT_TEE_URL }}
|
||||
TURNKEY_AUTH_PROXY_CONFIG_ID: ${{ secrets.TURNKEY_AUTH_PROXY_CONFIG_ID }}
|
||||
TURNKEY_GOOGLE_CLIENT_ID: ${{ secrets.TURNKEY_GOOGLE_CLIENT_ID }}
|
||||
TURNKEY_ORGANIZATION_ID: ${{ secrets.TURNKEY_ORGANIZATION_ID }}
|
||||
@@ -1176,7 +1176,7 @@ jobs:
|
||||
NODE_OPTIONS: "--max-old-space-size=6144"
|
||||
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SUMSUB_TEE_URL: ${{ secrets.SUMSUB_TEE_URL }}
|
||||
DIDIT_TEE_URL: ${{ secrets.DIDIT_TEE_URL }}
|
||||
TURNKEY_AUTH_PROXY_CONFIG_ID: ${{ secrets.TURNKEY_AUTH_PROXY_CONFIG_ID }}
|
||||
TURNKEY_GOOGLE_CLIENT_ID: ${{ secrets.TURNKEY_GOOGLE_CLIENT_ID }}
|
||||
TURNKEY_ORGANIZATION_ID: ${{ secrets.TURNKEY_ORGANIZATION_ID }}
|
||||
|
||||
28
.github/workflows/webview-app-ci.yml
vendored
28
.github/workflows/webview-app-ci.yml
vendored
@@ -39,6 +39,32 @@ jobs:
|
||||
- name: Build webview-app
|
||||
run: yarn workspace @selfxyz/webview-app build
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build common
|
||||
run: yarn workspace @selfxyz/common build
|
||||
- name: Build mobile-sdk-alpha
|
||||
run: yarn workspace @selfxyz/mobile-sdk-alpha build
|
||||
- name: Build webview-bridge
|
||||
run: yarn workspace @selfxyz/webview-bridge build
|
||||
- name: Run linter
|
||||
run: yarn workspace @selfxyz/webview-app lint
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Check Prettier formatting
|
||||
run: yarn workspace @selfxyz/webview-app fmt
|
||||
|
||||
types:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -53,4 +79,4 @@ jobs:
|
||||
- name: Build webview-bridge
|
||||
run: yarn workspace @selfxyz/webview-bridge build
|
||||
- name: Typecheck
|
||||
run: yarn workspace @selfxyz/webview-app typecheck
|
||||
run: yarn workspace @selfxyz/webview-app types
|
||||
|
||||
30
.github/workflows/webview-bridge-ci.yml
vendored
30
.github/workflows/webview-bridge-ci.yml
vendored
@@ -6,12 +6,16 @@ permissions:
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "common/**"
|
||||
- "packages/mobile-sdk-alpha/**"
|
||||
- "packages/webview-bridge/**"
|
||||
- ".github/workflows/webview-bridge-ci.yml"
|
||||
- ".github/actions/**"
|
||||
push:
|
||||
branches: [dev, staging, main]
|
||||
paths:
|
||||
- "common/**"
|
||||
- "packages/mobile-sdk-alpha/**"
|
||||
- "packages/webview-bridge/**"
|
||||
- ".github/workflows/webview-bridge-ci.yml"
|
||||
- ".github/actions/**"
|
||||
@@ -31,6 +35,30 @@ jobs:
|
||||
- name: Build
|
||||
run: yarn workspace @selfxyz/webview-bridge build
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build common
|
||||
run: yarn workspace @selfxyz/common build
|
||||
- name: Build mobile-sdk-alpha
|
||||
run: yarn workspace @selfxyz/mobile-sdk-alpha build
|
||||
- name: Run linter
|
||||
run: yarn workspace @selfxyz/webview-bridge lint
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Check Prettier formatting
|
||||
run: yarn workspace @selfxyz/webview-bridge fmt
|
||||
|
||||
types:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -43,7 +71,7 @@ jobs:
|
||||
- name: Build mobile-sdk-alpha
|
||||
run: yarn workspace @selfxyz/mobile-sdk-alpha build
|
||||
- name: Typecheck
|
||||
run: yarn workspace @selfxyz/webview-bridge typecheck
|
||||
run: yarn workspace @selfxyz/webview-bridge types
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -13,7 +13,10 @@ output/*
|
||||
*.tsbuildinfo
|
||||
.yarnrc.yml
|
||||
package-lock.json
|
||||
.claude
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
**/.claude/settings.json
|
||||
**/.claude/settings.local.json
|
||||
|
||||
# CI-generated tarballs (don't commit these!)
|
||||
mobile-sdk-alpha-ci.tgz
|
||||
|
||||
@@ -10,5 +10,6 @@ circuits/build/**
|
||||
contracts/artifacts/**
|
||||
contracts/cache/**
|
||||
contracts/typechain-types/**
|
||||
packages/webview-app/public/animations/**
|
||||
.nvmrc
|
||||
.watchmanconfig
|
||||
|
||||
75
CLAUDE.md
75
CLAUDE.md
@@ -43,60 +43,60 @@ nvm use && corepack enable && yarn install
|
||||
- **No regressions in the RN app.** Every change to `mobile-sdk-alpha` must be backwards-compatible with the existing Self Wallet app.
|
||||
- **Specs stay current.** When implementation deviates from the spec, update the spec. A stale spec is worse than no spec.
|
||||
- **Constraint tie-breaker.** If rules conflict: correctness and security first, then scope/clarity (small PRs, small files), then reuse. Document the tradeoff in the spec.
|
||||
- **Linear issue descriptions are immutable after creation.** Never overwrite an issue description with `save_issue` to add updates, status notes, or context. Issue descriptions are the original scope set at creation time. All subsequent updates — status changes, progress notes, discovered context, blockers, decision records — go in **comments** via `save_comment`. The only valid use of `save_issue` on an existing issue is to change structured fields (status, priority, assignee, labels). If you need to correct a factual error in the description, add a comment explaining the correction rather than silently rewriting history.
|
||||
|
||||
## Specs & Planning
|
||||
|
||||
**Every feature — even minor ones — uses the spec system.** Before implementing, read the relevant specs, write a plan to disk, then execute. No exceptions. A plan that only exists in session memory is a plan that will be lost.
|
||||
**Every feature — even minor ones — needs a spec.** For SDK work (`packages/`, `webview-app`, `webview-bridge`), specs live in **both** the repo (`specs/`) and Linear. The repo spec is the canonical, version-controlled execution plan. The Linear issue is the tracking and discovery layer. For app-only or non-SDK work, a Linear issue with inline scope is sufficient — no repo spec required.
|
||||
|
||||
### Spec System (`specs/`)
|
||||
### Where Specs Live
|
||||
|
||||
| File | Purpose | When to Read |
|
||||
| ------------------------------------------------ | ------------------------------------------- | ------------------------ |
|
||||
| [Specs README](./specs/README.md) | Table of contents, reading order | First. Always. |
|
||||
| [Templates](./specs/framework/TEMPLATES.md) | Copy-paste templates for all three tiers | When creating a new spec |
|
||||
| [SDK Overview](./specs/projects/sdk/OVERVIEW.md) | Architecture, bridge protocol, module table | For system-level context |
|
||||
|
||||
Workstream specs live in `specs/projects/sdk/workstreams/*/` with `SPEC.md` (living implementation details).
|
||||
|
||||
### Spec-Reading Protocol (for chunk execution)
|
||||
|
||||
To execute a chunk:
|
||||
|
||||
1. Read `specs/projects/sdk/INDEX.md` — find your workstream
|
||||
2. Read the workstream `SPEC.md` — find your chunk
|
||||
3. If you need architecture context, read the project `OVERVIEW.md`
|
||||
|
||||
That's it. Do not read framework docs unless you are writing a new spec.
|
||||
- **Execution specs → `specs/projects/sdk/workstreams/<scope>/plans/<ID>-<slug>.md`** — version-controlled, agent-executable plans
|
||||
- **Backlog → `specs/projects/sdk/workstreams/<scope>/SPEC.md`** — durable context plus backlog table per workstream
|
||||
- **Architecture context → `specs/projects/sdk/OVERVIEW.md`** — system architecture, bridge protocol, decision matrix
|
||||
- **Audit docs → `docs/reviews/`** — PR audit findings, kept in repo for git history
|
||||
- **Linear issues** — tracking, discovery, status. Link to the repo spec. Attach a Linear document copy for cross-tool access.
|
||||
|
||||
### Planning Protocol
|
||||
|
||||
1. **Read** the relevant workstream specs and this file's Key Rules — understand the current state and constraints
|
||||
2. **Write a plan to disk** — use the appropriate tier from `specs/framework/TEMPLATES.md`:
|
||||
- **Large features / new workstreams:** Create a full implementation spec (`specs/projects/sdk/workstreams/<scope>/SPEC.md`)
|
||||
- **Medium features / multi-chunk work:** Create a plan file in `workstreams/<scope>/plans/` named `<BACKLOG-ID>-<slug>.md` and link it from the backlog in the relevant `SPEC.md`
|
||||
- **Small features / single-chunk fixes:** Create a minimal plan file in `workstreams/<scope>/plans/` named `<BACKLOG-ID>-<slug>.md` or add the chunk to an existing active plan
|
||||
3. **Include in every plan:** scope of work, files modified, I/O examples, validation command, definition of done
|
||||
4. **Then implement** — update chunk status as you complete work
|
||||
5. **After completion:** Mark chunks done in SPEC.md status tables. Review status checklists at session start — if something is marked "Done" that isn't, or "Pending" that's in progress, fix it first.
|
||||
1. **Read** this file's Key Rules and any relevant specs — understand the current state and constraints
|
||||
2. **Create a Linear issue** if one doesn't exist — include scope, files modified, acceptance criteria
|
||||
3. **Write the spec** in `specs/` following the two-layer model (backlog row in `SPEC.md`, execution plan in `plans/`)
|
||||
4. **Create a Linear document** attached to the issue with the spec content (so non-GitHub users can review)
|
||||
5. **Then implement** — one spec = one PR (see PR size target in Key Rules)
|
||||
6. **After completion:** Update the Linear issue status via `save_issue` (status field only). Add a **comment** via `save_comment` summarizing what was done, linking the PR. Close when done.
|
||||
|
||||
### Spec-Writing Guidelines
|
||||
|
||||
When writing specs, follow these principles so they work as AI agent prompts:
|
||||
Specs are agent-executable prompts. A new Claude Code session with no prior context must be able to pick up the spec and produce a correct PR.
|
||||
|
||||
- **Use second person.** "You are making X portable" not "X should be made portable."
|
||||
- **Make decisions, not options.** "Use local wrappers" not "Consider adding to Euclid or using local wrappers." Agents can't choose between approaches — tell them which one.
|
||||
- **Use second person.** "You are fixing X" not "X should be fixed."
|
||||
- **Be explicit about constraints.** "You will NOT modify..." not just "Focus on..."
|
||||
- **Provide exact file paths with line numbers.** `src/proving/provingMachine.ts:543` not "the proving machine file."
|
||||
- **Provide exact file paths with line numbers.** `src/utils/sumsubProvider.ts:118` not "the provider file."
|
||||
- **State the validation command.** Agents will run it. If it's not there, they'll skip validation.
|
||||
- **One chunk = one self-contained prompt.** The chunk must include enough context to execute without reading the full spec.
|
||||
- **One PR = one plan file.** A plan file is the execution handoff. It must be self-contained enough that a new agent can pick it up after session loss.
|
||||
- **Use `--remote` for M and L chunks.** Medium and large chunks benefit from `claude --remote` so work continues in the background.
|
||||
- **One spec = one PR.** Target the PR size from Key Rules (1k–3k LOC). If a spec would exceed that, split it.
|
||||
- **Mark items as required vs optional.** Don't let agents infer priority.
|
||||
- **Include out-of-scope sections.** These are as important as in-scope sections for preventing drift.
|
||||
- **Use `--remote` for medium+ work.** Medium and large specs benefit from `claude --remote` so work continues in the background.
|
||||
|
||||
### Why Even Minor Features
|
||||
### Audit Pipeline Skills
|
||||
|
||||
Three Claude Code skills automate the review-to-implementation pipeline:
|
||||
|
||||
1. **`/pr-audit`** — Multi-agent review (component + integration + routing), produces audit doc in `docs/reviews/`
|
||||
2. **`/gaps-to-issues`** — Creates Linear issues from audit PR buckets
|
||||
3. **`/spec-from-audit`** — Generates agent-executable specs (repo file + Linear document), one per issue
|
||||
|
||||
Run them in sequence with review pauses between each step.
|
||||
|
||||
### Why Specs
|
||||
|
||||
- Prevents scope creep — writing "files NOT modified" forces focus
|
||||
- Survives session loss — API errors, context overflow, `/clear` won't destroy the plan
|
||||
- Enables parallel work — multiple agents can pick up chunks from the same plan
|
||||
- Creates audit trail — what was planned vs what was built
|
||||
- Survives session loss — specs live in the repo and Linear, not session memory
|
||||
- Enables parallel work — multiple agents can pick up specs from the same project
|
||||
- Creates audit trail — what was planned vs what was built (version-controlled in git)
|
||||
- Enables cross-tool review — Linear documents let non-GitHub users review specs
|
||||
|
||||
## Validation Commands
|
||||
|
||||
@@ -120,5 +120,6 @@ yarn lint && yarn types && yarn build
|
||||
## Workspace-Specific Instructions
|
||||
|
||||
- `app/AGENTS.md` — Mobile app development, E2E testing, deployment
|
||||
- `packages/webview-app/AGENTS.md` — WebView app development, Euclid screen migration, asset management
|
||||
- `packages/mobile-sdk-alpha/AGENTS.md` — SDK development, testing guidelines
|
||||
- `noir/AGENTS.md` — Noir circuit development
|
||||
|
||||
@@ -286,9 +286,19 @@ grep -r "require('react-native')" app/tests/
|
||||
|
||||
See `.cursor/rules/test-memory-optimization.mdc` for comprehensive guidelines, examples, and anti-patterns.
|
||||
|
||||
## Linear Issue Interaction
|
||||
|
||||
When working with Linear issues during development:
|
||||
|
||||
- **`save_comment`** for: status updates, progress notes, blockers, linking PRs, corrections, decision records
|
||||
- **`save_issue`** for: changing status, priority, assignee, labels (structured fields only)
|
||||
- **`create_document`** for: attaching specs as Linear documents
|
||||
|
||||
**Never overwrite an issue description.** Descriptions are the original scope set at creation time. All subsequent context goes in comments. If the description has a factual error, add a comment explaining the correction — do not silently rewrite it.
|
||||
|
||||
## SDK Architecture
|
||||
|
||||
The Self Wallet app serves as a **test environment** for the SDK refactor. For SDK architecture context:
|
||||
|
||||
- **[SDK Overview](../specs/projects/sdk/OVERVIEW.md)** — System architecture, bridge protocol, decision matrix
|
||||
- **[SDK Project Index](../specs/projects/sdk/INDEX.md)** — Workstream links and entry point
|
||||
- **[SDK Overview](../specs/projects/sdk/OVERVIEW.md)** — System architecture, bridge protocol, decision matrix (read-only reference)
|
||||
- **Implementation specs** — Canonical source is `specs/projects/sdk/workstreams/<scope>/plans/` (version-controlled). Linear documents attached to issues are mirrored copies for tracking/discovery. When in doubt, trust the repo spec.
|
||||
|
||||
@@ -42,7 +42,7 @@ allprojects {
|
||||
url("$rootDir/../../node_modules/jsc-android/dist")
|
||||
}
|
||||
maven { url 'https://jitpack.io' }
|
||||
maven { url "https://maven.sumsub.com/repository/maven-public/" }
|
||||
maven { url "https://raw.githubusercontent.com/didit-protocol/sdk-android/main/repository" }
|
||||
}
|
||||
configurations.configureEach {
|
||||
resolutionStrategy.dependencySubstitution {
|
||||
|
||||
@@ -10,4 +10,4 @@ IS_TEST_BUILD=
|
||||
MIXPANEL_NFC_PROJECT_TOKEN=
|
||||
SEGMENT_KEY=
|
||||
SENTRY_DSN=
|
||||
SUMSUB_TEE_URL=
|
||||
DIDIT_TEE_URL=
|
||||
|
||||
@@ -8,6 +8,9 @@ export const DEFAULT_DOE = undefined;
|
||||
|
||||
export const DEFAULT_PNUMBER = undefined;
|
||||
|
||||
export const DIDIT_TEE_URL =
|
||||
process.env.DIDIT_TEE_URL || 'http://localhost:8080';
|
||||
|
||||
export const ENABLE_DEBUG_LOGS = process.env.ENABLE_DEBUG_LOGS === 'true';
|
||||
|
||||
export const GOOGLE_SIGNIN_ANDROID_CLIENT_ID =
|
||||
@@ -18,22 +21,18 @@ export const GOOGLE_SIGNIN_IOS_CLIENT_ID =
|
||||
|
||||
export const GOOGLE_SIGNIN_WEB_CLIENT_ID =
|
||||
process.env.GOOGLE_SIGNIN_WEB_CLIENT_ID;
|
||||
|
||||
export const GRAFANA_LOKI_PASSWORD = process.env.GRAFANA_LOKI_PASSWORD;
|
||||
export const GRAFANA_LOKI_URL = process.env.GRAFANA_LOKI_URL;
|
||||
|
||||
export const GRAFANA_LOKI_USERNAME = process.env.GRAFANA_LOKI_USERNAME;
|
||||
|
||||
/* This file provides compatiblity between how web expects env variables to be and how native does.
|
||||
* on web it is aliased to @env on native it is not used
|
||||
*/
|
||||
export const IS_TEST_BUILD = process.env.IS_TEST_BUILD === 'true';
|
||||
|
||||
export const MIXPANEL_NFC_PROJECT_TOKEN = undefined;
|
||||
export const SEGMENT_KEY = process.env.SEGMENT_KEY;
|
||||
export const SENTRY_DSN = process.env.SENTRY_DSN;
|
||||
export const SUMSUB_TEE_URL =
|
||||
process.env.SUMSUB_TEE_URL || 'http://localhost:8080';
|
||||
export const SUMSUB_TEST_TOKEN = process.env.SUMSUB_TEST_TOKEN;
|
||||
|
||||
export const TURNKEY_AUTH_PROXY_CONFIG_ID =
|
||||
process.env.TURNKEY_AUTH_PROXY_CONFIG_ID;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import Foundation
|
||||
import React
|
||||
#if !E2E_TESTING
|
||||
import NFCPassportReader
|
||||
import SelfNFCPassportReader
|
||||
import Mixpanel
|
||||
#endif
|
||||
import Sentry
|
||||
@@ -19,11 +19,11 @@ import Sentry
|
||||
@available(iOS 15, *)
|
||||
@objc(PassportReader)
|
||||
class PassportReader: NSObject {
|
||||
private var passportReader: NFCPassportReader.PassportReader
|
||||
private var passportReader: SelfNFCPassportReader.PassportReader
|
||||
private var analytics: SelfAnalytics?
|
||||
|
||||
override init() {
|
||||
self.passportReader = NFCPassportReader.PassportReader()
|
||||
self.passportReader = SelfNFCPassportReader.PassportReader()
|
||||
super.init()
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class PassportReader: NSObject {
|
||||
func configure(token: String, enableDebugLogs: Bool) {
|
||||
let analytics = SelfAnalytics(token: token, enableDebugLogs: enableDebugLogs)
|
||||
self.analytics = analytics
|
||||
self.passportReader = NFCPassportReader.PassportReader(analytics: analytics)
|
||||
self.passportReader = SelfNFCPassportReader.PassportReader(analytics: analytics)
|
||||
}
|
||||
|
||||
@objc(trackEvent:properties:)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import Foundation
|
||||
import React
|
||||
#if !E2E_TESTING
|
||||
import NFCPassportReader
|
||||
import SelfNFCPassportReader
|
||||
import Security
|
||||
|
||||
@available(iOS 13, macOS 10.15, *)
|
||||
@@ -77,7 +77,7 @@ enum PassportReaderCore {
|
||||
}
|
||||
|
||||
static func scanPassport(
|
||||
reader: NFCPassportReader.PassportReader,
|
||||
reader: SelfNFCPassportReader.PassportReader,
|
||||
passportNumber: String,
|
||||
dateOfBirth: String,
|
||||
dateOfExpiry: String,
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
source "https://cdn.cocoapods.org/"
|
||||
|
||||
# Skip Sumsub configuration for E2E testing
|
||||
unless ENV["E2E_TESTING"] == "1"
|
||||
source "https://github.com/SumSubstance/Specs.git"
|
||||
|
||||
# Enable Fisherman (Device Intelligence) module for fraud detection
|
||||
# Privacy: Device ID collection declared in app/ios/PrivacyInfo.xcprivacy
|
||||
ENV["IDENSIC_WITH_FISHERMAN"] = "true"
|
||||
|
||||
# VideoIdent module disabled for current release
|
||||
# This feature provides liveness checks via live video calls with human agents
|
||||
# Disabled to avoid microphone permission requirements on both platforms
|
||||
# TODO: Re-enable for future release when liveness checks are needed
|
||||
# ENV["IDENSIC_WITH_VIDEOIDENT"] = "true"
|
||||
end
|
||||
|
||||
use_frameworks!
|
||||
require "tmpdir"
|
||||
|
||||
@@ -52,14 +37,6 @@ def simulator_arm64_audit_entries
|
||||
name: "FingerprintPro",
|
||||
path: "Pods/FingerprintPro/FingerprintPro.xcframework",
|
||||
},
|
||||
{
|
||||
name: "IdensicMobileSDK",
|
||||
path: "Pods/IdensicMobileSDK/IdensicMobileSDK.xcframework",
|
||||
},
|
||||
{
|
||||
name: "IdensicMobileSDK_Fisherman",
|
||||
path: "Pods/IdensicMobileSDK/IdensicMobileSDK_Fisherman.xcframework",
|
||||
},
|
||||
{
|
||||
name: "OpenSSL",
|
||||
path: "Pods/OpenSSL-Universal/Frameworks/OpenSSL.xcframework",
|
||||
@@ -106,14 +83,14 @@ target "Self" do
|
||||
use_expo_modules!
|
||||
# Native module exclusion for E2E testing is handled in react-native.config.cjs
|
||||
config = use_native_modules!
|
||||
# Skip NFCPassportReader for e2e testing to avoid build issues
|
||||
# Skip SelfNFCPassportReader for e2e testing to avoid build issues
|
||||
unless ENV["E2E_TESTING"] == "1"
|
||||
# Check if we're running in a selfxyz repo or an external fork
|
||||
is_selfxyz_repo = ENV["GITHUB_REPOSITORY"]&.start_with?("selfxyz/") || ENV["GITHUB_REPOSITORY"].nil?
|
||||
|
||||
if !is_selfxyz_repo
|
||||
# External fork - use public NFCPassportReader repository (placeholder)
|
||||
# TODO: Replace with actual public NFCPassportReader repository URL
|
||||
# External fork - use public SelfNFCPassportReader repository (placeholder)
|
||||
# TODO: Replace with actual public SelfNFCPassportReader repository URL
|
||||
nfc_repo_url = "https://github.com/PLACEHOLDER/NFCPassportReader.git"
|
||||
elsif ENV["GITHUB_ACTIONS"] == "true"
|
||||
# CI: NEVER embed credentials in URLs. Rely on workflow-provided auth via:
|
||||
@@ -127,15 +104,17 @@ target "Self" do
|
||||
nfc_repo_url = "git@github.com:selfxyz/NFCPassportReader.git"
|
||||
end
|
||||
|
||||
pod "NFCPassportReader", git: nfc_repo_url, commit: "9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b"
|
||||
pod "SelfNFCPassportReader", git: nfc_repo_url, commit: "2cdc50a5c27b75594b94b27fdc4bb6172ada0f96"
|
||||
end
|
||||
|
||||
# Explicitly declare Mixpanel to ensure it's available even in E2E builds
|
||||
# (NFCPassportReader also includes Mixpanel, but is skipped during E2E testing)
|
||||
# (SelfNFCPassportReader also includes Mixpanel, but is skipped during E2E testing)
|
||||
pod "Mixpanel-swift", "~> 5.0.0", :modular_headers => true
|
||||
|
||||
pod "QKMRZScanner"
|
||||
pod "lottie-ios"
|
||||
pod 'DiditSDK', :podspec => './local-pods/DiditSDK/DiditSDK.podspec'
|
||||
pod 'OpenSSL-Universal', '~> 1.1.1900'
|
||||
pod "SwiftQRScanner", :git => "https://github.com/vinodiOS/SwiftQRScanner"
|
||||
# RNReactNativeHapticFeedback is handled by autolinking
|
||||
|
||||
@@ -190,7 +169,7 @@ target "Self" do
|
||||
system(command)
|
||||
end
|
||||
|
||||
# Only strip OpenSSL bitcode if NFCPassportReader is included (not in e2e testing)
|
||||
# Only strip OpenSSL bitcode if SelfNFCPassportReader is included (not in e2e testing)
|
||||
unless ENV["E2E_TESTING"] == "1"
|
||||
framework_paths = [
|
||||
"Pods/OpenSSL-Universal/Frameworks/OpenSSL.xcframework/ios-arm64/OpenSSL.framework/OpenSSL",
|
||||
@@ -228,6 +207,7 @@ target "Self" do
|
||||
config.build_settings["GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS"] = "NO"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# update QKCutoutView.swift to hide OCR border
|
||||
@@ -254,6 +234,22 @@ target "Self" do
|
||||
end
|
||||
end
|
||||
|
||||
# Fix fmt consteval error on Xcode 26 — clang enables FMT_USE_CONSTEVAL but
|
||||
# has a bug evaluating consteval calls. Patch the detection block in base.h to
|
||||
# always resolve to 0. Safe on all Xcode versions (fmt falls back to constexpr).
|
||||
fmt_base_h = "Pods/fmt/include/fmt/base.h"
|
||||
if File.exist?(fmt_base_h)
|
||||
system("chmod u+w #{fmt_base_h}")
|
||||
text = File.read(fmt_base_h)
|
||||
unless text.include?("// Patched: force-disable FMT_USE_CONSTEVAL")
|
||||
patched = text.gsub(
|
||||
/^# define FMT_USE_CONSTEVAL 1.*$/,
|
||||
"# define FMT_USE_CONSTEVAL 0 // Patched: force-disable FMT_USE_CONSTEVAL"
|
||||
)
|
||||
File.write(fmt_base_h, patched) if patched != text
|
||||
end
|
||||
end
|
||||
|
||||
# Add E2E_TESTING compilation condition for main app target when environment variable is set
|
||||
if ENV["E2E_TESTING"] == "1"
|
||||
# Find Self.xcodeproj and add E2E_TESTING compilation condition
|
||||
|
||||
@@ -12,6 +12,8 @@ PODS:
|
||||
- boost (1.84.0)
|
||||
- BVLinearGradient (2.8.3):
|
||||
- React-Core
|
||||
- DiditSDK (3.2.6):
|
||||
- OpenSSL-Universal (~> 1.1.1900)
|
||||
- DoubleConversion (1.1.6)
|
||||
- EXApplication (6.0.2):
|
||||
- ExpoModulesCore
|
||||
@@ -57,7 +59,6 @@ PODS:
|
||||
- Yoga
|
||||
- fast_float (6.1.4)
|
||||
- FBLazyVector (0.77.0)
|
||||
- FingerprintPro (2.13.0)
|
||||
- Firebase/CoreOnly (11.11.0):
|
||||
- FirebaseCore (~> 11.11.0)
|
||||
- Firebase/Messaging (11.11.0):
|
||||
@@ -141,14 +142,6 @@ PODS:
|
||||
- hermes-engine (0.77.0):
|
||||
- hermes-engine/Pre-built (= 0.77.0)
|
||||
- hermes-engine/Pre-built (0.77.0)
|
||||
- IdensicMobileSDK (1.40.2):
|
||||
- IdensicMobileSDK/Default (= 1.40.2)
|
||||
- IdensicMobileSDK/Core (1.40.2)
|
||||
- IdensicMobileSDK/Default (1.40.2):
|
||||
- IdensicMobileSDK/Core
|
||||
- IdensicMobileSDK/Fisherman (1.40.2):
|
||||
- FingerprintPro (~> 2.11)
|
||||
- IdensicMobileSDK/Core
|
||||
- lottie-ios (4.5.0)
|
||||
- lottie-react-native (7.2.2):
|
||||
- DoubleConversion
|
||||
@@ -180,9 +173,6 @@ PODS:
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- NFCPassportReader (2.1.1):
|
||||
- Mixpanel-swift (~> 5.0.0)
|
||||
- OpenSSL-Universal (= 1.1.1900)
|
||||
- OpenSSL-Universal (1.1.1900)
|
||||
- PromisesObjC (2.4.0)
|
||||
- QKMRZParser (2.0.0)
|
||||
@@ -1461,10 +1451,6 @@ PODS:
|
||||
- Yoga
|
||||
- react-native-get-random-values (1.11.0):
|
||||
- React-Core
|
||||
- react-native-mobilesdk-module (1.40.2):
|
||||
- IdensicMobileSDK (= 1.40.2)
|
||||
- IdensicMobileSDK/Fisherman (= 1.40.2)
|
||||
- React-Core
|
||||
- react-native-netinfo (11.4.1):
|
||||
- React-Core
|
||||
- react-native-nfc-manager (3.17.2):
|
||||
@@ -2145,9 +2131,34 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- SdkReactNative (3.2.7):
|
||||
- DiditSDK (~> 3.2)
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
- RCT-Folly (= 2024.11.18.00)
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- segment-analytics-react-native (2.21.4):
|
||||
- React-Core
|
||||
- sovran-react-native
|
||||
- SelfNFCPassportReader (2.1.1):
|
||||
- Mixpanel-swift (~> 5.0.0)
|
||||
- OpenSSL-Universal (= 1.1.1900)
|
||||
- Sentry/HybridSDK (8.53.2)
|
||||
- SocketRocket (0.7.1)
|
||||
- sovran-react-native (1.1.3):
|
||||
@@ -2159,6 +2170,7 @@ PODS:
|
||||
DEPENDENCIES:
|
||||
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
|
||||
- BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
|
||||
- DiditSDK (from `./local-pods/DiditSDK/DiditSDK.podspec`)
|
||||
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
|
||||
- EXApplication (from `../node_modules/expo-application/ios`)
|
||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||
@@ -2177,7 +2189,7 @@ DEPENDENCIES:
|
||||
- lottie-ios
|
||||
- lottie-react-native (from `../node_modules/lottie-react-native`)
|
||||
- Mixpanel-swift (~> 5.0.0)
|
||||
- "NFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`, commit `9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b`)"
|
||||
- OpenSSL-Universal (~> 1.1.1900)
|
||||
- QKMRZScanner
|
||||
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
||||
- RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
||||
@@ -2216,7 +2228,6 @@ DEPENDENCIES:
|
||||
- react-native-cloud-storage (from `../node_modules/react-native-cloud-storage`)
|
||||
- "react-native-compat (from `../node_modules/@walletconnect/react-native-compat`)"
|
||||
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
|
||||
- "react-native-mobilesdk-module (from `../node_modules/@sumsub/react-native-mobilesdk-module`)"
|
||||
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
|
||||
- react-native-nfc-manager (from `../node_modules/react-native-nfc-manager`)
|
||||
- react-native-passkey (from `../node_modules/react-native-passkey`)
|
||||
@@ -2268,18 +2279,17 @@ DEPENDENCIES:
|
||||
- RNScreens (from `../node_modules/react-native-screens`)
|
||||
- "RNSentry (from `../node_modules/@sentry/react-native`)"
|
||||
- RNSVG (from `../node_modules/react-native-svg`)
|
||||
- "SdkReactNative (from `../node_modules/@didit-protocol/sdk-react-native`)"
|
||||
- "segment-analytics-react-native (from `../node_modules/@segment/analytics-react-native`)"
|
||||
- "SelfNFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`, commit `2cdc50a5c27b75594b94b27fdc4bb6172ada0f96`)"
|
||||
- "sovran-react-native (from `../node_modules/@segment/sovran-react-native`)"
|
||||
- SwiftQRScanner (from `https://github.com/vinodiOS/SwiftQRScanner`)
|
||||
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/SumSubstance/Specs.git:
|
||||
- IdensicMobileSDK
|
||||
trunk:
|
||||
- AppAuth
|
||||
- AppCheckCore
|
||||
- FingerprintPro
|
||||
- Firebase
|
||||
- FirebaseABTesting
|
||||
- FirebaseCore
|
||||
@@ -2311,6 +2321,8 @@ EXTERNAL SOURCES:
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
|
||||
BVLinearGradient:
|
||||
:path: "../node_modules/react-native-linear-gradient"
|
||||
DiditSDK:
|
||||
:podspec: "./local-pods/DiditSDK/DiditSDK.podspec"
|
||||
DoubleConversion:
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
|
||||
EXApplication:
|
||||
@@ -2344,9 +2356,6 @@ EXTERNAL SOURCES:
|
||||
:tag: hermes-2024-11-25-RNv0.77.0-d4f25d534ab744866448b36ca3bf3d97c08e638c
|
||||
lottie-react-native:
|
||||
:path: "../node_modules/lottie-react-native"
|
||||
NFCPassportReader:
|
||||
:commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b
|
||||
:git: "git@github.com:selfxyz/NFCPassportReader.git"
|
||||
RCT-Folly:
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
|
||||
RCTDeprecation:
|
||||
@@ -2417,8 +2426,6 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/@walletconnect/react-native-compat"
|
||||
react-native-get-random-values:
|
||||
:path: "../node_modules/react-native-get-random-values"
|
||||
react-native-mobilesdk-module:
|
||||
:path: "../node_modules/@sumsub/react-native-mobilesdk-module"
|
||||
react-native-netinfo:
|
||||
:path: "../node_modules/@react-native-community/netinfo"
|
||||
react-native-nfc-manager:
|
||||
@@ -2521,8 +2528,13 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/@sentry/react-native"
|
||||
RNSVG:
|
||||
:path: "../node_modules/react-native-svg"
|
||||
SdkReactNative:
|
||||
:path: "../node_modules/@didit-protocol/sdk-react-native"
|
||||
segment-analytics-react-native:
|
||||
:path: "../node_modules/@segment/analytics-react-native"
|
||||
SelfNFCPassportReader:
|
||||
:commit: 2cdc50a5c27b75594b94b27fdc4bb6172ada0f96
|
||||
:git: "git@github.com:selfxyz/NFCPassportReader.git"
|
||||
sovran-react-native:
|
||||
:path: "../node_modules/@segment/sovran-react-native"
|
||||
SwiftQRScanner:
|
||||
@@ -2531,8 +2543,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
CHECKOUT OPTIONS:
|
||||
NFCPassportReader:
|
||||
:commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b
|
||||
SelfNFCPassportReader:
|
||||
:commit: 2cdc50a5c27b75594b94b27fdc4bb6172ada0f96
|
||||
:git: "git@github.com:selfxyz/NFCPassportReader.git"
|
||||
SwiftQRScanner:
|
||||
:commit: c71ff91297640a944de4bca61434155c3f9b0979
|
||||
@@ -2542,20 +2554,20 @@ SPEC CHECKSUMS:
|
||||
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
|
||||
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
BVLinearGradient: 880f91a7854faff2df62518f0281afb1c60d49a3
|
||||
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
|
||||
DiditSDK: 3113b1aa1e5f67e84e84bd8449273257e5d9eff0
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
EXApplication: 27a524f5c3e671c6218220fba04752629466a1a9
|
||||
EXConstants: a1f35b9aabbb3c6791f8e67722579b1ffcdd3f18
|
||||
Expo: 03dca15247583ca0d09d3cee37fb2d5a0b878f04
|
||||
ExpoAdapterGoogleSignIn: 3332ac2d96d803350f53f84047244b35e2efc994
|
||||
ExpoAsset: 0687fe05f5d051c4a34dd1f9440bd00858413cfe
|
||||
ExpoFileSystem: c8c19bf80d914c83dda3beb8569d7fb603be0970
|
||||
ExpoFont: 773955186469acc5108ff569712a2d243857475f
|
||||
ExpoKeepAwake: 2a5f15dd4964cba8002c9a36676319a3394c85c7
|
||||
ExpoModulesCore: 0b8556860296c2ac2a0f393764c9ef78170a1558
|
||||
EXApplication: 4c72f6017a14a65e338c5e74fca418f35141e819
|
||||
EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b
|
||||
Expo: 4bb70893882e6382b41d1e910d7226c6a1b85f0a
|
||||
ExpoAdapterGoogleSignIn: ab4d9fc38cb91077a4138d178395525ec65d0c2e
|
||||
ExpoAsset: 48386d40d53a8c1738929b3ed509bcad595b5516
|
||||
ExpoFileSystem: 42d363d3b96f9afab980dcef60d5657a4443c655
|
||||
ExpoFont: f354e926f8feae5e831ec8087f36652b44a0b188
|
||||
ExpoKeepAwake: b0171a73665bfcefcfcc311742a72a956e6aa680
|
||||
ExpoModulesCore: bcee92d3a2c68c408b2d8da43e3094109340dc17
|
||||
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
||||
FBLazyVector: 2bc03a5cf64e29c611bbc5d7eb9d9f7431f37ee6
|
||||
FingerprintPro: 2f419138022451a72f783db9c94967f5a68e9977
|
||||
Firebase: 6a8f201c61eda24e98f1ce2b44b1b9c2caf525cc
|
||||
FirebaseABTesting: 8551c24eb28e300ce697f8eb72c1a519bb96eb40
|
||||
FirebaseCore: 2321536f9c423b1f857e047a82b8a42abc6d9e2c
|
||||
@@ -2574,12 +2586,10 @@ SPEC CHECKSUMS:
|
||||
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
hermes-engine: 1f783c3d53940aed0d2c84586f0b7a85ab7827ef
|
||||
IdensicMobileSDK: 00b13320e1b1e0574e68475bd0fbc7cd30fce26e
|
||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||
lottie-react-native: 57ffc63285c1e5dcad6743dca0d3e1ed34cac598
|
||||
lottie-react-native: 6cb05b7b4ea463afe657e3b46784f067858e1a5d
|
||||
Mixpanel-swift: e9bef28a9648faff384d5ba6f48ecc2787eb24c0
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
NFCPassportReader: 48873f856f91215dbfa1eaaec20eae639672862e
|
||||
OpenSSL-Universal: 84efb8a29841f2764ac5403e0c4119a28b713346
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
QKMRZParser: 6b419b6f07d6bff6b50429b97de10846dc902c29
|
||||
@@ -2590,95 +2600,96 @@ SPEC CHECKSUMS:
|
||||
RCTTypeSafety: 50d6ec72a3d13cf77e041ff43a0617050fb98e3f
|
||||
React: e46fdbd82d2de942970c106677056f3bdd438d82
|
||||
React-callinvoker: b027ad895934b5f27ce166d095ed0d272d7df619
|
||||
React-Core: 36b7f20f655d47a35046e2b02c9aa5a8f1bcb61e
|
||||
React-CoreModules: 7fac6030d37165c251a7bd4bde3333212544da3c
|
||||
React-cxxreact: 0ead442ecaa248e7f71719e286510676495ae26d
|
||||
React-Core: 92733c8280b1642afed7ebfb3c523feaec946ece
|
||||
React-CoreModules: e2dfd87b6fdb9d969b16871655885a4d89a2a9f4
|
||||
React-cxxreact: d1a70e78543bb5b159fdaf6c52cadd33c1ae3244
|
||||
React-debug: c17d400ddcb2c45aa4f5efedeb443c72b58b40aa
|
||||
React-defaultsnativemodule: d8ddce2020fede6b0a6d3cccc3fbb1fedf1aab37
|
||||
React-domnativemodule: 17da9148ba917807b9bab6c4e1fddbc11303be64
|
||||
React-Fabric: fda27452bab6f8b5213f33c1d59a24f6c6b66579
|
||||
React-FabricComponents: 10623f84dcb5ae9b2bbe98f577546b10fa459fdb
|
||||
React-FabricImage: 2237e1c2089eb4e55541485e173f96af43afca7d
|
||||
React-defaultsnativemodule: af13e4f2629106aede1d6286921f852715017d64
|
||||
React-domnativemodule: b6785fc507cfcbdf24509a0be26fdac7454f7ea3
|
||||
React-Fabric: 5f8c48a36ff906a0e8761ff914ef368f67a25b59
|
||||
React-FabricComponents: 2ba16205b15ce80460a1dcc3725b3926493b47f8
|
||||
React-FabricImage: d1b0c203284c0ab077277a54830e4de4c0134908
|
||||
React-featureflags: 94805545eda554c548e3615f248f4f4c65ef279e
|
||||
React-featureflagsnativemodule: b71dc56c26b09c5becaabc59d90eb6715a76d01e
|
||||
React-graphics: f81c5369a01264f5e5f2ab7b2e7fbe769c94ed42
|
||||
React-hermes: 13e1c1c9222503bcd7ad450370c5a26dc9b46ebe
|
||||
React-idlecallbacksnativemodule: 16c2ade55cf3537f7d6d1afb7acb230d65b1d63c
|
||||
React-ImageManager: 130248847aada2e9485db30cef63284ffc2f0846
|
||||
React-jserrorhandler: ef0948d6835b991094660d93cb7dcf3446d065f5
|
||||
React-jsi: 931610846e52e5d157f4bc3f71a14f9a53573abd
|
||||
React-jsiexecutor: 3f5fb21d47c5c72c13a1710b288d78c8209a38f9
|
||||
React-jsinspector: 231977808d975ea2ad045b910623651ef7219657
|
||||
React-jsitracing: 9b717dd9c91915ccf51af10df94e8c38de722786
|
||||
React-logger: 9a0c4e1e41cd640ac49d69aacadab783f7e0096b
|
||||
React-Mapbuffer: 257e617e7554c0ec448d13d38b13ee3cbdd3c5eb
|
||||
React-microtasksnativemodule: fa9db75d61e2053274057767ced1a2e2c485b0fa
|
||||
react-native-app-auth: 9b0a0e3ca279c3426a451e2607c8483808b8ed4a
|
||||
react-native-biometrics: 352e5a794bfffc46a0c86725ea7dc62deb085bdc
|
||||
react-native-blur: 6782cb12b39a0200ad2a782fb9a5529c2c83c33b
|
||||
react-native-cloud-storage: 8dc640aac2cf6e8a6231cc49696e8f8405b716bf
|
||||
react-native-compat: c6ac08d44535eb2b3735a2491318136dbdecf271
|
||||
react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06
|
||||
react-native-mobilesdk-module: 9c0b53eeea509a7ca1a6429632f2144a597ad824
|
||||
react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac
|
||||
react-native-nfc-manager: ef3b44c4f1975ab16d6109bb1671ab68068aba58
|
||||
react-native-passkey: 12d9c47c848f939b608e1c2d31197312be53f71e
|
||||
react-native-safe-area-context: 4a867695ce0b837b7fedc90c5629d4322be4222f
|
||||
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
|
||||
react-native-webview: de5205a97121427588aff27de2ddea4cc9fc0a19
|
||||
React-featureflagsnativemodule: 0ab7272372052fe9dc561dc2e4bbd4fd8ab11ea4
|
||||
React-graphics: 6800e73b337075ad0cb9226c1592ed1a91703244
|
||||
React-hermes: bf50c8272cb562300a54a621aa69dc12a0b4fcf2
|
||||
React-idlecallbacksnativemodule: 57d5b25440ed0478966710675354eac676508ff5
|
||||
React-ImageManager: fff4c0c50041d7b8f67d6f435e7a4b1e9125ad27
|
||||
React-jserrorhandler: 4abc5dfa7d5fb7bfba328faddfa97dc90441c276
|
||||
React-jsi: 19e77567e235d06b7e8f425d2a6c1e948ab286e9
|
||||
React-jsiexecutor: fe6ad8b9a2bf97e435fc1c969c80ed7f447ed68e
|
||||
React-jsinspector: 01aa56b6037c65a6ec4432a120aa74cc6fdf514f
|
||||
React-jsitracing: cb05a2c5c36eb212be028e26c38028f0d352c16b
|
||||
React-logger: 02e5802824aa9b15cb7df42e10a91abead83cd8d
|
||||
React-Mapbuffer: bbd3be71ef32e8198ac0f78b841662103e032ffe
|
||||
React-microtasksnativemodule: 8e65fc37744388153b9bca94552d04955d852058
|
||||
react-native-app-auth: e21c8ee920876b960e38c9381971bd189ebea06b
|
||||
react-native-biometrics: 43ed5b828646a7862dbc7945556446be00798e7d
|
||||
react-native-blur: 745703f35133ed6a1210d4bbff358a631911f002
|
||||
react-native-cloud-storage: 796c793dc354bb49f9df27ca25eed0f79a15549e
|
||||
react-native-compat: 10b5f906b469268eaceca83ea2393c177f1ce18a
|
||||
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
|
||||
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
|
||||
react-native-nfc-manager: c8891e460b4943b695d63f7f4effc6345bbefc83
|
||||
react-native-passkey: 8818f842d1b80e45c06e906a5c85964719782bf5
|
||||
react-native-safe-area-context: 5b5d3eb6ec9ef848f16c064a4eab4a92c7d7895e
|
||||
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
|
||||
react-native-webview: 05734d99f1e422c5ddfeefbd083d53abd78fccb1
|
||||
React-nativeconfig: 334c9961d74ddd3bc203afb92ee574ed01c7c755
|
||||
React-NativeModulesApple: e55f72e014482edd711542815a98b865ee6de9a1
|
||||
React-perflogger: 15a7bcb6c46eae8a981f7add8c9f4172e2372324
|
||||
React-performancetimeline: 9fb03db27775ddef6a98e3d22811acf210f07ba4
|
||||
React-NativeModulesApple: bf996c9e3b86e579e6e8635633b721c165a60b2c
|
||||
React-perflogger: 721172bda31a65ce7b7a0c3bf3de96f12ef6f45d
|
||||
React-performancetimeline: a23bfc89694e13ead855f25049bb9d60ce3704a2
|
||||
React-RCTActionSheet: 25eb72eabade4095bfaf6cd9c5c965c76865daa8
|
||||
React-RCTAnimation: 04c987fa858fa16169f543d29edb4140bd35afa9
|
||||
React-RCTAppDelegate: b2707904e4f8ad92fd052e62684bf0c3b88381cc
|
||||
React-RCTBlob: 1f214a7211632515805dd1f1b81fac70d12f812d
|
||||
React-RCTFabric: 0838a13e11c221d1d5648257b2ca31fede22874b
|
||||
React-RCTFBReactNativeSpec: 60d72b45a150ca35748b9a77028674b1e56a2e43
|
||||
React-RCTImage: e516d72739797fb7c1dac5c691f02a0f5445c290
|
||||
React-RCTLinking: 1e5554afe4f959696ad3285738c1510f2592f220
|
||||
React-RCTNetwork: 65e1e52c8614dcab342fa1eaec750ca818160e74
|
||||
React-RCTSettings: e86c204b481ef9264929fe00d1fdd04ce561748a
|
||||
React-RCTText: 15f14d6f9b75e64ffe749c75e30ff047cf0fa1be
|
||||
React-RCTVibration: 8d9078d5432972fe12d9f1526b38f504ad3d45cb
|
||||
React-RCTAnimation: 8efbd0a4a71fd3dbe84e6d08b92bec5728b7524b
|
||||
React-RCTAppDelegate: 8ff6da817adefd15d4e25ade53a477c344f9b213
|
||||
React-RCTBlob: 6056bd62a56a6d2dad55cdf195949db1de623e14
|
||||
React-RCTFabric: 113fe8b6532ac21a6a46700b2650b8d458020ee4
|
||||
React-RCTFBReactNativeSpec: 4214925b1c4829fb1e73bfbacb301244b522dc11
|
||||
React-RCTImage: 7b3f38c77e183bdcb43dbcd7b5842b96c814889a
|
||||
React-RCTLinking: 6cca74db71b23f670b72e45603e615c2b72b2235
|
||||
React-RCTNetwork: 5791b0718eff20c12f6f3d62e2ad50cff4b5c8a0
|
||||
React-RCTSettings: 84154e31a232b5b03b6b7a89924a267c431ccf16
|
||||
React-RCTText: cd49cb4442ee7f64b0415b27745d2495cb40cfaa
|
||||
React-RCTVibration: 2a7432e61d42f802716bd67edc793b5e5f58971a
|
||||
React-rendererconsistency: 9da9009da0eafdf005a77a260b1dbea274a90aa8
|
||||
React-rendererdebug: bb56856ce3901396c959ddcf0991f7a3a162f4c5
|
||||
React-rendererdebug: 4b9e70532888e08f41c5fcbcbc050e99a590839c
|
||||
React-rncore: d380e5c97ec669c0bd097612cd98247597a32679
|
||||
React-RuntimeApple: 559b3d8f068335e896224b8365fd8cee814e6652
|
||||
React-RuntimeCore: 87c25d97233f61b68bb254360e2724c01eb93198
|
||||
React-RuntimeApple: 0088247d510e7eb4a3a2ecc0964411266730d10d
|
||||
React-RuntimeCore: 0e45d29ad4057b029db38e92ab24d4294253c6e3
|
||||
React-runtimeexecutor: f9ae11481be048438640085c1e8266d6afebae44
|
||||
React-RuntimeHermes: 0cba4a2b329dcb8392754dd20a839709c7e3389f
|
||||
React-runtimescheduler: 62d73526c3471884a896328e11a930ea4b42dfe1
|
||||
React-RuntimeHermes: 4d6bbb8c4832794c34fc2a0301a885a9e8c936d5
|
||||
React-runtimescheduler: bb1282886aa8ba594ff5704c14ba19af1551149f
|
||||
React-timing: 9b94f0fb713587a697ce56b0fc7cb31cb5be70a5
|
||||
React-utils: 9e73840482020d1914b68089e807b3f2f56b10a3
|
||||
ReactAppDependencyProvider: 3d947e9d62f351c06c71497e1be897e6006dc303
|
||||
ReactCodegen: e92a1659b32705bd8ee0d2ba016d6993a4ade05b
|
||||
ReactCommon: a02340b2a1a76f3703298a4680bb03277ca87440
|
||||
RNAppleAuthentication: d6fe579e5f43cf8db54bdc48518bccea61c592a4
|
||||
RNCAsyncStorage: 481acf401089f312189e100815088ea5dafc583c
|
||||
RNCClipboard: 4d8c76e488f1491e5235901b7028ff53a678bd94
|
||||
RNDeviceInfo: 53f9c84e28e854a308d3e548e25ef120b4615531
|
||||
RNFBApp: b67ded6e4b0a6c0fee5e4f8e75e3a31567949a08
|
||||
RNFBMessaging: 7202ad4c49bdcdc7fd3634d85827a6305049c148
|
||||
RNFBRemoteConfig: d3b7942bc4a2e16e162b841c0df2a869015d98c9
|
||||
RNGestureHandler: 75a1894590b15c560094c2b09c5dce6a64eefa29
|
||||
RNGoogleSignin: bd5e55072fc89c69e3eb139be2a9c8935d0a0f2c
|
||||
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
|
||||
RNKeychain: 774184659ed098fd715a4976d44e2003c829934f
|
||||
RNLocalize: 37ca6516cd717a04a8d85a17f9a3879a728ce179
|
||||
RNReactNativeHapticFeedback: a49e613d48d721c99cad9689a490554104c22154
|
||||
RNScreens: cc97e4382039563c725394067185356352df69ad
|
||||
RNSentry: f343c58d33eb8351a5b5cfbb157d3527e2f59645
|
||||
RNSVG: 2b1b9e597b2a0847e2963aefe17d976d5c882f3f
|
||||
segment-analytics-react-native: 05c3bf2adb8a3be2c273808a6fdaced06d927917
|
||||
React-utils: 07c3365e9dcbb8940e912ce099b20fb0e56dbacf
|
||||
ReactAppDependencyProvider: 6e8d68583f39dc31ee65235110287277eb8556ef
|
||||
ReactCodegen: 58a974a1a86362975fd49596480c5f0f17ee06a2
|
||||
ReactCommon: e686c5766f0ebe5293be5a3957b833645cdac8ad
|
||||
RNAppleAuthentication: a89c9804592b38ed4ab11f0aee68d05ba12ad432
|
||||
RNCAsyncStorage: 6a8127b6987dc9fbce778669b252b14c8355c7ce
|
||||
RNCClipboard: 9f7b908de4bf4353871fb454c15fc03db4917b88
|
||||
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
|
||||
RNFBApp: 4105e54d9ca4a1c10893a032268470f670181110
|
||||
RNFBMessaging: 6857871d9dff8f26b0c325fc7d97ba69cb77d213
|
||||
RNFBRemoteConfig: 8d3675f18c052483ce294bb97b857428467fb41e
|
||||
RNGestureHandler: 36aca36e4ef19f55dbf97239199d00fd58494e34
|
||||
RNGoogleSignin: 60c3f470558dbff0ae54f2f164ef82a89d3eb561
|
||||
RNInAppBrowser: 6d3eb68d471b9834335c664704719b8be1bfdb20
|
||||
RNKeychain: 35beaa17938f7d8e4990d8a38fad5f8a748fc47c
|
||||
RNLocalize: 67cd0eece3ba20fb5dae7625d77f02e88d3d9573
|
||||
RNReactNativeHapticFeedback: eb5395b503c7a8f10de5e6722ef8afd3c61bc4f5
|
||||
RNScreens: b0811b109e1a0b8b579f3348018e177bee374840
|
||||
RNSentry: 98ab9f6a16c9596e36565ccf1a5871323f334766
|
||||
RNSVG: d926926b169d8b81eb06aeb69734076e1dd566a3
|
||||
SdkReactNative: 7f65ca10a978bf9440730a537d54511e68ed50b4
|
||||
segment-analytics-react-native: 0eae155b0e9fa560fa6b17d78941df64537c35b7
|
||||
SelfNFCPassportReader: 8b53f9d483e0dd1f1a275953e3dc6dfc733694c5
|
||||
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
|
||||
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
||||
sovran-react-native: eec37f82e4429f0e3661f46aaf4fcd85d1b54f60
|
||||
sovran-react-native: a3ad3f8ff90c2002b2aa9790001a78b0b0a38594
|
||||
SwiftQRScanner: e85a25f9b843e9231dab89a96e441472fe54a724
|
||||
SwiftyTesseract: 1f3d96668ae92dc2208d9842c8a59bea9fad2cbb
|
||||
Yoga: c34725819ab0a5962e85455b9e56679b306910ee
|
||||
|
||||
PODFILE CHECKSUM: b55b83b47bd348c6768b89bdab74c5d2e9e09320
|
||||
PODFILE CHECKSUM: 0b99fae2ec87b0be3e6d9b3fd87360fe0a84b25f
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
|
||||
#if !E2E_TESTING
|
||||
import Mixpanel
|
||||
import NFCPassportReader
|
||||
import SelfNFCPassportReader
|
||||
|
||||
public class SelfAnalytics: Analytics {
|
||||
private let enableDebugLogs: Bool
|
||||
|
||||
31
app/ios/local-pods/DiditSDK/DiditSDK.podspec
Normal file
31
app/ios/local-pods/DiditSDK/DiditSDK.podspec
Normal file
@@ -0,0 +1,31 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'DiditSDK'
|
||||
s.version = '3.2.6'
|
||||
s.summary = 'Didit Identity Verification SDK for iOS'
|
||||
s.description = <<-DESC
|
||||
The Didit SDK provides a complete identity verification solution including
|
||||
document scanning, NFC passport reading, face verification, and more.
|
||||
This local podspec excludes the vendored OpenSSL.xcframework to avoid
|
||||
conflicts with OpenSSL-Universal (provided by NFCPassportReader).
|
||||
DESC
|
||||
|
||||
s.homepage = 'https://github.com/didit-protocol/sdk-ios'
|
||||
s.license = { :type => 'MIT', :file => 'LICENSE' }
|
||||
s.author = { 'Didit' => 'support@didit.me' }
|
||||
s.source = {
|
||||
:http => 'https://github.com/didit-protocol/sdk-ios/releases/download/3.2.6/DiditSDK-CocoaPods.zip'
|
||||
}
|
||||
|
||||
s.ios.deployment_target = '13.0'
|
||||
s.swift_version = '5.0'
|
||||
|
||||
# Remove the vendored OpenSSL.xcframework after download to avoid conflicts
|
||||
# with OpenSSL-Universal (provided by NFCPassportReader)
|
||||
s.prepare_command = 'rm -rf OpenSSL.xcframework'
|
||||
|
||||
s.vendored_frameworks = 'DiditSDK.xcframework'
|
||||
|
||||
s.dependency 'OpenSSL-Universal', '~> 1.1.1900'
|
||||
|
||||
s.frameworks = 'UIKit', 'SwiftUI', 'AVFoundation', 'CoreNFC', 'CoreLocation'
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'OpenSSL-Universal'
|
||||
s.version = '1.1.1900'
|
||||
s.summary = 'Stub: OpenSSL is provided by DiditSDK vendored xcframework'
|
||||
s.homepage = 'https://github.com/nicklama/openssl-universal'
|
||||
s.license = { :type => 'Dual OpenSSL/SSLeay', :text => 'See OpenSSL license' }
|
||||
s.author = 'stub'
|
||||
s.source = { :git => '', :tag => s.version.to_s }
|
||||
s.ios.deployment_target = '13.0'
|
||||
s.preserve_paths = 'README.md'
|
||||
|
||||
# Point consumers to DiditSDK's vendored OpenSSL.xcframework so `import OpenSSL` resolves.
|
||||
s.pod_target_xcconfig = {
|
||||
'FRAMEWORK_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/DiditSDK"'
|
||||
}
|
||||
s.user_target_xcconfig = {
|
||||
'FRAMEWORK_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/DiditSDK"'
|
||||
}
|
||||
end
|
||||
@@ -16,7 +16,7 @@ module.exports = {
|
||||
'node',
|
||||
],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(react-native|@react-native|@react-navigation|@react-native-community|@segment/analytics-react-native|@openpassport|react-native-keychain|react-native-check-version|react-native-nfc-manager|react-native-passport-reader|react-native-gesture-handler|uuid|@stablelib|@react-native-google-signin|react-native-cloud-storage|@react-native-clipboard|@react-native-firebase|@selfxyz|@sentry|@anon-aadhaar|react-native-svg|react-native-svg-circle-country-flags|react-native-blur-effect|@sumsub)/)',
|
||||
'node_modules/(?!(react-native|@react-native|@react-navigation|@react-native-community|@segment/analytics-react-native|@openpassport|react-native-keychain|react-native-check-version|react-native-nfc-manager|react-native-passport-reader|react-native-gesture-handler|uuid|@stablelib|@react-native-google-signin|react-native-cloud-storage|@react-native-clipboard|@react-native-firebase|@selfxyz|@sentry|@anon-aadhaar|react-native-svg|react-native-svg-circle-country-flags|react-native-blur-effect|@didit-protocol)/)',
|
||||
],
|
||||
setupFiles: ['<rootDir>/jest.setup.js'],
|
||||
testMatch: [
|
||||
|
||||
@@ -1284,28 +1284,15 @@ jest.mock('react-native/Libraries/AppState/AppState', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @sumsub/react-native-mobilesdk-module
|
||||
jest.mock('@sumsub/react-native-mobilesdk-module', () => {
|
||||
const createBuilder = () => ({
|
||||
withHandlers: jest.fn().mockReturnThis(),
|
||||
withDebug: jest.fn().mockReturnThis(),
|
||||
withLocale: jest.fn().mockReturnThis(),
|
||||
withAnalyticsEnabled: jest.fn().mockReturnThis(),
|
||||
build: jest.fn().mockReturnValue({
|
||||
launch: jest.fn().mockResolvedValue({ success: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
const MockSNSMobileSDK = {
|
||||
init: jest
|
||||
.fn()
|
||||
.mockImplementation((accessToken, tokenExpirationHandler) =>
|
||||
createBuilder(),
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockSNSMobileSDK,
|
||||
};
|
||||
});
|
||||
// Mock @didit-protocol/sdk-react-native
|
||||
jest.mock('@didit-protocol/sdk-react-native', () => ({
|
||||
__esModule: true,
|
||||
startVerification: jest.fn().mockResolvedValue({
|
||||
type: 'completed',
|
||||
session: { status: 'approved', sessionId: 'mock-session-id' },
|
||||
}),
|
||||
startVerificationWithWorkflow: jest.fn().mockResolvedValue({
|
||||
type: 'completed',
|
||||
session: { status: 'approved', sessionId: 'mock-session-id' },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"@didit-protocol/sdk-react-native": "^3.2.7",
|
||||
"@ethersproject/shims": "^5.8.0",
|
||||
"@invertase/react-native-apple-authentication": "^2.5.1",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
@@ -110,7 +111,6 @@
|
||||
"@sentry/react": "^9.32.0",
|
||||
"@sentry/react-native": "7.0.0",
|
||||
"@stablelib/cbor": "^2.0.1",
|
||||
"@sumsub/react-native-mobilesdk-module": "1.40.2",
|
||||
"@tamagui/animations-css": "1.126.14",
|
||||
"@tamagui/animations-react-native": "1.126.14",
|
||||
"@tamagui/config": "1.126.14",
|
||||
|
||||
@@ -6,9 +6,9 @@ const dependencies = {
|
||||
'@selfxyz/mobile-sdk-alpha': { platforms: { android: null, ios: null } },
|
||||
};
|
||||
|
||||
// Disable Sumsub SDK autolinking during E2E testing to avoid build issues
|
||||
// Disable Didit SDK autolinking during E2E testing to avoid build issues
|
||||
if (process.env.E2E_TESTING === '1') {
|
||||
dependencies['@sumsub/react-native-mobilesdk-module'] = {
|
||||
dependencies['@didit-protocol/sdk-react-native'] = {
|
||||
platforms: { android: null, ios: null },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ interface KycIdCardProps {
|
||||
|
||||
/**
|
||||
* Maps KYC idType to display title.
|
||||
* idType values from Sumsub: "drivers_licence", "passport", "NATIONAL ID", etc.
|
||||
* idType values: "drivers_licence", "passport", "NATIONAL ID", etc.
|
||||
*/
|
||||
function getKycDocTitle(idType: string): string {
|
||||
const normalized = idType
|
||||
@@ -45,7 +45,7 @@ function getKycDocTitle(idType: string): string {
|
||||
|
||||
/**
|
||||
* KYC document card - matches IdCard design exactly but shows "STANDARD" badge.
|
||||
* Used for documents verified through Sumsub KYC flow (drivers license, etc.).
|
||||
* Used for documents verified through KYC flow (drivers license, etc.).
|
||||
*/
|
||||
const KycIdCard: FC<KycIdCardProps> = ({
|
||||
idDocument,
|
||||
|
||||
@@ -8,27 +8,30 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import { sanitizeErrorMessage } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { fetchAccessToken, launchSumsub } from '@/integrations/sumsub';
|
||||
import type { SumsubResult } from '@/integrations/sumsub/types';
|
||||
import { createSession, launchDidit } from '@/integrations/didit';
|
||||
import type { DiditVerificationResult } from '@/integrations/didit/types';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
|
||||
export type FallbackErrorSource = 'mrz_scan_failed' | 'nfc_scan_failed';
|
||||
|
||||
export interface UseSumsubLauncherOptions {
|
||||
export interface UseDiditLauncherOptions {
|
||||
/**
|
||||
* Country code for the user's document
|
||||
*/
|
||||
countryCode: string;
|
||||
/**
|
||||
* Error source to track where the Sumsub launch was initiated from
|
||||
* Error source to track where the Didit launch was initiated from
|
||||
*/
|
||||
errorSource: FallbackErrorSource;
|
||||
/**
|
||||
* Optional callback to handle successful verification.
|
||||
* Receives the Sumsub result and the userId from the access token.
|
||||
* If not provided, defaults to navigating to KycSuccess with the userId.
|
||||
* Receives the Didit result and the sessionId from the session.
|
||||
* If not provided, defaults to navigating to KycSuccess with the sessionId.
|
||||
*/
|
||||
onSuccess?: (result: SumsubResult, userId: string) => void | Promise<void>;
|
||||
onSuccess?: (
|
||||
result: DiditVerificationResult,
|
||||
sessionId: string,
|
||||
) => void | Promise<void>;
|
||||
/**
|
||||
* Optional callback to handle user cancellation
|
||||
*/
|
||||
@@ -36,53 +39,57 @@ export interface UseSumsubLauncherOptions {
|
||||
/**
|
||||
* Optional callback to handle verification failure
|
||||
*/
|
||||
onError?: (error: unknown, result?: SumsubResult) => void | Promise<void>;
|
||||
onError?: (
|
||||
error: unknown,
|
||||
result?: DiditVerificationResult,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for launching Sumsub verification with consistent error handling.
|
||||
* Custom hook for launching Didit verification with consistent error handling.
|
||||
*
|
||||
* Abstracts the common pattern of:
|
||||
* 1. Fetching access token
|
||||
* 2. Launching Sumsub SDK
|
||||
* 1. Creating a session
|
||||
* 2. Launching Didit SDK
|
||||
* 3. Handling errors by navigating to fallback screen
|
||||
* 4. Managing loading state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { launchSumsubVerification, isLoading } = useSumsubLauncher({
|
||||
* const { launchDiditVerification, isLoading } = useDiditLauncher({
|
||||
* countryCode: 'US',
|
||||
* errorSource: 'nfc_scan_failed',
|
||||
* });
|
||||
*
|
||||
* <Button onPress={launchSumsubVerification} disabled={isLoading}>
|
||||
* <Button onPress={launchDiditVerification} disabled={isLoading}>
|
||||
* {isLoading ? 'Loading...' : 'Try Alternative Verification'}
|
||||
* </Button>
|
||||
* ```
|
||||
*/
|
||||
export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
|
||||
export const useDiditLauncher = (options: UseDiditLauncherOptions) => {
|
||||
const { countryCode, errorSource, onSuccess, onCancel, onError } = options;
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const launchSumsubVerification = useCallback(async () => {
|
||||
const launchDiditVerification = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const accessToken = await fetchAccessToken();
|
||||
const result = await launchSumsub({ accessToken: accessToken.token });
|
||||
const session = await createSession();
|
||||
const result = await launchDidit(session.sessionToken);
|
||||
|
||||
// Handle user cancellation
|
||||
if (!result.success && result.status === 'Interrupted') {
|
||||
if (result.type === 'cancelled') {
|
||||
await onCancel?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle verification failure
|
||||
if (!result.success) {
|
||||
const error = result.errorMsg || result.errorType || 'Unknown error';
|
||||
if (result.type === 'failed') {
|
||||
const error =
|
||||
result.error?.message || result.error?.type || 'Unknown error';
|
||||
const safeError = sanitizeErrorMessage(error);
|
||||
console.error('Sumsub verification failed:', safeError);
|
||||
console.error('Didit verification failed:', safeError);
|
||||
|
||||
// Call custom error handler if provided, otherwise navigate to fallback screen
|
||||
if (onError) {
|
||||
@@ -100,9 +107,9 @@ export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
|
||||
|
||||
// Handle success - navigate to KycSuccess by default
|
||||
if (onSuccess) {
|
||||
await onSuccess(result, accessToken.userId);
|
||||
await onSuccess(result, session.sessionId);
|
||||
} else {
|
||||
navigation.navigate('KycSuccess', { userId: accessToken.userId });
|
||||
navigation.navigate('KycSuccess', { sessionId: session.sessionId });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
@@ -127,7 +134,7 @@ export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
|
||||
}, [navigation, countryCode, errorSource, onSuccess, onCancel, onError]);
|
||||
|
||||
return {
|
||||
launchSumsubVerification,
|
||||
launchDiditVerification,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
@@ -4,17 +4,17 @@
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { SUMSUB_TEE_URL } from '@env';
|
||||
import { DIDIT_TEE_URL } from '@env';
|
||||
|
||||
import { deserializeApplicantInfo } from '@selfxyz/common';
|
||||
import type { DocumentType, KycData } from '@selfxyz/common/utils/types';
|
||||
|
||||
import type { SumsubApplicantInfoSerialized } from '@/integrations/sumsub/types';
|
||||
import type { ApplicantInfoSerialized } from '@/integrations/didit/types';
|
||||
import { navigationRef } from '@/navigation';
|
||||
import { storeDocumentWithDeduplication } from '@/providers/passportDataProvider';
|
||||
import { usePendingKycStore } from '@/stores/pendingKycStore';
|
||||
|
||||
interface UseSumsubWebSocketOptions {
|
||||
interface UseDiditWebSocketOptions {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onVerificationFailed?: (reason: string) => void;
|
||||
@@ -22,11 +22,11 @@ interface UseSumsubWebSocketOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hook for Sumsub websocket subscription logic.
|
||||
* Handles connecting to the TEE service, subscribing to a userId,
|
||||
* Shared hook for Didit websocket subscription logic.
|
||||
* Handles connecting to the TEE service, subscribing to a sessionId,
|
||||
* and processing verification results.
|
||||
*/
|
||||
export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
|
||||
export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
|
||||
const {
|
||||
onSuccess,
|
||||
onError,
|
||||
@@ -45,55 +45,58 @@ export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
|
||||
);
|
||||
|
||||
const socketsRef = useRef<Map<string, Socket>>(new Map());
|
||||
const subscribedUserIdsRef = useRef<Set<string>>(new Set());
|
||||
const subscribedSessionIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const subscribe = useCallback(
|
||||
(userId: string) => {
|
||||
if (subscribedUserIdsRef.current.has(userId)) {
|
||||
console.log('[SumsubWebSocket] Already subscribed to userId:', userId);
|
||||
(sessionId: string) => {
|
||||
if (subscribedSessionIdsRef.current.has(sessionId)) {
|
||||
console.log(
|
||||
'[DiditWebSocket] Already subscribed to sessionId:',
|
||||
sessionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingVerification = getPendingVerification(userId);
|
||||
const existingVerification = getPendingVerification(sessionId);
|
||||
const isProcessing = existingVerification?.status === 'processing';
|
||||
|
||||
// Don't retry 'processing' verifications as the proving machine is reading to be triggered.
|
||||
if (isProcessing) {
|
||||
console.log(
|
||||
'[SumsubWebSocket] Verification in processing state, skipping for userId:',
|
||||
userId,
|
||||
'[DiditWebSocket] Verification in processing state, skipping for sessionId:',
|
||||
sessionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skipAddPending) {
|
||||
console.log(
|
||||
'[SumsubWebSocket] Adding pending verification for userId:',
|
||||
userId,
|
||||
'[DiditWebSocket] Adding pending verification for sessionId:',
|
||||
sessionId,
|
||||
);
|
||||
addPendingVerification(userId);
|
||||
addPendingVerification(sessionId);
|
||||
}
|
||||
subscribedUserIdsRef.current.add(userId);
|
||||
subscribedSessionIdsRef.current.add(sessionId);
|
||||
|
||||
console.log('[SumsubWebSocket] Connecting to WebSocket:', SUMSUB_TEE_URL);
|
||||
const socket = io(SUMSUB_TEE_URL, {
|
||||
console.log('[DiditWebSocket] Connecting to WebSocket:', DIDIT_TEE_URL);
|
||||
const socket = io(DIDIT_TEE_URL, {
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
socketsRef.current.set(userId, socket);
|
||||
socketsRef.current.set(sessionId, socket);
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log(
|
||||
'[SumsubWebSocket] Connected, subscribing to user:',
|
||||
userId,
|
||||
'[DiditWebSocket] Connected, subscribing to user:',
|
||||
sessionId,
|
||||
);
|
||||
socket.emit('subscribe', userId);
|
||||
socket.emit('subscribe', sessionId);
|
||||
});
|
||||
|
||||
socket.on('success', async (data: SumsubApplicantInfoSerialized) => {
|
||||
socket.on('success', async (data: ApplicantInfoSerialized) => {
|
||||
console.log(
|
||||
'[SumsubWebSocket] Received applicant info for userId:',
|
||||
userId,
|
||||
'[DiditWebSocket] Received applicant info for sessionId:',
|
||||
sessionId,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -110,21 +113,27 @@ export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
|
||||
};
|
||||
const documentId = await storeDocumentWithDeduplication(kycData);
|
||||
console.log(
|
||||
'[SumsubWebSocket] KYC data stored successfully, documentId:',
|
||||
'[DiditWebSocket] KYC data stored successfully, documentId:',
|
||||
documentId,
|
||||
);
|
||||
|
||||
updateVerificationStatus(userId, 'processing', undefined, documentId);
|
||||
updateVerificationStatus(
|
||||
sessionId,
|
||||
'processing',
|
||||
undefined,
|
||||
documentId,
|
||||
);
|
||||
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('KYCVerified', { documentId });
|
||||
}
|
||||
|
||||
socket.emit('ack_success', sessionId);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
console.error('[SumsubWebSocket] Failed to store KYC data:', err);
|
||||
console.error('[DiditWebSocket] Failed to store KYC data:', err);
|
||||
updateVerificationStatus(
|
||||
userId,
|
||||
sessionId,
|
||||
'failed',
|
||||
'Failed to store KYC data',
|
||||
);
|
||||
@@ -132,32 +141,32 @@ export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
|
||||
}
|
||||
|
||||
socket.disconnect();
|
||||
socketsRef.current.delete(userId);
|
||||
subscribedUserIdsRef.current.delete(userId);
|
||||
socketsRef.current.delete(sessionId);
|
||||
subscribedSessionIdsRef.current.delete(sessionId);
|
||||
});
|
||||
|
||||
socket.on('verification_failed', (reason: string) => {
|
||||
console.log('[SumsubWebSocket] Verification failed:', reason);
|
||||
updateVerificationStatus(userId, 'failed', reason);
|
||||
console.log('[DiditWebSocket] Verification failed:', reason);
|
||||
updateVerificationStatus(sessionId, 'failed', reason);
|
||||
onVerificationFailed?.(reason);
|
||||
|
||||
socket.disconnect();
|
||||
socketsRef.current.delete(userId);
|
||||
subscribedUserIdsRef.current.delete(userId);
|
||||
socketsRef.current.delete(sessionId);
|
||||
subscribedSessionIdsRef.current.delete(sessionId);
|
||||
});
|
||||
|
||||
socket.on('error', (errorMessage: string) => {
|
||||
console.error('[SumsubWebSocket] Socket error:', errorMessage);
|
||||
updateVerificationStatus(userId, 'failed', errorMessage);
|
||||
console.error('[DiditWebSocket] Socket error:', errorMessage);
|
||||
updateVerificationStatus(sessionId, 'failed', errorMessage);
|
||||
onError?.(errorMessage);
|
||||
|
||||
socket.disconnect();
|
||||
socketsRef.current.delete(userId);
|
||||
subscribedUserIdsRef.current.delete(userId);
|
||||
socketsRef.current.delete(sessionId);
|
||||
subscribedSessionIdsRef.current.delete(sessionId);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[SumsubWebSocket] Disconnected for userId:', userId);
|
||||
console.log('[DiditWebSocket] Disconnected for sessionId:', sessionId);
|
||||
});
|
||||
},
|
||||
[
|
||||
@@ -171,13 +180,13 @@ export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
|
||||
],
|
||||
);
|
||||
|
||||
const unsubscribe = useCallback((userId: string) => {
|
||||
const socket = socketsRef.current.get(userId);
|
||||
const unsubscribe = useCallback((sessionId: string) => {
|
||||
const socket = socketsRef.current.get(sessionId);
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socketsRef.current.delete(userId);
|
||||
socketsRef.current.delete(sessionId);
|
||||
}
|
||||
subscribedUserIdsRef.current.delete(userId);
|
||||
subscribedSessionIdsRef.current.delete(sessionId);
|
||||
}, []);
|
||||
|
||||
const unsubscribeAll = useCallback(() => {
|
||||
@@ -185,11 +194,11 @@ export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
|
||||
socket.disconnect();
|
||||
});
|
||||
socketsRef.current.clear();
|
||||
subscribedUserIdsRef.current.clear();
|
||||
subscribedSessionIdsRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const isSubscribed = useCallback((userId: string) => {
|
||||
return subscribedUserIdsRef.current.has(userId);
|
||||
const isSubscribed = useCallback((sessionId: string) => {
|
||||
return subscribedSessionIdsRef.current.has(sessionId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useSumsubWebSocket } from '@/hooks/useSumsubWebSocket';
|
||||
import { useDiditWebSocket } from '@/hooks/useDiditWebSocket';
|
||||
import { navigationRef } from '@/navigation';
|
||||
import { usePendingKycStore } from '@/stores/pendingKycStore';
|
||||
|
||||
@@ -14,9 +14,9 @@ import { usePendingKycStore } from '@/stores/pendingKycStore';
|
||||
* This hook runs on app startup and:
|
||||
* 1. Checks for any pending verifications in the store
|
||||
* 2. For each non-expired pending/processing verification, reconnects to websocket
|
||||
* 3. Subscribes to the userId to receive any cached results
|
||||
* 3. Subscribes to the sessionId to receive any cached results
|
||||
* 4. Updates verification status based on server response
|
||||
* 5. Initiates proving machine after document storage (handled in useSumsubWebSocket)
|
||||
* 5. Initiates proving machine after document storage (handled in useDiditWebSocket)
|
||||
*
|
||||
* NOTE: This requires the TEE server to cache completed verification results
|
||||
* so they can be retrieved when the app reopens.
|
||||
@@ -39,7 +39,7 @@ export function usePendingKycRecovery() {
|
||||
console.log('[PendingKycRecovery] Verification failed:', reason);
|
||||
}, []);
|
||||
|
||||
const { subscribe, unsubscribeAll } = useSumsubWebSocket({
|
||||
const { subscribe, unsubscribeAll } = useDiditWebSocket({
|
||||
skipAddPending: true,
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
@@ -56,7 +56,7 @@ export function usePendingKycRecovery() {
|
||||
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'[PendingKycRecovery] Already attempted userIds:',
|
||||
'[PendingKycRecovery] Already attempted sessionIds:',
|
||||
Array.from(hasAttemptedRecoveryRef.current),
|
||||
);
|
||||
|
||||
@@ -65,39 +65,39 @@ export function usePendingKycRecovery() {
|
||||
v.status === 'processing' &&
|
||||
v.documentId &&
|
||||
v.timeoutAt > Date.now() &&
|
||||
!hasAttemptedRecoveryRef.current.has(v.userId),
|
||||
!hasAttemptedRecoveryRef.current.has(v.sessionId),
|
||||
);
|
||||
|
||||
if (processingWithDocument) {
|
||||
console.log(
|
||||
'[PendingKycRecovery] Resuming processing verification, navigating to KYCVerified:',
|
||||
processingWithDocument.userId,
|
||||
processingWithDocument.sessionId,
|
||||
);
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('KYCVerified', {
|
||||
documentId: processingWithDocument.documentId,
|
||||
});
|
||||
// Only mark as attempted after successful navigation
|
||||
hasAttemptedRecoveryRef.current.add(processingWithDocument.userId);
|
||||
hasAttemptedRecoveryRef.current.add(processingWithDocument.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation not ready yet - poll until ready
|
||||
console.log(
|
||||
'[PendingKycRecovery] Navigation not ready, polling for readiness:',
|
||||
processingWithDocument.userId,
|
||||
processingWithDocument.sessionId,
|
||||
);
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
if (navigationRef.isReady()) {
|
||||
console.log(
|
||||
'[PendingKycRecovery] Navigation ready, navigating for:',
|
||||
processingWithDocument.userId,
|
||||
processingWithDocument.sessionId,
|
||||
);
|
||||
navigationRef.navigate('KYCVerified', {
|
||||
documentId: processingWithDocument.documentId,
|
||||
});
|
||||
hasAttemptedRecoveryRef.current.add(processingWithDocument.userId);
|
||||
hasAttemptedRecoveryRef.current.add(processingWithDocument.sessionId);
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 100); // Poll every 100ms
|
||||
@@ -112,16 +112,16 @@ export function usePendingKycRecovery() {
|
||||
v =>
|
||||
v.status === 'pending' &&
|
||||
v.timeoutAt > Date.now() &&
|
||||
!hasAttemptedRecoveryRef.current.has(v.userId),
|
||||
!hasAttemptedRecoveryRef.current.has(v.sessionId),
|
||||
);
|
||||
|
||||
if (firstPending) {
|
||||
hasAttemptedRecoveryRef.current.add(firstPending.userId);
|
||||
hasAttemptedRecoveryRef.current.add(firstPending.sessionId);
|
||||
console.log(
|
||||
'[PendingKycRecovery] Recovering pending verification:',
|
||||
firstPending.userId,
|
||||
firstPending.sessionId,
|
||||
);
|
||||
subscribe(firstPending.userId);
|
||||
subscribe(firstPending.sessionId);
|
||||
}
|
||||
}, [pendingVerifications, subscribe, unsubscribeAll]);
|
||||
}
|
||||
|
||||
77
app/src/integrations/didit/diditService.ts
Normal file
77
app/src/integrations/didit/diditService.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { startVerification } from '@didit-protocol/sdk-react-native';
|
||||
import { DIDIT_TEE_URL } from '@env';
|
||||
|
||||
import type {
|
||||
DiditVerificationResult,
|
||||
SessionResponse,
|
||||
} from '@/integrations/didit/types';
|
||||
|
||||
export interface DiditConfig {
|
||||
locale?: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30000;
|
||||
|
||||
export const createSession = async (): Promise<SessionResponse> => {
|
||||
const apiUrl = DIDIT_TEE_URL;
|
||||
console.log('[Didit] createSession URL:', apiUrl);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/session`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to create Didit session (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return JSON.parse(body) as SessionResponse;
|
||||
}
|
||||
|
||||
return body as SessionResponse;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
`Request to Didit TEE timed out after ${FETCH_TIMEOUT_MS / 1000}s`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Failed to create Didit session: ${err.message}`);
|
||||
}
|
||||
|
||||
throw new Error('Failed to create Didit session: Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
export const launchDidit = async (
|
||||
sessionToken: string,
|
||||
config?: DiditConfig,
|
||||
): Promise<DiditVerificationResult> => {
|
||||
const result = await startVerification(sessionToken, {
|
||||
languageCode: config?.locale ?? 'en',
|
||||
loggingEnabled: config?.debug ?? __DEV__,
|
||||
});
|
||||
|
||||
return result as DiditVerificationResult;
|
||||
};
|
||||
14
app/src/integrations/didit/index.ts
Normal file
14
app/src/integrations/didit/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
export type {
|
||||
ApplicantInfoSerialized,
|
||||
DiditVerificationResult,
|
||||
SessionResponse,
|
||||
} from '@/integrations/didit/types';
|
||||
export {
|
||||
type DiditConfig,
|
||||
createSession,
|
||||
launchDidit,
|
||||
} from '@/integrations/didit/diditService';
|
||||
29
app/src/integrations/didit/types.ts
Normal file
29
app/src/integrations/didit/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
export interface ApplicantInfoSerialized {
|
||||
signature: string;
|
||||
applicantInfo: string;
|
||||
pubkey: Array<string>;
|
||||
}
|
||||
|
||||
export interface DiditVerificationResult {
|
||||
type: 'completed' | 'cancelled' | 'failed';
|
||||
session?: {
|
||||
status: string;
|
||||
sessionId: string;
|
||||
};
|
||||
error?: {
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
export interface SessionResponse {
|
||||
sessionId: string;
|
||||
sessionToken: string;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
export type {
|
||||
AccessTokenResponse,
|
||||
SumsubApplicantInfo,
|
||||
SumsubResult,
|
||||
} from '@/integrations/sumsub/types';
|
||||
export {
|
||||
type SumsubConfig,
|
||||
fetchAccessToken,
|
||||
launchSumsub,
|
||||
} from '@/integrations/sumsub/sumsubService';
|
||||
@@ -1,145 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { SUMSUB_TEE_URL } from '@env';
|
||||
import SNSMobileSDK from '@sumsub/react-native-mobilesdk-module';
|
||||
|
||||
import { alpha2ToAlpha3 } from '@selfxyz/common';
|
||||
|
||||
import type {
|
||||
AccessTokenResponse,
|
||||
SumsubResult,
|
||||
} from '@/integrations/sumsub/types';
|
||||
|
||||
// Maps Self document type codes to Sumsub document types
|
||||
type SelfDocumentType = 'p' | 'i';
|
||||
type SumsubDocumentType = 'PASSPORT' | 'ID_CARD';
|
||||
|
||||
const DOCUMENT_TYPE_MAP: Record<SelfDocumentType, SumsubDocumentType> = {
|
||||
p: 'PASSPORT',
|
||||
i: 'ID_CARD',
|
||||
};
|
||||
|
||||
export interface SumsubConfig {
|
||||
accessToken: string;
|
||||
locale?: string;
|
||||
debug?: boolean;
|
||||
/** Self document type code ('p' for passport, 'i' for ID card) */
|
||||
documentType?: SelfDocumentType;
|
||||
/** ISO 3166-1 alpha-2 country code (e.g., 'US', 'GB') */
|
||||
countryCode?: string;
|
||||
onStatusChanged?: (prevStatus: string, newStatus: string) => void;
|
||||
onEvent?: (eventType: string, payload: unknown) => void;
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30000; // 30 seconds
|
||||
|
||||
export const fetchAccessToken = async (): Promise<AccessTokenResponse> => {
|
||||
const apiUrl = SUMSUB_TEE_URL;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/access-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get Sumsub access token (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
// Handle both string and object responses
|
||||
if (typeof body === 'string') {
|
||||
return JSON.parse(body) as AccessTokenResponse;
|
||||
}
|
||||
|
||||
return body as AccessTokenResponse;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
`Request to Sumsub TEE timed out after ${FETCH_TIMEOUT_MS / 1000}s`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Failed to get Sumsub access token: ${err.message}`);
|
||||
}
|
||||
|
||||
throw new Error('Failed to get Sumsub access token: Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
export const launchSumsub = async (
|
||||
config: SumsubConfig,
|
||||
): Promise<SumsubResult> => {
|
||||
let sdk = SNSMobileSDK.init(config.accessToken, async () => {
|
||||
// Token refresh not implemented for test flow
|
||||
throw new Error(
|
||||
'Sumsub token expired - refresh not implemented for test flow',
|
||||
);
|
||||
})
|
||||
.withHandlers({
|
||||
onStatusChanged: event => {
|
||||
console.log(`Sumsub status: ${event.prevStatus} => ${event.newStatus}`);
|
||||
config.onStatusChanged?.(event.prevStatus, event.newStatus);
|
||||
},
|
||||
onLog: _event => {
|
||||
// Log event received but don't log message (may contain PII)
|
||||
console.log('[Sumsub] Log event received');
|
||||
},
|
||||
onEvent: event => {
|
||||
// Only log event type, not full payload (may contain PII)
|
||||
console.log(`Sumsub event: ${event.eventType}`);
|
||||
config.onEvent?.(event.eventType, event.payload);
|
||||
},
|
||||
})
|
||||
.withDebug(config.debug ?? __DEV__)
|
||||
.withLocale(config.locale ?? 'en')
|
||||
// Platform configuration:
|
||||
// - Device Intelligence (Fisherman): Enabled on both iOS and Android
|
||||
// * iOS: Configured via IDENSIC_WITH_FISHERMAN in Podfile
|
||||
// * Android: Configured via idensic-mobile-sdk-fisherman in patch file
|
||||
// * Privacy: iOS declares device ID collection in PrivacyInfo.xcprivacy
|
||||
// * Privacy: Android should declare device fingerprinting in Google Play Data Safety
|
||||
// - VideoIdent (live video calls): Disabled on both platforms for current release
|
||||
// * iOS: Disabled in Podfile (avoids microphone permission requirements)
|
||||
// * Android: Disabled in patch file (avoids FOREGROUND_SERVICE_MICROPHONE permission)
|
||||
// * Note: VideoIdent will be re-enabled on both platforms in future release for liveness checks
|
||||
.withAnalyticsEnabled(true); // Required for Device Intelligence to function
|
||||
|
||||
// Pre-select document type and country if provided
|
||||
// This skips the document selection step in Sumsub
|
||||
if (config.documentType && config.countryCode) {
|
||||
const sumsubDocType = DOCUMENT_TYPE_MAP[config.documentType];
|
||||
// Handle both 2-letter (US) and 3-letter (USA) country codes
|
||||
// alpha2ToAlpha3 returns undefined for 3-letter codes, so use the original if conversion fails
|
||||
const alpha3Country =
|
||||
alpha2ToAlpha3(config.countryCode) ?? config.countryCode;
|
||||
|
||||
if (sumsubDocType && alpha3Country) {
|
||||
console.log(
|
||||
`[Sumsub] Pre-selecting document: ${sumsubDocType} from ${alpha3Country}`,
|
||||
);
|
||||
sdk = sdk.withPreferredDocumentDefinitions({
|
||||
IDENTITY: {
|
||||
idDocType: sumsubDocType,
|
||||
country: alpha3Country,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sdk.build().launch();
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
export interface AccessTokenResponse {
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface SumsubApplicantInfo {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
key: string;
|
||||
clientId: string;
|
||||
inspectionId: string;
|
||||
externalUserId: string;
|
||||
info?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
dob?: string;
|
||||
country?: string;
|
||||
phone?: string;
|
||||
};
|
||||
email?: string;
|
||||
phone?: string;
|
||||
review: {
|
||||
reviewAnswer: string;
|
||||
reviewResult: {
|
||||
reviewAnswer: string;
|
||||
};
|
||||
};
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SumsubApplicantInfoSerialized {
|
||||
signature: string;
|
||||
applicantInfo: string;
|
||||
pubkey: Array<string>;
|
||||
}
|
||||
|
||||
export interface SumsubResult {
|
||||
success: boolean;
|
||||
status: string;
|
||||
errorType?: string;
|
||||
errorMsg?: string;
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export type OnboardingRoutesParamList = {
|
||||
Disclaimer: undefined;
|
||||
KycSuccess:
|
||||
| {
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
| undefined;
|
||||
KYCVerified:
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { logNFCEvent, logProofEvent } from '@/config/sentry';
|
||||
import { fetchAccessToken, launchSumsub } from '@/integrations/sumsub';
|
||||
import { createSession, launchDidit } from '@/integrations/didit';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { navigationRef } from '@/navigation';
|
||||
import {
|
||||
@@ -316,7 +316,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
documentTypes: string[];
|
||||
}) => {
|
||||
currentCountryCode = countryCode;
|
||||
// Store country code early so it's available for Sumsub fallback flows
|
||||
// Store country code early so it's available for Didit fallback flows
|
||||
useMRZStore.getState().update({ countryCode });
|
||||
navigateIfReady('IDPicker', { countryCode, documentTypes });
|
||||
},
|
||||
@@ -346,33 +346,23 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
if (
|
||||
useErrorInjectionStore
|
||||
.getState()
|
||||
.shouldTrigger('sumsub_initialization')
|
||||
.shouldTrigger('didit_initialization')
|
||||
) {
|
||||
console.log('[DEV] Injecting Sumsub initialization error');
|
||||
console.log('[DEV] Injecting Didit initialization error');
|
||||
throw new Error(
|
||||
'Injected Sumsub initialization error for testing',
|
||||
'Injected Didit initialization error for testing',
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = await fetchAccessToken();
|
||||
const result = await launchSumsub({
|
||||
accessToken: accessToken.token,
|
||||
});
|
||||
const session = await createSession();
|
||||
const result = await launchDidit(session.sessionToken);
|
||||
|
||||
console.log('[Sumsub] Result:', JSON.stringify(result));
|
||||
console.log('[Didit] Result:', JSON.stringify(result));
|
||||
|
||||
// User cancelled/dismissed without completing verification
|
||||
// Status values: 'Initial' (never started), 'Incomplete' (started but not finished),
|
||||
// 'Interrupted' (explicitly cancelled)
|
||||
const cancelledStatuses = [
|
||||
'Initial',
|
||||
'Incomplete',
|
||||
'Interrupted',
|
||||
];
|
||||
if (cancelledStatuses.includes(result.status)) {
|
||||
if (result.type === 'cancelled') {
|
||||
console.log(
|
||||
'[Sumsub] User cancelled or closed without completing, status:',
|
||||
result.status,
|
||||
'[Didit] User cancelled or closed without completing',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -380,15 +370,20 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
// Dev-only: Check for injected verification error
|
||||
const shouldInjectVerificationError = useErrorInjectionStore
|
||||
.getState()
|
||||
.shouldTrigger('sumsub_verification');
|
||||
.shouldTrigger('didit_verification');
|
||||
|
||||
// Actual error from provider
|
||||
if (!result.success || shouldInjectVerificationError) {
|
||||
if (
|
||||
result.type === 'failed' ||
|
||||
shouldInjectVerificationError
|
||||
) {
|
||||
if (shouldInjectVerificationError) {
|
||||
console.log('[DEV] Injecting Sumsub verification error');
|
||||
console.log('[DEV] Injecting Didit verification error');
|
||||
} else {
|
||||
const safeError = sanitizeErrorMessage(
|
||||
result.errorMsg || result.errorType || 'unknown_error',
|
||||
result.error?.message ||
|
||||
result.error?.type ||
|
||||
'unknown_error',
|
||||
);
|
||||
console.error('KYC provider failed:', safeError);
|
||||
}
|
||||
@@ -402,15 +397,15 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// User completed verification (status: 'Pending', 'Approved', etc.)
|
||||
// User completed verification
|
||||
// Navigate to KYC success screen
|
||||
console.log(
|
||||
'[Sumsub] Verification submitted, status:',
|
||||
result.status,
|
||||
'[Didit] Verification submitted, status:',
|
||||
result.session?.status,
|
||||
);
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('KycSuccess', {
|
||||
userId: accessToken.userId,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { YStack } from 'tamagui';
|
||||
import type { StaticScreenProps } from '@react-navigation/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -71,36 +71,46 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
// DISABLED FOR NOW: Turnkey functionality
|
||||
// const [turnkeyPending, setTurnkeyPending] = useState(false);
|
||||
|
||||
const { showModal: showDisableModal } = useModal(
|
||||
useMemo(
|
||||
() => ({
|
||||
titleText: 'Disable cloud backups',
|
||||
bodyText:
|
||||
'Are you sure you want to disable cloud backups, you may lose your recovery phrase.',
|
||||
buttonText: 'I understand the risks',
|
||||
onButtonPress: async () => {
|
||||
try {
|
||||
trackEvent(BackupEvents.CLOUD_BACKUP_DISABLE_STARTED);
|
||||
await loginWithBiometrics();
|
||||
await disableBackup();
|
||||
toggleCloudBackupEnabled();
|
||||
trackEvent(BackupEvents.CLOUD_BACKUP_DISABLED_DONE);
|
||||
} finally {
|
||||
setICloudPending(false);
|
||||
}
|
||||
},
|
||||
onModalDismiss: () => {
|
||||
setICloudPending(false);
|
||||
},
|
||||
}),
|
||||
const showDisableModal = useCallback(() => {
|
||||
Alert.alert(
|
||||
'Disable cloud backups',
|
||||
'Are you sure you want to disable cloud backups? You may lose your recovery phrase.',
|
||||
[
|
||||
loginWithBiometrics,
|
||||
disableBackup,
|
||||
toggleCloudBackupEnabled,
|
||||
trackEvent,
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
onPress: () => setICloudPending(false),
|
||||
},
|
||||
{
|
||||
text: 'Disable',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
trackEvent(BackupEvents.CLOUD_BACKUP_DISABLE_STARTED);
|
||||
await loginWithBiometrics();
|
||||
await disableBackup();
|
||||
toggleCloudBackupEnabled();
|
||||
trackEvent(BackupEvents.CLOUD_BACKUP_DISABLED_DONE);
|
||||
} catch (error) {
|
||||
console.error('Failed to disable cloud backup', error);
|
||||
Alert.alert(
|
||||
'Error',
|
||||
'Failed to disable cloud backups. Please try again.',
|
||||
);
|
||||
} finally {
|
||||
setICloudPending(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
{ cancelable: false },
|
||||
);
|
||||
}, [
|
||||
loginWithBiometrics,
|
||||
disableBackup,
|
||||
toggleCloudBackupEnabled,
|
||||
trackEvent,
|
||||
]);
|
||||
|
||||
const { showModal: showNoRegisteredAccountModal } = useModal(
|
||||
useMemo(
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
|
||||
|
||||
import WarningIcon from '@/assets/images/warning.svg';
|
||||
import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
@@ -70,18 +70,16 @@ const AadhaarUploadErrorScreen: React.FC = () => {
|
||||
const errorType = route.params?.errorType || 'general';
|
||||
const { title, description } = getErrorMessages(errorType);
|
||||
|
||||
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
|
||||
{
|
||||
countryCode: 'IND',
|
||||
errorSource: 'mrz_scan_failed', // Use a compatible error source
|
||||
onCancel: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
onError: () => {
|
||||
// Stay on this screen - user can try again
|
||||
},
|
||||
const { launchDiditVerification, isLoading: isRetrying } = useDiditLauncher({
|
||||
countryCode: 'IND',
|
||||
errorSource: 'mrz_scan_failed', // Use a compatible error source
|
||||
onCancel: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
);
|
||||
onError: () => {
|
||||
// Stay on this screen - user can try again
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
buttonTap();
|
||||
@@ -95,8 +93,8 @@ const AadhaarUploadErrorScreen: React.FC = () => {
|
||||
|
||||
const handleTryAlternative = useCallback(async () => {
|
||||
trackEvent(AadhaarEvents.HELP_BUTTON_PRESSED, { errorType });
|
||||
await launchSumsubVerification();
|
||||
}, [errorType, launchSumsubVerification, trackEvent]);
|
||||
await launchDiditVerification();
|
||||
}, [errorType, launchDiditVerification, trackEvent]);
|
||||
|
||||
return (
|
||||
<YStack flex={1} backgroundColor={slate100}>
|
||||
|
||||
@@ -16,8 +16,8 @@ import QrScan from '@/assets/icons/qr_scan.svg';
|
||||
import Star from '@/assets/icons/star.svg';
|
||||
import type { TipProps } from '@/components/Tips';
|
||||
import Tips from '@/components/Tips';
|
||||
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
|
||||
import { flush as flushAnalytics } from '@/services/analytics';
|
||||
|
||||
@@ -54,7 +54,7 @@ const DocumentCameraTroubleScreen: React.FC = () => {
|
||||
const selfClient = useSelfClient();
|
||||
const { useMRZStore } = selfClient;
|
||||
const { countryCode } = useMRZStore();
|
||||
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
|
||||
const { launchDiditVerification, isLoading } = useDiditLauncher({
|
||||
countryCode,
|
||||
errorSource: 'mrz_scan_failed',
|
||||
});
|
||||
@@ -88,7 +88,7 @@ const DocumentCameraTroubleScreen: React.FC = () => {
|
||||
</Caption>
|
||||
|
||||
<SecondaryButton
|
||||
onPress={launchSumsubVerification}
|
||||
onPress={launchDiditVerification}
|
||||
disabled={isLoading}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
|
||||
@@ -14,9 +14,9 @@ import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import type { TipProps } from '@/components/Tips';
|
||||
import Tips from '@/components/Tips';
|
||||
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
|
||||
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import { selectionChange } from '@/integrations/haptics';
|
||||
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
|
||||
import { flushAllAnalytics } from '@/services/analytics';
|
||||
@@ -61,7 +61,7 @@ const DocumentNFCTroubleScreen: React.FC = () => {
|
||||
const selfClient = useSelfClient();
|
||||
const { useMRZStore } = selfClient;
|
||||
const { countryCode } = useMRZStore();
|
||||
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
|
||||
const { launchDiditVerification, isLoading } = useDiditLauncher({
|
||||
countryCode,
|
||||
errorSource: 'nfc_scan_failed',
|
||||
});
|
||||
@@ -96,7 +96,7 @@ const DocumentNFCTroubleScreen: React.FC = () => {
|
||||
</SecondaryButton>
|
||||
|
||||
<SecondaryButton
|
||||
onPress={launchSumsubVerification}
|
||||
onPress={launchDiditVerification}
|
||||
disabled={isLoading}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
|
||||
|
||||
import WarningIcon from '@/assets/images/warning.svg';
|
||||
import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
@@ -66,19 +66,17 @@ const RegistrationFallbackMRZScreen: React.FC = () => {
|
||||
|
||||
const headerTitle = getHeaderTitle(documentType);
|
||||
|
||||
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
|
||||
{
|
||||
countryCode,
|
||||
errorSource: 'mrz_scan_failed',
|
||||
onCancel: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
onError: (_error, _result) => {
|
||||
// Stay on this screen - user can try again
|
||||
// Error is already logged in the hook
|
||||
},
|
||||
const { launchDiditVerification, isLoading: isRetrying } = useDiditLauncher({
|
||||
countryCode,
|
||||
errorSource: 'mrz_scan_failed',
|
||||
onCancel: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
);
|
||||
onError: (_error, _result) => {
|
||||
// Stay on this screen - user can try again
|
||||
// Error is already logged in the hook
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
buttonTap();
|
||||
@@ -89,8 +87,8 @@ const RegistrationFallbackMRZScreen: React.FC = () => {
|
||||
trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', {
|
||||
errorSource: 'mrz_scan_failed',
|
||||
});
|
||||
await launchSumsubVerification();
|
||||
}, [launchSumsubVerification, trackEvent]);
|
||||
await launchDiditVerification();
|
||||
}, [launchDiditVerification, trackEvent]);
|
||||
|
||||
const handleRetryOriginal = useCallback(() => {
|
||||
trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
|
||||
|
||||
import WarningIcon from '@/assets/images/warning.svg';
|
||||
import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
@@ -67,19 +67,17 @@ const RegistrationFallbackNFCScreen: React.FC = () => {
|
||||
|
||||
const headerTitle = getHeaderTitle(documentType);
|
||||
|
||||
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
|
||||
{
|
||||
countryCode,
|
||||
errorSource: 'nfc_scan_failed',
|
||||
onCancel: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
onError: (_error, _result) => {
|
||||
// Stay on this screen - user can try again
|
||||
// Error is already logged in the hook
|
||||
},
|
||||
const { launchDiditVerification, isLoading: isRetrying } = useDiditLauncher({
|
||||
countryCode,
|
||||
errorSource: 'nfc_scan_failed',
|
||||
onCancel: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
);
|
||||
onError: (_error, _result) => {
|
||||
// Stay on this screen - user can try again
|
||||
// Error is already logged in the hook
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
buttonTap();
|
||||
@@ -95,8 +93,8 @@ const RegistrationFallbackNFCScreen: React.FC = () => {
|
||||
trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', {
|
||||
errorSource: 'nfc_scan_failed',
|
||||
});
|
||||
await launchSumsubVerification();
|
||||
}, [launchSumsubVerification, trackEvent]);
|
||||
await launchDiditVerification();
|
||||
}, [launchDiditVerification, trackEvent]);
|
||||
|
||||
const handleRetryOriginal = useCallback(() => {
|
||||
trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', {
|
||||
|
||||
@@ -25,11 +25,8 @@ import { advercase, dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
import EPassportLogo from '@/assets/icons/epassport_logo.svg';
|
||||
import { DocumentFlowNavBar } from '@/components/navbar/DocumentFlowNavBar';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { createSession, launchDidit } from '@/integrations/didit';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import {
|
||||
fetchAccessToken,
|
||||
launchSumsub,
|
||||
} from '@/integrations/sumsub/sumsubService';
|
||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { useFeedback } from '@/providers/feedbackProvider';
|
||||
@@ -41,7 +38,7 @@ type LogoConfirmationScreenRouteProp = RouteProp<
|
||||
|
||||
const LogoConfirmationScreen: React.FC = () => {
|
||||
const route = useRoute<LogoConfirmationScreenRouteProp>();
|
||||
const { documentType, countryCode } = route.params;
|
||||
const { countryCode } = route.params;
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const { showModal } = useFeedback();
|
||||
@@ -61,27 +58,19 @@ const LogoConfirmationScreen: React.FC = () => {
|
||||
buttonText: 'Proceed with an external verifier',
|
||||
onButtonPress: async () => {
|
||||
try {
|
||||
const accessToken = await fetchAccessToken();
|
||||
const result = await launchSumsub({
|
||||
accessToken: accessToken.token,
|
||||
// Pre-select document type and country based on user's earlier selection
|
||||
documentType: documentType as 'p' | 'i',
|
||||
countryCode,
|
||||
});
|
||||
const session = await createSession();
|
||||
const result = await launchDidit(session.sessionToken);
|
||||
|
||||
// User cancelled/dismissed without completing verification
|
||||
if (
|
||||
!result.success &&
|
||||
['Initial', 'Incomplete', 'Interrupted'].includes(result.status)
|
||||
) {
|
||||
if (result.type === 'cancelled') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verification failed (provider error/rejection)
|
||||
if (!result.success) {
|
||||
if (result.type === 'failed') {
|
||||
console.error(
|
||||
'Sumsub verification failed:',
|
||||
result.errorType ?? result.status,
|
||||
'Didit verification failed:',
|
||||
result.error?.type ?? 'unknown',
|
||||
);
|
||||
navigation.navigate('KycFailure', {
|
||||
countryCode,
|
||||
@@ -91,9 +80,9 @@ const LogoConfirmationScreen: React.FC = () => {
|
||||
}
|
||||
|
||||
// Verification succeeded - navigate to KycSuccessScreen
|
||||
navigation.navigate('KycSuccess', { userId: accessToken.userId });
|
||||
navigation.navigate('KycSuccess', { sessionId: session.sessionId });
|
||||
} catch {
|
||||
console.error('Error launching Sumsub verification');
|
||||
console.error('Error launching Didit verification');
|
||||
showModal({
|
||||
titleText: 'Error',
|
||||
bodyText: 'Unable to start verification. Please try again.',
|
||||
@@ -103,7 +92,7 @@ const LogoConfirmationScreen: React.FC = () => {
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [documentType, countryCode, navigation, showModal]);
|
||||
}, [countryCode, navigation, showModal]);
|
||||
|
||||
return (
|
||||
<ExpandableBottomLayout.Layout backgroundColor={slate100}>
|
||||
|
||||
@@ -260,7 +260,7 @@ const HomeScreen: React.FC = () => {
|
||||
{/* Show pending KYC cards at the top */}
|
||||
{activePendingVerifications.map(verification => (
|
||||
<PendingIdCard
|
||||
key={verification.userId}
|
||||
key={verification.sessionId}
|
||||
onClick={() => {
|
||||
if (
|
||||
verification.status === 'processing' &&
|
||||
|
||||
@@ -75,7 +75,7 @@ const KYCVerifiedScreen: React.FC = () => {
|
||||
//TODO improvement: instead of removing it here, we could do it in provingMachine's final state(error/completed)
|
||||
//if we do that, the card will still be displayed in Homescreen as 'Pending' if user click back midway during provingMachine
|
||||
if (pendingVerification) {
|
||||
removePendingVerification(pendingVerification.userId);
|
||||
removePendingVerification(pendingVerification.sessionId);
|
||||
}
|
||||
|
||||
const documentMetadata: {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import { useSumsubWebSocket } from '@/hooks/useSumsubWebSocket';
|
||||
import { useDiditWebSocket } from '@/hooks/useDiditWebSocket';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import {
|
||||
@@ -34,7 +34,7 @@ import { useSettingStore } from '@/stores/settingStore';
|
||||
|
||||
type KycSuccessRouteParams = StaticScreenProps<
|
||||
| {
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
@@ -44,7 +44,7 @@ const KycSuccessScreen: React.FC<KycSuccessRouteParams> = ({
|
||||
}) => {
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const userId = params?.userId;
|
||||
const sessionId = params?.sessionId;
|
||||
const insets = useSafeAreaInsets();
|
||||
const setFcmToken = useSettingStore(state => state.setFcmToken);
|
||||
const selfClient = useSelfClient();
|
||||
@@ -66,42 +66,42 @@ const KycSuccessScreen: React.FC<KycSuccessRouteParams> = ({
|
||||
console.log('[KycSuccessScreen] Verification failed:', reason);
|
||||
}, []);
|
||||
|
||||
const { subscribe, unsubscribeAll } = useSumsubWebSocket({
|
||||
const { subscribe, unsubscribeAll } = useDiditWebSocket({
|
||||
onSuccess: handleWebSocketSuccess,
|
||||
onError: handleWebSocketError,
|
||||
onVerificationFailed: handleVerificationFailed,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userId && !hasSubscribedRef.current) {
|
||||
if (sessionId && !hasSubscribedRef.current) {
|
||||
hasSubscribedRef.current = true;
|
||||
console.log('[KycSuccessScreen] Subscribing to userId:', userId);
|
||||
subscribe(userId);
|
||||
console.log('[KycSuccessScreen] Subscribing to sessionId:', sessionId);
|
||||
subscribe(sessionId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
hasSubscribedRef.current = false;
|
||||
unsubscribeAll();
|
||||
};
|
||||
}, [userId, subscribe, unsubscribeAll]);
|
||||
}, [sessionId, subscribe, unsubscribeAll]);
|
||||
|
||||
const handleReceiveUpdates = useCallback(async () => {
|
||||
buttonTap();
|
||||
|
||||
if ((await requestNotificationPermission()) && userId) {
|
||||
if ((await requestNotificationPermission()) && sessionId) {
|
||||
const token = await getFCMToken();
|
||||
if (token) {
|
||||
setFcmToken(token);
|
||||
trackEvent(ProofEvents.FCM_TOKEN_STORED);
|
||||
|
||||
const sessionId = uuidv5(userId, SELF_UUID_NAMESPACE);
|
||||
await registerDeviceToken(sessionId, token);
|
||||
const notificationId = uuidv5(sessionId, SELF_UUID_NAMESPACE);
|
||||
await registerDeviceToken(notificationId, token);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to Home regardless of permission result
|
||||
navigation.navigate('Home', {});
|
||||
}, [navigation, setFcmToken, trackEvent, userId]);
|
||||
}, [navigation, setFcmToken, trackEvent, sessionId]);
|
||||
|
||||
const handleCheckLater = () => {
|
||||
buttonTap();
|
||||
|
||||
@@ -16,8 +16,8 @@ export type InjectedErrorType =
|
||||
| 'nfc_parse_failure'
|
||||
| 'api_network_error'
|
||||
| 'api_timeout'
|
||||
| 'sumsub_initialization'
|
||||
| 'sumsub_verification';
|
||||
| 'didit_initialization'
|
||||
| 'didit_verification';
|
||||
|
||||
export const ERROR_GROUPS = {
|
||||
MRZ: ['mrz_invalid_format', 'mrz_unknown_error'] as InjectedErrorType[],
|
||||
@@ -27,10 +27,7 @@ export const ERROR_GROUPS = {
|
||||
'nfc_parse_failure',
|
||||
] as InjectedErrorType[],
|
||||
API: ['api_network_error', 'api_timeout'] as InjectedErrorType[],
|
||||
Sumsub: [
|
||||
'sumsub_initialization',
|
||||
'sumsub_verification',
|
||||
] as InjectedErrorType[],
|
||||
Didit: ['didit_initialization', 'didit_verification'] as InjectedErrorType[],
|
||||
};
|
||||
|
||||
export const ERROR_LABELS: Record<InjectedErrorType, string> = {
|
||||
@@ -41,8 +38,8 @@ export const ERROR_LABELS: Record<InjectedErrorType, string> = {
|
||||
nfc_parse_failure: 'NFC: Parse failure',
|
||||
api_network_error: 'API: Network error',
|
||||
api_timeout: 'API: Timeout',
|
||||
sumsub_initialization: 'Sumsub: Initialization',
|
||||
sumsub_verification: 'Sumsub: Verification',
|
||||
didit_initialization: 'Didit: Initialization',
|
||||
didit_verification: 'Didit: Verification',
|
||||
};
|
||||
|
||||
interface ErrorInjectionState {
|
||||
|
||||
@@ -16,19 +16,19 @@ const VERIFICATION_TIMEOUT_MS = 48 * 60 * 60 * 1000; // 48 hours TODO seshanth
|
||||
interface PendingKycState {
|
||||
pendingVerifications: PendingKycVerification[];
|
||||
|
||||
addPendingVerification: (userId: string) => void;
|
||||
addPendingVerification: (sessionId: string) => void;
|
||||
updateVerificationStatus: (
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
status: PendingKycStatus,
|
||||
errorMessage?: string,
|
||||
documentId?: string,
|
||||
) => void;
|
||||
removePendingVerification: (userId: string) => void;
|
||||
removePendingVerification: (sessionId: string) => void;
|
||||
removeExpiredVerifications: () => void;
|
||||
clearAllPendingVerifications: () => void;
|
||||
hasPendingVerification: () => boolean;
|
||||
getPendingVerification: (
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
) => PendingKycVerification | undefined;
|
||||
}
|
||||
|
||||
@@ -37,14 +37,16 @@ export const usePendingKycStore = create<PendingKycState>()(
|
||||
(set, get) => ({
|
||||
pendingVerifications: [],
|
||||
|
||||
addPendingVerification: (userId: string) => {
|
||||
addPendingVerification: (sessionId: string) => {
|
||||
const now = Date.now();
|
||||
set(state => ({
|
||||
pendingVerifications: [
|
||||
// Remove any existing entry for this userId
|
||||
...state.pendingVerifications.filter(v => v.userId !== userId),
|
||||
// Remove any existing entry for this sessionId
|
||||
...state.pendingVerifications.filter(
|
||||
v => v.sessionId !== sessionId,
|
||||
),
|
||||
{
|
||||
userId,
|
||||
sessionId,
|
||||
createdAt: now,
|
||||
status: 'pending',
|
||||
timeoutAt: now + VERIFICATION_TIMEOUT_MS,
|
||||
@@ -54,14 +56,14 @@ export const usePendingKycStore = create<PendingKycState>()(
|
||||
},
|
||||
|
||||
updateVerificationStatus: (
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
status: PendingKycStatus,
|
||||
errorMessage?: string,
|
||||
documentId?: string,
|
||||
) => {
|
||||
set(state => ({
|
||||
pendingVerifications: state.pendingVerifications.map(v =>
|
||||
v.userId === userId
|
||||
v.sessionId === sessionId
|
||||
? {
|
||||
...v,
|
||||
status,
|
||||
@@ -73,10 +75,10 @@ export const usePendingKycStore = create<PendingKycState>()(
|
||||
}));
|
||||
},
|
||||
|
||||
removePendingVerification: (userId: string) => {
|
||||
removePendingVerification: (sessionId: string) => {
|
||||
set(state => ({
|
||||
pendingVerifications: state.pendingVerifications.filter(
|
||||
v => v.userId !== userId,
|
||||
v => v.sessionId !== sessionId,
|
||||
),
|
||||
}));
|
||||
},
|
||||
@@ -97,11 +99,12 @@ export const usePendingKycStore = create<PendingKycState>()(
|
||||
hasPendingVerification: () =>
|
||||
get().pendingVerifications.some(v => v.status === 'pending'),
|
||||
|
||||
getPendingVerification: (userId: string) =>
|
||||
get().pendingVerifications.find(v => v.userId === userId),
|
||||
getPendingVerification: (sessionId: string) =>
|
||||
get().pendingVerifications.find(v => v.sessionId === sessionId),
|
||||
}),
|
||||
{
|
||||
name: 'pending-kyc-storage',
|
||||
version: 1,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
},
|
||||
),
|
||||
|
||||
56
app/src/types/sumsub.d.ts
vendored
56
app/src/types/sumsub.d.ts
vendored
@@ -1,56 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
declare module '@sumsub/react-native-mobilesdk-module' {
|
||||
export interface SumsubEvent {
|
||||
eventType: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SumsubHandlers {
|
||||
onStatusChanged?: (event: SumsubStatusChangedEvent) => void;
|
||||
onLog?: (event: SumsubLogEvent) => void;
|
||||
onEvent?: (event: SumsubEvent) => void;
|
||||
}
|
||||
|
||||
export interface SumsubLogEvent {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SumsubResult {
|
||||
success: boolean;
|
||||
status: string;
|
||||
errorType?: string;
|
||||
errorMsg?: string;
|
||||
}
|
||||
|
||||
export interface SumsubSDK {
|
||||
withHandlers(handlers: SumsubHandlers): SumsubSDK;
|
||||
withDebug(debug: boolean): SumsubSDK;
|
||||
withLocale(locale: string): SumsubSDK;
|
||||
withAnalyticsEnabled(enabled: boolean): SumsubSDK;
|
||||
withAutoCloseOnApprove(seconds: number): SumsubSDK;
|
||||
withApplicantConf(config: { email?: string; phone?: string }): SumsubSDK;
|
||||
withPreferredDocumentDefinitions(
|
||||
definitions: Record<string, { idDocType: string; country: string }>,
|
||||
): SumsubSDK;
|
||||
withDisableMLKit(disable: boolean): SumsubSDK;
|
||||
withStrings(strings: Record<string, string>): SumsubSDK;
|
||||
build(): SumsubSDK;
|
||||
launch(): Promise<SumsubResult>;
|
||||
dismiss(): void;
|
||||
}
|
||||
|
||||
export interface SumsubStatusChangedEvent {
|
||||
prevStatus: string;
|
||||
newStatus: string;
|
||||
}
|
||||
|
||||
export default class SNSMobileSDK {
|
||||
static init(
|
||||
accessToken: string,
|
||||
tokenExpirationHandler: () => Promise<string>,
|
||||
): SumsubSDK;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import { usePendingKycRecovery } from '@/hooks/usePendingKycRecovery';
|
||||
import { navigationRef } from '@/navigation';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/hooks/useSumsubWebSocket', () => ({
|
||||
useSumsubWebSocket: jest.fn(() => ({
|
||||
jest.mock('@/hooks/useDiditWebSocket', () => ({
|
||||
useDiditWebSocket: jest.fn(() => ({
|
||||
subscribe: jest.fn(),
|
||||
unsubscribeAll: jest.fn(),
|
||||
})),
|
||||
@@ -39,10 +39,8 @@ describe('usePendingKycRecovery', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Setup default mocks
|
||||
const { useSumsubWebSocket } = jest.requireMock(
|
||||
'@/hooks/useSumsubWebSocket',
|
||||
);
|
||||
useSumsubWebSocket.mockReturnValue({
|
||||
const { useDiditWebSocket } = jest.requireMock('@/hooks/useDiditWebSocket');
|
||||
useDiditWebSocket.mockReturnValue({
|
||||
subscribe: mockSubscribe,
|
||||
unsubscribeAll: mockUnsubscribeAll,
|
||||
});
|
||||
@@ -79,7 +77,7 @@ describe('usePendingKycRecovery', () => {
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
sessionId: 'session-123',
|
||||
status: 'processing',
|
||||
documentId: 'doc-456',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
@@ -102,7 +100,7 @@ describe('usePendingKycRecovery', () => {
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
sessionId: 'session-123',
|
||||
status: 'processing',
|
||||
documentId: 'doc-456',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
@@ -133,10 +131,10 @@ describe('usePendingKycRecovery', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not attempt recovery for same userId twice', () => {
|
||||
it('should not attempt recovery for same sessionId twice', () => {
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
const verification = {
|
||||
userId: 'user-123',
|
||||
sessionId: 'session-123',
|
||||
status: 'processing' as const,
|
||||
documentId: 'doc-456',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
@@ -156,7 +154,7 @@ describe('usePendingKycRecovery', () => {
|
||||
// Rerender with same verification
|
||||
rerender();
|
||||
|
||||
// Should not navigate again for same userId
|
||||
// Should not navigate again for same sessionId
|
||||
expect(mockNavigationRef.navigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -165,7 +163,7 @@ describe('usePendingKycRecovery', () => {
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-789',
|
||||
sessionId: 'session-789',
|
||||
status: 'pending',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
},
|
||||
@@ -175,7 +173,7 @@ describe('usePendingKycRecovery', () => {
|
||||
|
||||
renderHook(() => usePendingKycRecovery());
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('user-789');
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('session-789');
|
||||
});
|
||||
|
||||
it('should skip expired verifications', () => {
|
||||
@@ -183,7 +181,7 @@ describe('usePendingKycRecovery', () => {
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-expired',
|
||||
sessionId: 'session-expired',
|
||||
status: 'pending',
|
||||
timeoutAt: Date.now() - 1000, // Expired
|
||||
},
|
||||
@@ -202,7 +200,7 @@ describe('usePendingKycRecovery', () => {
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
sessionId: 'session-123',
|
||||
status: 'processing',
|
||||
documentId: 'doc-456',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
@@ -233,12 +231,12 @@ describe('usePendingKycRecovery', () => {
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-pending',
|
||||
sessionId: 'session-pending',
|
||||
status: 'pending',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
},
|
||||
{
|
||||
userId: 'user-processing',
|
||||
sessionId: 'session-processing',
|
||||
status: 'processing',
|
||||
documentId: 'doc-789',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
|
||||
@@ -24,23 +24,18 @@ jest.mock('@/services/analytics', () => ({
|
||||
flush: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Sumsub SDK to prevent ES module parsing errors in isolateModules
|
||||
jest.mock('@sumsub/react-native-mobilesdk-module', () => {
|
||||
const createBuilder = () => ({
|
||||
withHandlers: jest.fn().mockReturnThis(),
|
||||
withDebug: jest.fn().mockReturnThis(),
|
||||
withLocale: jest.fn().mockReturnThis(),
|
||||
withAnalyticsEnabled: jest.fn().mockReturnThis(),
|
||||
build: jest.fn().mockReturnValue({
|
||||
launch: jest.fn().mockResolvedValue({ success: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: { init: jest.fn(() => createBuilder()) },
|
||||
};
|
||||
});
|
||||
// Mock Didit SDK to prevent ES module parsing errors in isolateModules
|
||||
jest.mock('@didit-protocol/sdk-react-native', () => ({
|
||||
__esModule: true,
|
||||
startVerification: jest.fn().mockResolvedValue({
|
||||
type: 'completed',
|
||||
session: { status: 'approved', sessionId: 'mock-session-id' },
|
||||
}),
|
||||
startVerificationWithWorkflow: jest.fn().mockResolvedValue({
|
||||
type: 'completed',
|
||||
session: { status: 'approved', sessionId: 'mock-session-id' },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('navigation', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -44,8 +44,8 @@ jest.mock('react-native-edge-to-edge', () => ({
|
||||
SystemBars: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useSumsubWebSocket', () => ({
|
||||
useSumsubWebSocket: jest.fn(() => ({
|
||||
jest.mock('@/hooks/useDiditWebSocket', () => ({
|
||||
useDiditWebSocket: jest.fn(() => ({
|
||||
subscribe: jest.fn(),
|
||||
unsubscribe: jest.fn(),
|
||||
unsubscribeAll: jest.fn(),
|
||||
@@ -148,11 +148,11 @@ describe('KycSuccessScreen', () => {
|
||||
const mockNavigate = jest.fn();
|
||||
const mockTrackEvent = jest.fn();
|
||||
const mockSetFcmToken = jest.fn();
|
||||
const mockUserId = '19f21362-856a-4606-88e1-fa306036978f';
|
||||
const mockSessionId = '19f21362-856a-4606-88e1-fa306036978f';
|
||||
const mockFcmToken = 'mock-fcm-token';
|
||||
const mockRoute = {
|
||||
params: {
|
||||
userId: mockUserId,
|
||||
sessionId: mockSessionId,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('KycSuccessScreen', () => {
|
||||
await waitFor(() => {
|
||||
// Verify device token was registered with deterministic session ID
|
||||
expect(notificationService.registerDeviceToken).toHaveBeenCalledWith(
|
||||
uuidv5(mockUserId, notificationService.SELF_UUID_NAMESPACE),
|
||||
uuidv5(mockSessionId, notificationService.SELF_UUID_NAMESPACE),
|
||||
mockFcmToken,
|
||||
);
|
||||
});
|
||||
@@ -290,7 +290,7 @@ describe('KycSuccessScreen', () => {
|
||||
expect(notificationService.registerDeviceToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing userId gracefully', async () => {
|
||||
it('should handle missing sessionId gracefully', async () => {
|
||||
const routeWithoutUserId = {
|
||||
params: {},
|
||||
};
|
||||
@@ -312,7 +312,7 @@ describe('KycSuccessScreen', () => {
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Verify FCM token was NOT fetched (no userId)
|
||||
// Verify FCM token was NOT fetched (no sessionId)
|
||||
expect(notificationService.getFCMToken).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -52,21 +52,15 @@ template REGISTER_KYC() {
|
||||
id_num[i] <== data_padded[idNumberIdx + i];
|
||||
}
|
||||
|
||||
signal nullifier_inputs[6 + id_number_length + id_type_length];
|
||||
signal nullifier_inputs[id_number_length + id_type_length];
|
||||
|
||||
nullifier_inputs[0] <== 115; //s
|
||||
nullifier_inputs[1] <== 117; //u
|
||||
nullifier_inputs[2] <== 109; //m
|
||||
nullifier_inputs[3] <== 115; //s
|
||||
nullifier_inputs[4] <== 117; //u
|
||||
nullifier_inputs[5] <== 98; //b
|
||||
for (var i = 0; i < id_number_length; i++) {
|
||||
nullifier_inputs[i + 6] <== id_num[i];
|
||||
nullifier_inputs[i] <== id_num[i];
|
||||
}
|
||||
for (var i = 0; i < id_type_length; i++) {
|
||||
nullifier_inputs[i + 6 + id_number_length] <== data_padded[id_type_index + i];
|
||||
nullifier_inputs[i + id_number_length] <== data_padded[id_type_index + i];
|
||||
}
|
||||
signal output nullifier <== PackBytesAndPoseidon(6 + id_number_length + id_type_length)(nullifier_inputs);
|
||||
signal output nullifier <== PackBytesAndPoseidon(id_number_length + id_type_length)(nullifier_inputs);
|
||||
signal output commitment <== Poseidon(2)([secret, msg_hasher.out]);
|
||||
|
||||
signal output pubkey_hash <== Poseidon(2)([verifyIdCommSig.Ax, verifyIdCommSig.Ay]);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# run from root
|
||||
# first argument should register | dsc | disclose
|
||||
if [[ $1 != "register" && $1 != "dsc" && $1 != "disclose" && $1 != "register_id" && $1 != "register_aadhaar" && $1 != "register_kyc" ]]; then
|
||||
echo "first argument should be register | dsc | disclose | register_id | register_aadhaar"
|
||||
echo "first argument should be register | dsc | disclose | register_id | register_aadhaar | register_kyc"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
# run from root
|
||||
# first argument should register | dsc | disclose
|
||||
if [[ $1 != "register" && $1 != "dsc" && $1 != "disclose" && $1 != "register_id" ]]; then
|
||||
echo "first argument should be register | dsc | disclose | register_id"
|
||||
if [[ $1 != "register" && $1 != "dsc" && $1 != "disclose" && $1 != "register_id" && $1 != "register_kyc" ]]; then
|
||||
echo "first argument should be register | dsc | disclose | register_id | register_kyc"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -67,6 +67,10 @@ REGISTER_ID_CIRCUITS=(
|
||||
"register_id_sha512_sha512_sha512_rsapss_65537_64_2048:true"
|
||||
)
|
||||
|
||||
REGISTER_KYC_CIRCUITS=(
|
||||
"register_kyc:true"
|
||||
)
|
||||
|
||||
DISCLOSE_CIRCUITS=(
|
||||
"vc_and_disclose:true"
|
||||
"vc_and_disclose_id:true"
|
||||
@@ -105,6 +109,11 @@ elif [[ $1 == "register_id" ]]; then
|
||||
output="output/register"
|
||||
mkdir -p $output
|
||||
basepath="./circuits/circuits/register_id/instances"
|
||||
elif [[ $1 == "register_kyc" ]]; then
|
||||
allowed_circuits=("${REGISTER_KYC_CIRCUITS[@]}")
|
||||
output="output/register"
|
||||
mkdir -p $output
|
||||
basepath="./circuits/circuits/register/instances"
|
||||
elif [[ $1 == "dsc" ]]; then
|
||||
allowed_circuits=("${DSC_CIRCUITS[@]}")
|
||||
output="output/dsc"
|
||||
@@ -146,9 +155,9 @@ for item in "${allowed_circuits[@]}"; do
|
||||
circuit_name="${filename%.*}"
|
||||
(
|
||||
circom $filepath \
|
||||
-l "node_modules" \
|
||||
-l "node_modules/@zk-kit/binary-merkle-root.circom/src" \
|
||||
-l "node_modules/circomlib/circuits" \
|
||||
-l "circuits/node_modules" \
|
||||
-l "circuits/node_modules/@zk-kit/binary-merkle-root.circom/src" \
|
||||
-l "circuits/node_modules/circomlib/circuits" \
|
||||
--O1 --r1cs --wasm --output $output
|
||||
) &
|
||||
pids+=($!)
|
||||
|
||||
@@ -51,7 +51,6 @@ describe('REGISTER KYC Circuit Tests', () => {
|
||||
KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH
|
||||
);
|
||||
const nullifierInputs = [
|
||||
...'sumsub'.split('').map((x) => x.charCodeAt(0)),
|
||||
...idnumber,
|
||||
...input.data_padded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
|
||||
];
|
||||
|
||||
@@ -41,9 +41,12 @@ export function deserializeApplicantInfo(
|
||||
const country = applicantInfo
|
||||
.slice(KYC_COUNTRY_INDEX, KYC_COUNTRY_INDEX + KYC_COUNTRY_LENGTH)
|
||||
.replace(/\x00/g, '');
|
||||
const idType = applicantInfo
|
||||
.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH)
|
||||
.replace(/\x00/g, '');
|
||||
const idTypeRaw = applicantInfo.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH);
|
||||
const nsLen = idTypeRaw.charCodeAt(0);
|
||||
const idType =
|
||||
nsLen > 0 && nsLen < KYC_ID_TYPE_LENGTH
|
||||
? idTypeRaw.slice(1 + nsLen).replace(/\x00/g, '')
|
||||
: idTypeRaw.replace(/\x00/g, '');
|
||||
const idNumber = applicantInfo
|
||||
.slice(KYC_ID_NUMBER_INDEX, KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH)
|
||||
.replace(/\x00/g, '');
|
||||
|
||||
@@ -2,21 +2,21 @@ import { poseidon2 } from 'poseidon-lite';
|
||||
|
||||
import { packBytesAndPoseidon } from '../hash.js';
|
||||
import { IDDocument, isKycDocument } from '../types.js';
|
||||
import { deserializeApplicantInfo } from './api.js';
|
||||
import {
|
||||
KYC_ID_NUMBER_INDEX,
|
||||
KYC_ID_NUMBER_LENGTH,
|
||||
KYC_ID_TYPE_INDEX,
|
||||
KYC_ID_TYPE_LENGTH,
|
||||
} from './constants.js';
|
||||
import { serializeKycData } from './types.js';
|
||||
|
||||
const decodeRawBytes = (base64: string): number[] => {
|
||||
const raw = Buffer.from(base64, 'base64');
|
||||
return Array.from(raw, (b) => Number(b));
|
||||
};
|
||||
|
||||
export const generateKycCommitment = (passportData: IDDocument, secret: string) => {
|
||||
if (isKycDocument(passportData)) {
|
||||
const applicantInfo = deserializeApplicantInfo(passportData.serializedApplicantInfo);
|
||||
const serializedData = serializeKycData(applicantInfo);
|
||||
const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
|
||||
const dataPadded = msgPadded.map((x) => Number(x));
|
||||
const dataPadded = decodeRawBytes(passportData.serializedApplicantInfo);
|
||||
const commitment = poseidon2([secret, packBytesAndPoseidon(dataPadded)]);
|
||||
return commitment.toString();
|
||||
}
|
||||
@@ -24,16 +24,12 @@ export const generateKycCommitment = (passportData: IDDocument, secret: string)
|
||||
|
||||
export const generateKycNullifier = (passportData: IDDocument) => {
|
||||
if (isKycDocument(passportData)) {
|
||||
const applicantInfo = deserializeApplicantInfo(passportData.serializedApplicantInfo);
|
||||
const serializedData = serializeKycData(applicantInfo);
|
||||
const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
|
||||
const dataPadded = msgPadded.map((x) => Number(x));
|
||||
const dataPadded = decodeRawBytes(passportData.serializedApplicantInfo);
|
||||
const idNumber = dataPadded.slice(
|
||||
KYC_ID_NUMBER_INDEX,
|
||||
KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH
|
||||
);
|
||||
const nullifierInputs = [
|
||||
...'sumsub'.split('').map((x) => x.charCodeAt(0)),
|
||||
...idNumber,
|
||||
...dataPadded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
|
||||
];
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
KYC_ID_TYPE_INDEX,
|
||||
KYC_ID_TYPE_LENGTH,
|
||||
} from '../kyc/constants.js';
|
||||
import { serializeKycData } from '../kyc/types.js';
|
||||
import { sha384_512Pad, shaPad } from '../shaPad.js';
|
||||
import { getLeafDscTree } from '../trees.js';
|
||||
import type { DocumentCategory, IDDocument, PassportData, SignatureAlgorithm } from '../types.js';
|
||||
@@ -209,16 +208,13 @@ export function generateNullifier(passportData: IDDocument) {
|
||||
return nullifierHash(passportData.extractedFields);
|
||||
}
|
||||
if (isKycDocument(passportData)) {
|
||||
const applicantInfo = deserializeApplicantInfo(passportData.serializedApplicantInfo);
|
||||
const serializedData = serializeKycData(applicantInfo);
|
||||
const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
|
||||
const dataPadded = msgPadded.map((x) => Number(x));
|
||||
const raw = Buffer.from(passportData.serializedApplicantInfo, 'base64');
|
||||
const dataPadded = Array.from(raw, (b) => Number(b));
|
||||
const idNumber = dataPadded.slice(
|
||||
KYC_ID_NUMBER_INDEX,
|
||||
KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH
|
||||
);
|
||||
const nullifierInputs = [
|
||||
...'sumsub'.split('').map((x) => x.charCodeAt(0)),
|
||||
...idNumber,
|
||||
...dataPadded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
|
||||
];
|
||||
|
||||
@@ -90,13 +90,13 @@ export interface PassportData extends BaseIDData {
|
||||
passportMetadata?: PassportMetadata;
|
||||
}
|
||||
|
||||
// pending - pending sumsub verification
|
||||
// processing - sumsub verification completed and pending onchain confirmation
|
||||
// failed - sumsub verification failed
|
||||
// pending - pending didit verification
|
||||
// processing - didit verification completed and pending onchain confirmation
|
||||
// failed - didit verification failed
|
||||
export type PendingKycStatus = 'pending' | 'processing' | 'failed';
|
||||
|
||||
export interface PendingKycVerification {
|
||||
userId: string; // Correlation key from fetchAccessToken()
|
||||
sessionId: string; // Correlation key from createSession()
|
||||
createdAt: number; // Timestamp when verification started
|
||||
status: PendingKycStatus; // Current status
|
||||
errorMessage?: string; // Error message if failed
|
||||
|
||||
160
docs/reviews/2026-03-23-euclid-settings-tunnel-audit.md
Normal file
160
docs/reviews/2026-03-23-euclid-settings-tunnel-audit.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# PR Audit — feat/euclid-settings-screens-rd2
|
||||
|
||||
> Date: 2026-03-23
|
||||
> Branch: `feat/euclid-settings-screens-rd2`
|
||||
> Base: `dev`
|
||||
> PR: #1858
|
||||
> Reviewers: Claude Code, Codex
|
||||
> Status: Ship as-is with follow-up issues for known gaps
|
||||
|
||||
## Follow-up Issues
|
||||
|
||||
| Issue | Priority | Title |
|
||||
| ------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------ |
|
||||
| [SELF-2357](https://linear.app/selfprotocol/issue/SELF-2357) | Urgent | Euclid migration — complete euclid-web → euclid and validate API compatibility |
|
||||
| [SELF-2358](https://linear.app/selfprotocol/issue/SELF-2358) | High | Sumsub / WV-05 contract compliance |
|
||||
| [SELF-2359](https://linear.app/selfprotocol/issue/SELF-2359) | High | Tunnel + proving flow data propagation and UI contract fixes |
|
||||
| [SELF-2360](https://linear.app/selfprotocol/issue/SELF-2360) | Medium | Settings persistence, test coverage, and doc/spec cleanup |
|
||||
|
||||
Each issue has a spec attached as a Linear document.
|
||||
|
||||
## Summary
|
||||
|
||||
This PR adds Euclid 3.0 settings sub-screens (Security, Notifications, Dev Mode), a PoC tunnel flow, the `euclid-web` → `euclid` migration, and a Sumsub Web SDK integration in the provider launch flow. It ships with known gaps that are tracked as follow-up issues.
|
||||
|
||||
## Merged Findings
|
||||
|
||||
Findings from both Claude Code and Codex reviews, deduplicated and grouped by follow-up issue.
|
||||
|
||||
### Issue 1: Euclid migration — complete `euclid-web` → `euclid` and validate exports
|
||||
|
||||
**Severity:** Critical (build-breaking)
|
||||
|
||||
- 3 settings screen components (`SecurityScreen`, `NotificationPreferencesScreen`, `DevModeScreen`) are imported from `@selfxyz/euclid` but not yet exported from the package
|
||||
- `LaunchTour1Screen`–`LaunchTour4Screen` don't exist — Euclid only exports a single `TourScreen`
|
||||
- `ProofGenerationScreen` doesn't exist — only a `ProofGeneration` component (not a screen wrapper)
|
||||
- ~~`ProviderResultScreen` still imports from `@selfxyz/euclid-web`~~ — **fixed** (migrated to `@selfxyz/euclid`)
|
||||
- ~~`ProviderLaunchScreen` still imports from `@selfxyz/euclid-web`~~ — **fixed** (migrated to `@selfxyz/euclid`, removed unused `BodyText`)
|
||||
- Multiple screens migrated from `euclid-web` to `euclid` — blast radius is package-wide, not just settings
|
||||
- `euclid-web` is being retired; all imports should use `@selfxyz/euclid`
|
||||
- Settings handover doc (`docs/superpowers/plans/2026-03-22-settings-handover.md (legacy location)`) says the app still imports `euclid-web` — stale
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- All imports use `@selfxyz/euclid`, zero references to `euclid-web`
|
||||
- All imported screen components exist in Euclid's exports
|
||||
- `yarn workspace @selfxyz/webview-app build` passes
|
||||
|
||||
### Issue 2: Sumsub / WV-05 contract compliance
|
||||
|
||||
**Severity:** High (incorrect behavior)
|
||||
|
||||
- `normalizeSumsubStatus()` marks `reviewAnswer === 'GREEN'` as `status: 'success'` without requiring `attestation` — breaks the KYC contract
|
||||
- `onApplicantSubmitted` emits `status: 'partial'` through the `onComplete` callback (not `onError`), so `ProviderResultScreen` treats it as success and routes to `/proving`
|
||||
- `emitOnce` guard means if `onApplicantSubmitted` fires before `applicantReviewComplete`, the actual terminal status is silently dropped
|
||||
- `ProvingScreen` fabricates a successful `VerificationResult` without consuming any provider attestation payload
|
||||
- `teeUrl` not parsed from launch URL/query params — `fetchSumsubAccessToken()` uses `VITE_SUMSUB_TEE_URL` or hardcoded default
|
||||
- `fetchSumsubAccessToken` signature doesn't accept `teeUrl` as a parameter — even after parsing, plumbing is missing
|
||||
- Token refresh callback in `launchSumsubWebSdk` (line 167) also calls `fetchSumsubAccessToken()` with no args — refresh hits hardcoded URL too
|
||||
- `sumsub-websdk.d.ts` custom type declaration added — unclear if it matches actual SDK API or is a stub
|
||||
- WV-05 plan says "code complete, needs testing" but `teeUrl` isn't parsed, service file structure wasn't created, and normalization doesn't match the contract
|
||||
- Invalid step value: `TunnelProvingScreen` passes `step="generatingProof"` — valid values are `"registeringId" | "generatingProof1" | "awaitingVerification" | "finishingUp"`
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- `normalizeSumsubStatus` requires attestation for `success`
|
||||
- `partial` status blocked from entering proving flow
|
||||
- `teeUrl` parsed from query params, threaded into token fetch and refresh
|
||||
- WV-05 plan status downgraded to "partial implementation"
|
||||
|
||||
### Issue 3: Tunnel flow correctness and state propagation
|
||||
|
||||
**Severity:** High (data loss / broken UX)
|
||||
|
||||
- Selected ID type discarded in `TunnelIDTypeScreen.onIDTypeSelect` — ignores the param, always navigates to `/tunnel/proof/receipt`
|
||||
- State passed via `location.state` is fragile — lost on refresh or direct navigation, no fallback or route guard
|
||||
- `TunnelProofReceiptScreen` passes invalid `documentType="passport"` prop to `ProofRequestScreen` (not a valid prop)
|
||||
- `TunnelProofReceiptScreen` mock items lack icons and `onInfoPress` callbacks
|
||||
- `KycMockScreen` is raw HTML divs with inline styles instead of Euclid components
|
||||
- `TunnelResultScreen` missing `animationSource` (Lottie) — uses icon only
|
||||
- `Date.now()` in `TunnelProofReceiptScreen` creates new timestamp on every render
|
||||
- Main proving flow (`ProvingScreen.tsx`) hardcodes `documentType='passport'` — affects primary flow, not just tunnel
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- ID type selection persisted and forwarded through the tunnel chain
|
||||
- Screens handle missing state gracefully (redirect or error)
|
||||
- Correct Euclid component props used throughout
|
||||
|
||||
### Issue 4: Settings persistence and bridge-backed actions
|
||||
|
||||
**Severity:** Medium (non-functional features)
|
||||
|
||||
- `SecurityScreen` backup state is hardcoded `false` — should query actual state from bridge/storage
|
||||
- `SecurityScreen` handlers for backup, recovery phrase, restore all navigate to `/coming-soon`
|
||||
- `NotificationPreferencesScreen` toggles are local `useState` — lost on reload, never persisted
|
||||
- `DevModeScreen` mock config is local state — lost on reload
|
||||
- `DevModeScreen` "Generate mock document" fires analytics but doesn't create or store anything
|
||||
- `SettingsScreen` "Manage Documents", "Get support", "Share Self" all navigate to `/coming-soon`
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Settings state backed by bridge/storage where applicable
|
||||
- Placeholder routes documented as intentional
|
||||
|
||||
### Issue 5: Test coverage
|
||||
|
||||
**Severity:** Medium
|
||||
|
||||
- No tests for Sumsub normalization/result mapping (`normalizeSumsubStatus`, `buildProviderResult`)
|
||||
- No route tests for tunnel flow progression
|
||||
- No smoke tests for settings wrapper navigation and action wiring
|
||||
- No tests for provider launch/result flow
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Unit tests for Sumsub normalization logic
|
||||
- Route/navigation tests for tunnel flow
|
||||
- Smoke tests for settings screens
|
||||
|
||||
### Issue 6: Doc/spec drift
|
||||
|
||||
**Severity:** Low
|
||||
|
||||
- Settings handover doc (`docs/superpowers/plans/2026-03-22-settings-handover.md (legacy location)`) is stale — references `euclid-web`, understates scope
|
||||
- Settings integration plan (`docs/superpowers/plans/2026-03-22-settings-screen-integration.md (legacy location)`) scoped to settings only but branch includes tunnel/provider/migration
|
||||
- WV-05 plan status overstated
|
||||
- Orphaned route: `/onboarding/confirm` defined in App.tsx but never navigated to
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Docs reflect actual branch state
|
||||
- WV-05 status corrected
|
||||
- Orphaned route removed or documented
|
||||
|
||||
## Actionable PR feedback (CodeRabbit + Codex connector)
|
||||
|
||||
Extracted from PR #1858 inline comments. Items already covered above are not repeated.
|
||||
|
||||
### Build-breaking (addressed in this branch)
|
||||
|
||||
- **`@selfxyz/euclid-web` removed from `package.json` but still imported** — `ProviderLaunchScreen` and `ProviderResultScreen` still referenced it. Both migrated to `@selfxyz/euclid` in this branch. (CodeRabbit P1, Codex connector P1)
|
||||
|
||||
### Actionable (not yet addressed)
|
||||
|
||||
- **`TunnelIDTypeScreen`: pass selected `idType` to next screen** — `onIDTypeSelect` ignores the param. Suggested fix: `navigate('/tunnel/proof/receipt', { state: { documentType: idType.id } })`. (CodeRabbit, mapped to Issue 3)
|
||||
- **`TunnelProofReceiptScreen`: read document type from route state** — Currently hardcodes `documentType="passport"`. Should read from `location.state` with fallback. (CodeRabbit, mapped to Issue 3)
|
||||
- **`NotificationPreferencesScreen`: persist toggles** — Use `storage` adapter from `useSelfClient()` to save/load preferences. (CodeRabbit, mapped to Issue 4)
|
||||
- **`DevModeScreen`: mock document generation is a no-op** — Should either persist the mock or show a toast indicating the feature isn't functional yet. (CodeRabbit, mapped to Issue 4)
|
||||
|
||||
### Nitpicks (low priority)
|
||||
|
||||
- **`NotificationPreferencesScreen`: memoize toggle handlers** — `toggles` array recreated every render. `useMemo` with `toggleValues` as dependency would be cleaner.
|
||||
|
||||
## What works well
|
||||
|
||||
- Route wiring is clean — every route has a file, every `navigate()` targets a defined route
|
||||
- Settings menu restructure (App settings / Support & feedback / Developer tools) is well-organized
|
||||
- Sumsub SDK lifecycle management (mount/destroy/abort) is properly handled
|
||||
- Analytics events are consistently tracked across all screens
|
||||
- `emitOnce` pattern prevents duplicate callbacks (though it has the silenced-review-status side effect)
|
||||
102
docs/superpowers/plans/2026-03-22-settings-handover.md
Normal file
102
docs/superpowers/plans/2026-03-22-settings-handover.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Euclid 3.0 Settings Integration — Handover
|
||||
|
||||
**Branch:** `feat/euclid-settings-screens` (based off `origin/main`)
|
||||
**Commit:** `feat(webview-app): add Euclid 3.0 settings sub-screens`
|
||||
|
||||
## What Was Done
|
||||
|
||||
Three new wrapper screens were added to `packages/webview-app/src/screens/account/`:
|
||||
|
||||
| File | Euclid Component | Route |
|
||||
| ----------------------------------- | ------------------------------- | ------------------------- |
|
||||
| `SecurityScreen.tsx` | `SecurityScreen` | `/settings/security` |
|
||||
| `NotificationPreferencesScreen.tsx` | `NotificationPreferencesScreen` | `/settings/notifications` |
|
||||
| `DevModeScreen.tsx` | `DevModeScreen` | `/settings/dev-mode` |
|
||||
|
||||
Routes added to `App.tsx`. `SettingsScreen.tsx` updated with three sections (App settings, Support & feedback, Developer tools) — "Security", "Notifications", and "Dev mode" now navigate to real screens instead of `/coming-soon`.
|
||||
|
||||
Each wrapper follows the existing pattern (see `CountryPickerScreen.tsx`): import Euclid component, wire with `useNavigate()` + `useSelfClient()` bridge adapters, manage local UI state.
|
||||
|
||||
## Blocker: Euclid Package Publish
|
||||
|
||||
The installed `@selfxyz/euclid-web@1.0.2` does **not** export `SecurityScreen`, `NotificationPreferencesScreen`, or `DevModeScreen`. These screens exist on `origin/main` of the **euclid repo** (`/Users/evinova-self/Documents/euclid`) but haven't been published to npm yet.
|
||||
|
||||
**To unblock, you need to publish a new version of `@selfxyz/euclid`** (note: package was renamed from `euclid-web` to `euclid` on euclid's `origin/main`).
|
||||
|
||||
Steps:
|
||||
|
||||
1. In the euclid repo, check out `origin/main` and verify the screens export: `git show origin/main:packages/euclid/src/screens/index.ts`
|
||||
2. Bump the version and publish (the euclid repo has automated publishing via PR merge of version bump PRs)
|
||||
3. In the self repo, update `packages/webview-app/package.json` to use the new version (and rename dependency from `@selfxyz/euclid-web` to `@selfxyz/euclid` if the package name changed)
|
||||
4. Run `yarn install` to pull the new package
|
||||
5. Run `yarn workspace @selfxyz/webview-app exec tsc --noEmit` to verify type-check passes
|
||||
|
||||
### Package Rename Note
|
||||
|
||||
The euclid repo renamed the web package from `@selfxyz/euclid-web` to `@selfxyz/euclid`. The webview-app still imports from `@selfxyz/euclid-web`. When updating the dependency, you'll also need to update all import paths across the webview-app screens:
|
||||
|
||||
```
|
||||
- import { ... } from '@selfxyz/euclid-web';
|
||||
+ import { ... } from '@selfxyz/euclid';
|
||||
```
|
||||
|
||||
## Pre-Existing Type Errors
|
||||
|
||||
These errors exist on `origin/main` independent of our changes:
|
||||
|
||||
- `BridgeProvider.tsx` — `browserHost` not in `WebViewBridgeOptions`
|
||||
- `SelfClientProvider.tsx` — `lifecycle.dismiss()` argument mismatch
|
||||
- `ConfirmIdentificationScreen.tsx`, `ProvingScreen.tsx`, `VerificationResultScreen.tsx` — `VerificationResult` type mismatch
|
||||
- `ProviderLaunchScreen.tsx` — `lifecycle.dismiss()` argument mismatch
|
||||
|
||||
## What Still Uses `/coming-soon`
|
||||
|
||||
These items in SettingsScreen still navigate to `/coming-soon` (no Euclid screen exists yet):
|
||||
|
||||
- **"Manage Documents"** — needs a document management screen
|
||||
- **"Get support"** — needs external link / bridge intent
|
||||
- **"Share Self"** — needs native share sheet via bridge
|
||||
|
||||
Within the sub-screens, these actions are also stubbed:
|
||||
|
||||
- **SecurityScreen**: "Backup your account", "Reveal recovery phrase", "Restore an account" → all go to `/coming-soon` (need native bridge integration for biometric auth, iCloud backup, wallet restore)
|
||||
- **NotificationPreferencesScreen**: Toggle state is local-only (needs bridge storage persistence)
|
||||
- **DevModeScreen**: "Generate mock document" tracks analytics and navigates home but doesn't actually create a mock document yet
|
||||
|
||||
## Navigation Flow
|
||||
|
||||
```
|
||||
/settings (SettingsViewScreen)
|
||||
├── Manage Documents → /coming-soon
|
||||
├── Security → /settings/security ✅ NEW
|
||||
│ ├── Backup your account → /coming-soon
|
||||
│ ├── Reveal recovery phrase → /coming-soon
|
||||
│ ├── Restore an account → /coming-soon
|
||||
│ └── Disable backups → local dialogue toggle
|
||||
├── Notifications → /settings/notifications ✅ NEW
|
||||
│ └── Toggles → local state only
|
||||
├── Get support → /coming-soon
|
||||
├── Share Self → /coming-soon
|
||||
├── Dev mode → /settings/dev-mode ✅ NEW
|
||||
│ ├── Steppers/toggles → local state
|
||||
│ ├── Generate mock document → analytics + navigate home
|
||||
│ └── Reset all values → reset state
|
||||
└── Close Self → lifecycle.dismiss()
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
After unblocking the euclid dependency:
|
||||
|
||||
```bash
|
||||
yarn workspace @selfxyz/webview-app exec tsc --noEmit # type-check
|
||||
yarn workspace @selfxyz/webview-app build # Vite production build
|
||||
yarn lint # lint
|
||||
```
|
||||
|
||||
## Related Resources
|
||||
|
||||
- **Implementation plan:** `docs/superpowers/plans/2026-03-22-settings-screen-integration.md`
|
||||
- **Euclid screen source:** `euclid repo origin/main:packages/euclid/src/screens/settings/`
|
||||
- **Euclid storybook stories:** `euclid repo origin/main:packages/storybook/stories/*Screen.stories.tsx`
|
||||
- **Linear tickets:** SELF-2223 (Settings), SELF-2311 (Update core app)
|
||||
667
docs/superpowers/plans/2026-03-22-settings-screen-integration.md
Normal file
667
docs/superpowers/plans/2026-03-22-settings-screen-integration.md
Normal file
@@ -0,0 +1,667 @@
|
||||
# Settings Screen Integration — Import Euclid 3.0 Screens into WebView App
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the five `/coming-soon` navigations in the webview-app's SettingsScreen with real Euclid 3.0 sub-screens (Security, Notifications, Dev Mode), plus wire the remaining menu items to appropriate bridge actions.
|
||||
|
||||
**Architecture:** Each settings sub-screen gets a thin wrapper in `packages/webview-app/src/screens/account/` that imports the Euclid component from `@selfxyz/euclid-web`, wires it with `useSelfClient()` bridge adapters and `useNavigate()` from React Router, and manages local UI state (e.g. dialogue visibility, toggle values). Routes are added to `App.tsx`. The existing wrapper pattern (see `CountryPickerScreen.tsx`, `ComingSoonScreen.tsx`) is followed exactly.
|
||||
|
||||
**Tech Stack:** React, React Router, `@selfxyz/euclid-web` (Euclid 3.0 component library), `@selfxyz/webview-bridge` (bridge adapters)
|
||||
|
||||
**Existing pattern to follow:**
|
||||
|
||||
```
|
||||
// webview-app wrapper screen pattern:
|
||||
import { EuclidScreen } from '@selfxyz/euclid-web';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const WrapperScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic, lifecycle } = useSelfClient();
|
||||
// Wire Euclid props to bridge adapters + React Router
|
||||
return <EuclidScreen {...wiredProps} />;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File | Responsibility |
|
||||
| ------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| Modify | `packages/webview-app/src/App.tsx` | Add 3 new routes |
|
||||
| Modify | `packages/webview-app/src/screens/account/SettingsScreen.tsx` | Replace `/coming-soon` navigations with real routes + bridge actions |
|
||||
| Create | `packages/webview-app/src/screens/account/SecurityScreen.tsx` | Wrapper for Euclid `SecurityScreen` |
|
||||
| Create | `packages/webview-app/src/screens/account/NotificationPreferencesScreen.tsx` | Wrapper for Euclid `NotificationPreferencesScreen` |
|
||||
| Create | `packages/webview-app/src/screens/account/DevModeScreen.tsx` | Wrapper for Euclid `DevModeScreen` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: SecurityScreen wrapper
|
||||
|
||||
The most important sub-screen. Wires backup state, recovery phrase, and restore actions through the bridge.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/webview-app/src/screens/account/SecurityScreen.tsx`
|
||||
|
||||
- [ ] **Step 1: Create SecurityScreen wrapper**
|
||||
|
||||
```tsx
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
SecurityScreen as EuclidSecurityScreen,
|
||||
LeftArrowIcon,
|
||||
CloudKeyIcon,
|
||||
LockIcon,
|
||||
ZapShieldIcon,
|
||||
} from '@selfxyz/euclid-web';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
|
||||
export const SecurityScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic, storage } = useSelfClient();
|
||||
const [isBackupEnabled, setIsBackupEnabled] = useState(false);
|
||||
const [showDisableDialogue, setShowDisableDialogue] = useState(false);
|
||||
|
||||
// TODO: Read actual backup state from bridge storage on mount
|
||||
// useEffect(() => { storage.get('backup_enabled').then(...) }, []);
|
||||
|
||||
const onBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onBackupAccount = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('security_backup_account_pressed');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onRevealRecoveryPhrase = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('security_reveal_phrase_pressed');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onRestoreAccount = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('security_restore_account_pressed');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onDisableBackups = useCallback(() => {
|
||||
haptic.trigger('warning');
|
||||
setShowDisableDialogue(true);
|
||||
}, [haptic]);
|
||||
|
||||
const onDisableICloudBackups = useCallback(() => {
|
||||
haptic.trigger('warning');
|
||||
analytics.trackEvent('security_backups_disabled');
|
||||
setIsBackupEnabled(false);
|
||||
setShowDisableDialogue(false);
|
||||
// TODO: Persist via bridge storage
|
||||
}, [haptic, analytics]);
|
||||
|
||||
const onDismissDialogue = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
setShowDisableDialogue(false);
|
||||
}, [haptic]);
|
||||
|
||||
return (
|
||||
<EuclidSecurityScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
escapeIcon={({ size, color }) => (
|
||||
<LeftArrowIcon size={size} color={color} />
|
||||
)}
|
||||
cloudKeyIcon={CloudKeyIcon}
|
||||
lockIcon={LockIcon}
|
||||
zapShieldIcon={ZapShieldIcon}
|
||||
isBackupEnabled={isBackupEnabled}
|
||||
onBack={onBack}
|
||||
onBackupAccount={onBackupAccount}
|
||||
onRevealRecoveryPhrase={onRevealRecoveryPhrase}
|
||||
onRestoreAccount={onRestoreAccount}
|
||||
onDisableBackups={onDisableBackups}
|
||||
showDisableDialogue={showDisableDialogue}
|
||||
onDisableICloudBackups={onDisableICloudBackups}
|
||||
onDismissDialogue={onDismissDialogue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check passes**
|
||||
|
||||
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
|
||||
Expected: No errors related to SecurityScreen
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/webview-app/src/screens/account/SecurityScreen.tsx
|
||||
git commit -m "feat(webview-app): add SecurityScreen wrapper for Euclid 3.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: NotificationPreferencesScreen wrapper
|
||||
|
||||
Manages toggle state locally (persisted via bridge storage in future).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/webview-app/src/screens/account/NotificationPreferencesScreen.tsx`
|
||||
|
||||
- [ ] **Step 1: Create NotificationPreferencesScreen wrapper**
|
||||
|
||||
```tsx
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
NotificationPreferencesScreen as EuclidNotificationPreferencesScreen,
|
||||
LeftArrowIcon,
|
||||
} from '@selfxyz/euclid-web';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
|
||||
const defaultToggles = [
|
||||
{
|
||||
key: 'self',
|
||||
label: 'Allow Self notifications',
|
||||
description: 'App updates and more',
|
||||
},
|
||||
{
|
||||
key: 'nova',
|
||||
label: 'Allow Nova notifications',
|
||||
description: 'Never miss a mission',
|
||||
},
|
||||
{
|
||||
key: 'points',
|
||||
label: 'Allow Self Points notifications',
|
||||
description: 'Points and rewards',
|
||||
},
|
||||
{
|
||||
key: 'id_status',
|
||||
label: 'Allow ID status notifications',
|
||||
description: 'Document verification updates',
|
||||
},
|
||||
];
|
||||
|
||||
export const NotificationPreferencesScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
const [toggleValues, setToggleValues] = useState<Record<string, boolean>>({
|
||||
self: true,
|
||||
nova: true,
|
||||
points: true,
|
||||
id_status: false,
|
||||
});
|
||||
|
||||
const onBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const toggles = defaultToggles.map(t => ({
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
value: toggleValues[t.key] ?? false,
|
||||
onToggleChange: (value: boolean) => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('notification_toggle_changed', {
|
||||
key: t.key,
|
||||
value,
|
||||
});
|
||||
setToggleValues(prev => ({ ...prev, [t.key]: value }));
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<EuclidNotificationPreferencesScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
escapeIcon={({ size, color }) => (
|
||||
<LeftArrowIcon size={size} color={color} />
|
||||
)}
|
||||
onBack={onBack}
|
||||
toggles={toggles}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check passes**
|
||||
|
||||
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
|
||||
Expected: No errors related to NotificationPreferencesScreen
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/webview-app/src/screens/account/NotificationPreferencesScreen.tsx
|
||||
git commit -m "feat(webview-app): add NotificationPreferencesScreen wrapper for Euclid 3.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: DevModeScreen wrapper
|
||||
|
||||
Manages mock document generation state. More complex — has steppers, dropdowns, toggle, and IDCard display.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/webview-app/src/screens/account/DevModeScreen.tsx`
|
||||
|
||||
- [ ] **Step 1: Create DevModeScreen wrapper**
|
||||
|
||||
```tsx
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
DevModeScreen as EuclidDevModeScreen,
|
||||
LeftArrowIcon,
|
||||
} from '@selfxyz/euclid-web';
|
||||
import type { IDCardProps } from '@selfxyz/euclid-web';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
|
||||
const ageOptions = ['18 or older', '21 or older', '25 or older', '30 or older'];
|
||||
const expiryOptions = ['1 year', '2 years', '5 years', '10 years'];
|
||||
|
||||
export const DevModeScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const [documentType, setDocumentType] = useState('passport');
|
||||
const [nationality, setNationality] = useState('united states of america');
|
||||
const [ageIndex, setAgeIndex] = useState(1);
|
||||
const [expiryIndex, setExpiryIndex] = useState(2);
|
||||
const [ofacCheck, setOfacCheck] = useState(true);
|
||||
|
||||
const idCard: IDCardProps = {
|
||||
variant: 'dev-passport',
|
||||
title: 'Developer Passport',
|
||||
subtitle: 'Digital credential for developers',
|
||||
};
|
||||
|
||||
const onBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onResetAllValues = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('dev_mode_reset');
|
||||
setDocumentType('passport');
|
||||
setNationality('united states of america');
|
||||
setAgeIndex(1);
|
||||
setExpiryIndex(2);
|
||||
setOfacCheck(true);
|
||||
}, [haptic, analytics]);
|
||||
|
||||
const onGenerateMockDocument = useCallback(() => {
|
||||
haptic.trigger('success');
|
||||
analytics.trackEvent('dev_mode_generate_mock', {
|
||||
documentType,
|
||||
nationality,
|
||||
age: ageOptions[ageIndex],
|
||||
expiresIn: expiryOptions[expiryIndex],
|
||||
ofacCheck,
|
||||
});
|
||||
navigate('/');
|
||||
}, [
|
||||
navigate,
|
||||
haptic,
|
||||
analytics,
|
||||
documentType,
|
||||
nationality,
|
||||
ageIndex,
|
||||
expiryIndex,
|
||||
ofacCheck,
|
||||
]);
|
||||
|
||||
return (
|
||||
<EuclidDevModeScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
escapeIcon={({ size, color }) => (
|
||||
<LeftArrowIcon size={size} color={color} />
|
||||
)}
|
||||
onBack={onBack}
|
||||
idCard={idCard}
|
||||
documentType={documentType}
|
||||
onDocumentTypePress={() => {
|
||||
setDocumentType(prev => (prev === 'passport' ? 'id_card' : 'passport'));
|
||||
}}
|
||||
nationality={nationality}
|
||||
onNationalityPress={() => {
|
||||
setNationality(prev =>
|
||||
prev === 'united states of america'
|
||||
? 'germany'
|
||||
: 'united states of america',
|
||||
);
|
||||
}}
|
||||
age={ageOptions[ageIndex]}
|
||||
onAgeIncrement={() =>
|
||||
setAgeIndex(prev => Math.min(prev + 1, ageOptions.length - 1))
|
||||
}
|
||||
onAgeDecrement={() => setAgeIndex(prev => Math.max(prev - 1, 0))}
|
||||
documentExpiresIn={expiryOptions[expiryIndex]}
|
||||
onDocumentExpiresIncrement={() =>
|
||||
setExpiryIndex(prev => Math.min(prev + 1, expiryOptions.length - 1))
|
||||
}
|
||||
onDocumentExpiresDecrement={() =>
|
||||
setExpiryIndex(prev => Math.max(prev - 1, 0))
|
||||
}
|
||||
ofacCheck={ofacCheck}
|
||||
onOfacCheckChange={value => {
|
||||
haptic.trigger('selection');
|
||||
setOfacCheck(value);
|
||||
}}
|
||||
onResetAllValues={onResetAllValues}
|
||||
onGenerateMockDocument={onGenerateMockDocument}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check passes**
|
||||
|
||||
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
|
||||
Expected: No errors related to DevModeScreen
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/webview-app/src/screens/account/DevModeScreen.tsx
|
||||
git commit -m "feat(webview-app): add DevModeScreen wrapper for Euclid 3.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire routes in App.tsx
|
||||
|
||||
Add the three new routes under `/settings/*`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/webview-app/src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports and routes**
|
||||
|
||||
Add these imports after the existing `SettingsScreen` import (line 16):
|
||||
|
||||
```tsx
|
||||
import { SecurityScreen } from './screens/account/SecurityScreen';
|
||||
import { NotificationPreferencesScreen } from './screens/account/NotificationPreferencesScreen';
|
||||
import { DevModeScreen } from './screens/account/DevModeScreen';
|
||||
```
|
||||
|
||||
Add these routes after the `/settings` route (after line 34):
|
||||
|
||||
```tsx
|
||||
<Route path="/settings/security" element={<SecurityScreen />} />
|
||||
<Route path="/settings/notifications" element={<NotificationPreferencesScreen />} />
|
||||
<Route path="/settings/dev-mode" element={<DevModeScreen />} />
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check passes**
|
||||
|
||||
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/webview-app/src/App.tsx
|
||||
git commit -m "feat(webview-app): add settings sub-screen routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire SettingsScreen menu items to real routes
|
||||
|
||||
Replace the five `/coming-soon` navigations with real routes and bridge actions.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/webview-app/src/screens/account/SettingsScreen.tsx`
|
||||
|
||||
- [ ] **Step 1: Update SettingsScreen menu items**
|
||||
|
||||
Replace the full component with updated navigation wiring. Key changes:
|
||||
|
||||
- "View document info" → `navigate('/coming-soon')` (no Euclid screen for this yet — keep as-is)
|
||||
- "Recovery phrase" → `navigate('/settings/security')` (Security screen handles this)
|
||||
- "Cloud backup" → `navigate('/settings/security')` (Security screen handles this)
|
||||
- "Get support" → call `lifecycle.dismiss()` with support intent (or keep `/coming-soon`)
|
||||
- "Share Self" → call `lifecycle.dismiss()` with share intent (or keep `/coming-soon`)
|
||||
|
||||
Additionally, add the Settings sub-sections that match the Euclid Storybook stories:
|
||||
|
||||
- **App settings section**: Manage Documents, Security, Notifications
|
||||
- **Support section**: Support, Send feedback
|
||||
- **Developer tools section** (conditional): Dev mode
|
||||
|
||||
```tsx
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
SettingsViewScreen,
|
||||
LeftArrowIcon,
|
||||
QuestionCircleStrokeIcon,
|
||||
DocumentDetailsIcon,
|
||||
LockIcon,
|
||||
NotificationIcon,
|
||||
ChatStrokeIcon,
|
||||
ShareIcon,
|
||||
CodeIcon,
|
||||
} from '@selfxyz/euclid-web';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
|
||||
export const SettingsScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic, lifecycle } = useSelfClient();
|
||||
|
||||
const onBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onDismiss = useCallback(async () => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('settings_dismiss_pressed');
|
||||
lifecycle.dismiss();
|
||||
}, [haptic, analytics, lifecycle]);
|
||||
|
||||
return (
|
||||
<SettingsViewScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
escapeIcon={({ size, color }) => (
|
||||
<LeftArrowIcon size={size} color={color} />
|
||||
)}
|
||||
infoIcon={({ size, color }) => (
|
||||
<QuestionCircleStrokeIcon size={size} color={color} />
|
||||
)}
|
||||
onClose={onBack}
|
||||
showBackupInfoBox={false}
|
||||
isBackupEnabled={false}
|
||||
CTAs={[]}
|
||||
sections={[
|
||||
{
|
||||
title: 'App settings',
|
||||
items: [
|
||||
{
|
||||
icon: DocumentDetailsIcon,
|
||||
label: 'Manage Documents',
|
||||
description: 'Recovery phrase, passport data',
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('settings_manage_documents_pressed');
|
||||
navigate('/coming-soon');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: LockIcon,
|
||||
label: 'Security',
|
||||
description: 'Recovery phrase, passport data',
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('settings_security_pressed');
|
||||
navigate('/settings/security');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: NotificationIcon,
|
||||
label: 'Notifications',
|
||||
description: 'Preferences, notification types',
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('settings_notifications_pressed');
|
||||
navigate('/settings/notifications');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Support & feedback',
|
||||
items: [
|
||||
{
|
||||
icon: ChatStrokeIcon,
|
||||
label: 'Get support',
|
||||
description: 'Help center & support',
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('settings_support_pressed');
|
||||
navigate('/coming-soon');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ShareIcon,
|
||||
label: 'Share Self',
|
||||
description: 'Share Self with friends',
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('settings_share_pressed');
|
||||
navigate('/coming-soon');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Developer tools',
|
||||
items: [
|
||||
{
|
||||
icon: CodeIcon,
|
||||
label: 'Dev mode',
|
||||
description: 'Manage mock IDs, simulate proofs',
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('settings_dev_mode_pressed');
|
||||
navigate('/settings/dev-mode');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
connectHeading=""
|
||||
connectSubheading=""
|
||||
connectButtons={[]}
|
||||
bottomSectionItems={[
|
||||
{
|
||||
label: 'Close Self',
|
||||
onPress: onDismiss,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check passes**
|
||||
|
||||
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Verify Vite build succeeds**
|
||||
|
||||
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app build`
|
||||
Expected: Build succeeds, bundle output in `dist/`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/webview-app/src/screens/account/SettingsScreen.tsx
|
||||
git commit -m "feat(webview-app): wire settings menu to Security, Notifications, and DevMode screens"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final validation
|
||||
|
||||
- [ ] **Step 1: Full type-check**
|
||||
|
||||
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app exec tsc --noEmit`
|
||||
Expected: PASS with zero errors
|
||||
|
||||
- [ ] **Step 2: Vite production build**
|
||||
|
||||
Run: `cd /Users/evinova-self/Documents/self && yarn workspace @selfxyz/webview-app build`
|
||||
Expected: Build succeeds. Bundle size should be similar to before (~294 KB, may increase slightly with 3 new screens)
|
||||
|
||||
- [ ] **Step 3: Lint check**
|
||||
|
||||
Run: `cd /Users/evinova-self/Documents/self && yarn lint`
|
||||
Expected: No new lint errors in changed files
|
||||
|
||||
---
|
||||
|
||||
## Navigation Flow After Implementation
|
||||
|
||||
```
|
||||
/settings (SettingsViewScreen)
|
||||
├─ "Manage Documents" → /coming-soon (no screen yet)
|
||||
├─ "Security" → /settings/security (SecurityScreen)
|
||||
│ ├─ "Backup your account" → /coming-soon (future: backup flow)
|
||||
│ ├─ "Reveal recovery phrase" → /coming-soon (future: bridge to native)
|
||||
│ ├─ "Restore an account" → /coming-soon (future: restore flow)
|
||||
│ └─ "Disable backups" → shows dialogue → local state toggle
|
||||
├─ "Notifications" → /settings/notifications (NotificationPreferencesScreen)
|
||||
│ └─ Toggle changes → local state (future: bridge to native prefs)
|
||||
├─ "Get support" → /coming-soon (future: external link)
|
||||
├─ "Share Self" → /coming-soon (future: share sheet via bridge)
|
||||
├─ "Dev mode" → /settings/dev-mode (DevModeScreen)
|
||||
│ ├─ Steppers/toggles → local state
|
||||
│ ├─ "Generate mock document" → analytics event + navigate home
|
||||
│ └─ "Reset all values" → reset local state
|
||||
└─ "Close Self" → lifecycle.dismiss()
|
||||
```
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Persisting toggle/backup state via bridge storage (marked with `// TODO` comments)
|
||||
- Recovery phrase reveal flow (requires biometric auth via bridge)
|
||||
- Cloud backup flow (requires native iCloud/Google backup APIs)
|
||||
- Restore account flow (requires native wallet restore)
|
||||
- Support link / Share sheet (requires native intents via bridge)
|
||||
- Manage Documents screen (no Euclid screen exists yet)
|
||||
- `@selfxyz/euclid-web` → `@selfxyz/euclid` package rename (separate dependency update task)
|
||||
@@ -36,9 +36,12 @@ export function deserializeApplicantInfo(
|
||||
const country = applicantInfo
|
||||
.slice(KYC_COUNTRY_INDEX, KYC_COUNTRY_INDEX + KYC_COUNTRY_LENGTH)
|
||||
.replace(/\x00/g, '');
|
||||
const idType = applicantInfo
|
||||
.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH)
|
||||
.replace(/\x00/g, '');
|
||||
const idTypeRaw = applicantInfo.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH);
|
||||
const nsLen = idTypeRaw.charCodeAt(0);
|
||||
const idType =
|
||||
nsLen > 0 && nsLen < KYC_ID_TYPE_LENGTH
|
||||
? idTypeRaw.slice(1 + nsLen).replace(/\x00/g, '')
|
||||
: idTypeRaw.replace(/\x00/g, '');
|
||||
const idNumber = applicantInfo
|
||||
.slice(KYC_ID_NUMBER_INDEX, KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH)
|
||||
.replace(/\x00/g, '');
|
||||
|
||||
@@ -2,35 +2,31 @@ import { poseidon2 } from 'poseidon-lite';
|
||||
|
||||
import { packBytesAndPoseidon } from '../../crypto/hash/poseidon.js';
|
||||
import type { KycData as KycDocumentData } from '../../foundation/types/document.js';
|
||||
import { deserializeApplicantInfo } from './api.js';
|
||||
import {
|
||||
KYC_ID_NUMBER_INDEX,
|
||||
KYC_ID_NUMBER_LENGTH,
|
||||
KYC_ID_TYPE_INDEX,
|
||||
KYC_ID_TYPE_LENGTH,
|
||||
} from './constants.js';
|
||||
import { serializeKycData } from './types.js';
|
||||
|
||||
const decodeRawBytes = (base64: string): number[] => {
|
||||
const raw = Buffer.from(base64, 'base64');
|
||||
return Array.from(raw, b => Number(b));
|
||||
};
|
||||
|
||||
export const generateKycCommitment = (kycData: KycDocumentData, secret: string) => {
|
||||
const applicantInfo = deserializeApplicantInfo(kycData.serializedApplicantInfo);
|
||||
const serializedData = serializeKycData(applicantInfo);
|
||||
const msgPadded = Array.from(serializedData, x => x.charCodeAt(0));
|
||||
const dataPadded = msgPadded.map(x => Number(x));
|
||||
const dataPadded = decodeRawBytes(kycData.serializedApplicantInfo);
|
||||
const commitment = poseidon2([secret, packBytesAndPoseidon(dataPadded)]);
|
||||
return commitment.toString();
|
||||
};
|
||||
|
||||
export const generateKycNullifier = (kycData: KycDocumentData) => {
|
||||
const applicantInfo = deserializeApplicantInfo(kycData.serializedApplicantInfo);
|
||||
const serializedData = serializeKycData(applicantInfo);
|
||||
const msgPadded = Array.from(serializedData, x => x.charCodeAt(0));
|
||||
const dataPadded = msgPadded.map(x => Number(x));
|
||||
const dataPadded = decodeRawBytes(kycData.serializedApplicantInfo);
|
||||
const idNumber = dataPadded.slice(
|
||||
KYC_ID_NUMBER_INDEX,
|
||||
KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH,
|
||||
);
|
||||
const nullifierInputs = [
|
||||
...'sumsub'.split('').map(x => x.charCodeAt(0)),
|
||||
...idNumber,
|
||||
...dataPadded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
|
||||
];
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"build": "yarn workspaces foreach --topological-dev --parallel --exclude @selfxyz/contracts --exclude @selfxyz/circuits --exclude mobile-sdk-demo --exclude @selfxyz/kmp-sdk --exclude @selfxyz/kmp-sdk-test-app -i --all run build",
|
||||
"build:demo": "yarn workspace mobile-sdk-demo build",
|
||||
"build:mobile-sdk": "yarn workspace @selfxyz/mobile-sdk-alpha build",
|
||||
"build:sdk-android": "./scripts/build-webview-bundle.sh && cd packages/native-shell-android && ./gradlew assembleRelease",
|
||||
"build:sdk-bundle": "./scripts/build-webview-bundle.sh",
|
||||
"build:sdk-ios": "./scripts/build-webview-bundle.sh && cd packages/native-shell-ios && swift build",
|
||||
"check:versions": "node scripts/check-package-versions.mjs",
|
||||
"demo:mobile": "yarn build:mobile-sdk && yarn build:demo && yarn workspace mobile-sdk-demo start",
|
||||
"docstrings": "yarn docstrings:app && yarn docstrings:sdk",
|
||||
|
||||
@@ -142,14 +142,24 @@ yarn types # Verify type checking
|
||||
yarn build # Confirm build still works
|
||||
```
|
||||
|
||||
## SDK Architecture Specs
|
||||
## Linear Issue Interaction
|
||||
|
||||
For architecture context, implementation details, and workstream coordination:
|
||||
When working with Linear issues during development:
|
||||
|
||||
- **[SDK Overview](../../specs/projects/sdk/OVERVIEW.md)** — System architecture, bridge protocol, decision matrix
|
||||
- **[SDK Core Spec](../../specs/projects/sdk/workstreams/sdk-core/SPEC.md)** — Implementation chunks for this package (mobile-sdk-alpha)
|
||||
- **`save_comment`** for: status updates, progress notes, blockers, linking PRs, corrections, decision records
|
||||
- **`save_issue`** for: changing status, priority, assignee, labels (structured fields only)
|
||||
- **`create_document`** for: attaching specs as Linear documents
|
||||
|
||||
Before implementing SDK work, read `CLAUDE.md` Key Rules and `specs/projects/sdk/workstreams/sdk-core/SPEC.md` for constraints and validation commands.
|
||||
**Never overwrite an issue description.** Descriptions are the original scope set at creation time. All subsequent context goes in comments.
|
||||
|
||||
## SDK Architecture
|
||||
|
||||
For architecture context:
|
||||
|
||||
- **[SDK Overview](../../specs/projects/sdk/OVERVIEW.md)** — System architecture, bridge protocol, decision matrix (read-only reference)
|
||||
- **Implementation specs** — Canonical source is `specs/projects/sdk/workstreams/<scope>/plans/` (version-controlled). Linear documents attached to issues are mirrored copies for tracking/discovery. When in doubt, trust the repo spec.
|
||||
|
||||
Before implementing SDK work, read `CLAUDE.md` Key Rules for constraints and validation commands.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ export { createIndexedDBDocumentsAdapter } from './documents';
|
||||
export { createNoOpHapticAdapter } from './haptic';
|
||||
export { createWebAnalyticsAdapter } from './analytics';
|
||||
export { createWebCryptoAdapter } from './crypto';
|
||||
export { createWebNetworkAdapter } from './network';
|
||||
|
||||
36
packages/mobile-sdk-alpha/src/adapters/browser/network.ts
Normal file
36
packages/mobile-sdk-alpha/src/adapters/browser/network.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { NetworkAdapter, WsConn } from '../../types/public';
|
||||
|
||||
/**
|
||||
* Creates a {@link NetworkAdapter} backed by the platform's native `fetch` and
|
||||
* `WebSocket` globals. Works in any browser, WebView, or React Native context
|
||||
* where those globals are available.
|
||||
*/
|
||||
export function createWebNetworkAdapter(): NetworkAdapter {
|
||||
return {
|
||||
http: {
|
||||
fetch: (input: RequestInfo, init?: RequestInit) => fetch(input, init),
|
||||
},
|
||||
ws: {
|
||||
connect: (url: string): WsConn => {
|
||||
const socket = new WebSocket(url);
|
||||
return {
|
||||
send: (data: string | ArrayBufferView | ArrayBuffer) => socket.send(data),
|
||||
close: () => socket.close(),
|
||||
onMessage: cb => {
|
||||
socket.addEventListener('message', ev => cb((ev as MessageEvent).data));
|
||||
},
|
||||
onError: cb => {
|
||||
socket.addEventListener('error', e => cb(e));
|
||||
},
|
||||
onClose: cb => {
|
||||
socket.addEventListener('close', () => cb());
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,8 +12,10 @@ export type {
|
||||
ClockAdapter,
|
||||
Config,
|
||||
CryptoAdapter,
|
||||
DocumentCatalog,
|
||||
DocumentsAdapter,
|
||||
HttpAdapter,
|
||||
IDDocument,
|
||||
LogLevel,
|
||||
LoggerAdapter,
|
||||
MRZInfo,
|
||||
@@ -37,10 +39,9 @@ export type {
|
||||
export type { BaseContext, NFCScanContext, ProofContext } from './proving/internal/logging';
|
||||
export type { DG1, DG2, ParsedNFCResponse } from './nfc';
|
||||
export type { PassportValidationCallbacks } from './validation/document';
|
||||
export type { ProvingStateType, provingMachineCircuitType } from './proving/provingMachine';
|
||||
export type { ProvingState, ProvingStateType, provingMachineCircuitType } from './proving/provingMachine';
|
||||
export type { SDKEvent, SDKEventMap } from './types/events';
|
||||
export type { SdkErrorCategory } from './errors';
|
||||
|
||||
export type { WebAnalyticsOptions } from './adapters/browser';
|
||||
|
||||
export {
|
||||
@@ -76,6 +77,7 @@ export {
|
||||
createNoOpHapticAdapter,
|
||||
createWebAnalyticsAdapter,
|
||||
createWebCryptoAdapter,
|
||||
createWebNetworkAdapter,
|
||||
} from './adapters/browser';
|
||||
|
||||
export { createListenersMap, createSelfClient } from './client';
|
||||
@@ -87,6 +89,8 @@ export { extractMRZInfo, extractNameFromMRZ, formatDateToYYMMDD } from './mrz';
|
||||
|
||||
export { generateMockDocument, signatureAlgorithmToStrictSignatureAlgorithm } from './mock/generator';
|
||||
|
||||
export { getPostVerificationRoute, useProvingStore } from './proving/provingMachine';
|
||||
|
||||
export { isPassportDataValid } from './validation/document';
|
||||
|
||||
export { mergeConfig } from './config/merge';
|
||||
|
||||
62
packages/native-shell-android/build.gradle.kts
Normal file
62
packages/native-shell-android/build.gradle.kts
Normal file
@@ -0,0 +1,62 @@
|
||||
plugins {
|
||||
id("com.android.library") version "8.2.2"
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22"
|
||||
}
|
||||
|
||||
group = "xyz.self.sdk"
|
||||
|
||||
android {
|
||||
namespace = "xyz.self.sdk.nativeshell"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("validateWebViewBundle") {
|
||||
doLast {
|
||||
val bundleDir = file("src/main/assets/self-wallet")
|
||||
val indexFile = file("src/main/assets/self-wallet/index.html")
|
||||
if (!bundleDir.exists() || !indexFile.exists()) {
|
||||
throw GradleException(
|
||||
"WebView bundle not found at src/main/assets/self-wallet/index.html. " +
|
||||
"Run ./scripts/build-webview-bundle.sh from the repo root first."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("preBuild") {
|
||||
dependsOn("validateWebViewBundle")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.webkit:webkit:1.9.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
}
|
||||
4
packages/native-shell-android/consumer-rules.pro
Normal file
4
packages/native-shell-android/consumer-rules.pro
Normal file
@@ -0,0 +1,4 @@
|
||||
# Consumer ProGuard rules for native-shell-android
|
||||
# Keep bridge models for JSON serialization
|
||||
-keep class xyz.self.sdk.bridge.** { *; }
|
||||
-keep class xyz.self.sdk.api.** { *; }
|
||||
4
packages/native-shell-android/gradle.properties
Normal file
4
packages/native-shell-android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
packages/native-shell-android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
packages/native-shell-android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
packages/native-shell-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
packages/native-shell-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
networkTimeout=600000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
247
packages/native-shell-android/gradlew
vendored
Executable file
247
packages/native-shell-android/gradlew
vendored
Executable file
@@ -0,0 +1,247 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
92
packages/native-shell-android/gradlew.bat
vendored
Normal file
92
packages/native-shell-android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
1
packages/native-shell-android/proguard-rules.pro
vendored
Normal file
1
packages/native-shell-android/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# ProGuard rules for native-shell-android
|
||||
18
packages/native-shell-android/settings.gradle.kts
Normal file
18
packages/native-shell-android/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "native-shell-android"
|
||||
12
packages/native-shell-android/src/main/AndroidManifest.xml
Normal file
12
packages/native-shell-android/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="xyz.self.sdk.webview.SelfVerificationActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.AppCompat.NoActionBar"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.api
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import xyz.self.sdk.webview.SelfVerificationActivity
|
||||
|
||||
object SelfSdk {
|
||||
fun launch(activity: Activity, config: SelfSdkConfig, requestCode: Int = REQUEST_CODE_VERIFICATION) {
|
||||
val intent = Intent(activity, SelfVerificationActivity::class.java).apply {
|
||||
putExtra(SelfVerificationActivity.EXTRA_TEE_URL, config.teeUrl)
|
||||
putExtra(SelfVerificationActivity.EXTRA_VERIFICATION_ID, config.verificationId)
|
||||
putExtra(SelfVerificationActivity.EXTRA_USER_ID, config.userId)
|
||||
putExtra(SelfVerificationActivity.EXTRA_DEBUG_MODE, config.isDebugMode)
|
||||
}
|
||||
activity.startActivityForResult(intent, requestCode)
|
||||
}
|
||||
|
||||
fun handleResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?,
|
||||
callback: SelfSdkCallback,
|
||||
expectedRequestCode: Int = REQUEST_CODE_VERIFICATION,
|
||||
) {
|
||||
if (requestCode != expectedRequestCode) return
|
||||
|
||||
when (resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
val resultData = data?.getStringExtra(SelfVerificationActivity.EXTRA_RESULT_DATA) ?: "{}"
|
||||
callback.onSuccess(resultData)
|
||||
}
|
||||
Activity.RESULT_CANCELED -> {
|
||||
callback.onCancelled()
|
||||
}
|
||||
Activity.RESULT_FIRST_USER -> {
|
||||
val resultData = data?.getStringExtra(SelfVerificationActivity.EXTRA_RESULT_DATA)
|
||||
callback.onFailure(SelfSdkException(resultData ?: "Verification failed"))
|
||||
}
|
||||
else -> {
|
||||
callback.onFailure(SelfSdkException("Verification failed with result code: $resultCode"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val REQUEST_CODE_VERIFICATION = 9001
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.api
|
||||
|
||||
data class SelfSdkConfig(
|
||||
val teeUrl: String,
|
||||
val verificationId: String,
|
||||
val userId: String,
|
||||
val isDebugMode: Boolean = false,
|
||||
)
|
||||
|
||||
class SelfSdkException(message: String) : Exception(message)
|
||||
|
||||
interface SelfSdkCallback {
|
||||
fun onSuccess(resultJson: String)
|
||||
fun onFailure(error: SelfSdkException)
|
||||
fun onCancelled()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.bridge
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
interface BridgeHandler {
|
||||
val domain: BridgeDomain
|
||||
|
||||
suspend fun handle(
|
||||
method: String,
|
||||
params: Map<String, JsonElement>,
|
||||
): JsonElement?
|
||||
}
|
||||
|
||||
class BridgeHandlerException(
|
||||
val code: String,
|
||||
override val message: String,
|
||||
val details: Map<String, JsonElement>? = null,
|
||||
cause: Throwable? = null,
|
||||
) : Exception(message, cause)
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.bridge
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
const val BRIDGE_PROTOCOL_VERSION = 1
|
||||
|
||||
@Serializable
|
||||
enum class BridgeDomain {
|
||||
@SerialName("nfc") NFC,
|
||||
@SerialName("biometrics") BIOMETRICS,
|
||||
@SerialName("secureStorage") SECURE_STORAGE,
|
||||
@SerialName("camera") CAMERA,
|
||||
@SerialName("crypto") CRYPTO,
|
||||
@SerialName("haptic") HAPTIC,
|
||||
@SerialName("analytics") ANALYTICS,
|
||||
@SerialName("lifecycle") LIFECYCLE,
|
||||
@SerialName("documents") DOCUMENTS,
|
||||
@SerialName("navigation") NAVIGATION,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BridgeError(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val details: Map<String, JsonElement>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BridgeRequest(
|
||||
val type: String = "request",
|
||||
val version: Int,
|
||||
val id: String,
|
||||
val domain: BridgeDomain,
|
||||
val method: String,
|
||||
val params: Map<String, JsonElement> = emptyMap(),
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BridgeResponse(
|
||||
val type: String = "response",
|
||||
val version: Int = BRIDGE_PROTOCOL_VERSION,
|
||||
val id: String,
|
||||
val domain: BridgeDomain,
|
||||
val requestId: String,
|
||||
val success: Boolean,
|
||||
val data: JsonElement? = null,
|
||||
val error: BridgeError? = null,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BridgeEvent(
|
||||
val type: String = "event",
|
||||
val version: Int = BRIDGE_PROTOCOL_VERSION,
|
||||
val id: String,
|
||||
val domain: BridgeDomain,
|
||||
val event: String,
|
||||
val data: JsonElement,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.bridge
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import java.util.UUID
|
||||
|
||||
class MessageRouter(
|
||||
private val sendToWebView: (js: String) -> Unit,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
) {
|
||||
private val handlers = mutableMapOf<BridgeDomain, BridgeHandler>()
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
fun register(handler: BridgeHandler) {
|
||||
handlers[handler.domain] = handler
|
||||
}
|
||||
|
||||
fun onMessageReceived(rawJson: String) {
|
||||
val request = try {
|
||||
json.decodeFromString<BridgeRequest>(rawJson)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("BridgeRouter", "Failed to decode request: ${e::class.simpleName}")
|
||||
return
|
||||
}
|
||||
android.util.Log.d("BridgeRouter", "Received: domain=${request.domain} method=${request.method}")
|
||||
|
||||
if (request.version != BRIDGE_PROTOCOL_VERSION) {
|
||||
sendResponse(
|
||||
BridgeResponse(
|
||||
id = UUID.randomUUID().toString(),
|
||||
domain = request.domain,
|
||||
requestId = request.id,
|
||||
success = false,
|
||||
error = BridgeError(
|
||||
code = "UNSUPPORTED_VERSION",
|
||||
message = "Protocol version ${request.version} is not supported",
|
||||
),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val handler = handlers[request.domain]
|
||||
if (handler == null) {
|
||||
sendResponse(
|
||||
BridgeResponse(
|
||||
id = UUID.randomUUID().toString(),
|
||||
domain = request.domain,
|
||||
requestId = request.id,
|
||||
success = false,
|
||||
error = BridgeError(
|
||||
code = "DOMAIN_NOT_FOUND",
|
||||
message = "No handler registered for domain: ${request.domain}",
|
||||
),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val result = handler.handle(request.method, request.params)
|
||||
sendResponse(
|
||||
BridgeResponse(
|
||||
id = UUID.randomUUID().toString(),
|
||||
domain = request.domain,
|
||||
requestId = request.id,
|
||||
success = true,
|
||||
data = result,
|
||||
),
|
||||
)
|
||||
} catch (e: BridgeHandlerException) {
|
||||
sendResponse(
|
||||
BridgeResponse(
|
||||
id = UUID.randomUUID().toString(),
|
||||
domain = request.domain,
|
||||
requestId = request.id,
|
||||
success = false,
|
||||
error = BridgeError(
|
||||
code = e.code,
|
||||
message = e.message,
|
||||
details = e.details,
|
||||
),
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
sendResponse(
|
||||
BridgeResponse(
|
||||
id = UUID.randomUUID().toString(),
|
||||
domain = request.domain,
|
||||
requestId = request.id,
|
||||
success = false,
|
||||
error = BridgeError(
|
||||
code = "INTERNAL_ERROR",
|
||||
message = e.message ?: "Unknown error",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pushEvent(domain: BridgeDomain, event: String, data: JsonElement) {
|
||||
val bridgeEvent = BridgeEvent(
|
||||
id = UUID.randomUUID().toString(),
|
||||
domain = domain,
|
||||
event = event,
|
||||
data = data,
|
||||
)
|
||||
val eventJson = json.encodeToString(bridgeEvent)
|
||||
sendToWebView("window.SelfNativeBridge._handleEvent(${escapeForJs(eventJson)})")
|
||||
}
|
||||
|
||||
private fun sendResponse(response: BridgeResponse) {
|
||||
val responseJson = json.encodeToString(response)
|
||||
android.util.Log.d("BridgeRouter", "Sending response: domain=${response.domain} success=${response.success}")
|
||||
sendToWebView("window.SelfNativeBridge._handleResponse(${escapeForJs(responseJson)})")
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun escapeForJs(jsonStr: String): String {
|
||||
val escaped = jsonStr
|
||||
.replace("\\", "\\\\")
|
||||
.replace("'", "\\'")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\u2028", "\\u2028")
|
||||
.replace("\u2029", "\\u2029")
|
||||
return "'$escaped'"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.handlers
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import xyz.self.sdk.bridge.BridgeDomain
|
||||
import xyz.self.sdk.bridge.BridgeHandler
|
||||
import xyz.self.sdk.bridge.BridgeHandlerException
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.Signature
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
|
||||
class CryptoHandler : BridgeHandler {
|
||||
override val domain = BridgeDomain.CRYPTO
|
||||
|
||||
private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
|
||||
override suspend fun handle(
|
||||
method: String,
|
||||
params: Map<String, JsonElement>,
|
||||
): JsonElement? = when (method) {
|
||||
"generateKey" -> generateKey(params)
|
||||
"getPublicKey" -> getPublicKey(params)
|
||||
"sign" -> sign(params)
|
||||
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown crypto method: $method")
|
||||
}
|
||||
|
||||
private fun generateKey(params: Map<String, JsonElement>): JsonElement {
|
||||
val keyRef = params["keyRef"]?.jsonPrimitive?.content
|
||||
?: throw BridgeHandlerException("MISSING_PARAM", "keyRef parameter required")
|
||||
|
||||
// Delete existing key if present
|
||||
if (keyStore.containsAlias(keyRef)) {
|
||||
keyStore.deleteEntry(keyRef)
|
||||
}
|
||||
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
keyRef,
|
||||
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
|
||||
)
|
||||
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||
.build()
|
||||
|
||||
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore").apply {
|
||||
initialize(spec)
|
||||
generateKeyPair()
|
||||
}
|
||||
|
||||
return JsonObject(mapOf(
|
||||
"keyRef" to JsonPrimitive(keyRef),
|
||||
"success" to JsonPrimitive(true),
|
||||
))
|
||||
}
|
||||
|
||||
private fun getPublicKey(params: Map<String, JsonElement>): JsonElement {
|
||||
val keyRef = params["keyRef"]?.jsonPrimitive?.content
|
||||
?: throw BridgeHandlerException("MISSING_PARAM", "keyRef parameter required")
|
||||
|
||||
val cert = keyStore.getCertificate(keyRef)
|
||||
?: throw BridgeHandlerException("KEY_NOT_FOUND", "Key not found: $keyRef")
|
||||
|
||||
val publicKeyBytes = cert.publicKey.encoded
|
||||
val publicKeyB64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
|
||||
|
||||
return JsonObject(mapOf("publicKey" to JsonPrimitive(publicKeyB64)))
|
||||
}
|
||||
|
||||
private fun sign(params: Map<String, JsonElement>): JsonElement {
|
||||
val keyRef = params["keyRef"]?.jsonPrimitive?.content
|
||||
?: throw BridgeHandlerException("MISSING_PARAM", "keyRef parameter required")
|
||||
val dataB64 = params["data"]?.jsonPrimitive?.content
|
||||
?: throw BridgeHandlerException("MISSING_PARAM", "data parameter required")
|
||||
|
||||
val privateKey = keyStore.getKey(keyRef, null)
|
||||
?: throw BridgeHandlerException("KEY_NOT_FOUND", "Key not found: $keyRef")
|
||||
|
||||
val dataBytes = Base64.decode(dataB64, Base64.DEFAULT)
|
||||
|
||||
val signature = Signature.getInstance("SHA256withECDSA").apply {
|
||||
initSign(privateKey as java.security.PrivateKey)
|
||||
update(dataBytes)
|
||||
}.sign()
|
||||
|
||||
val signatureB64 = Base64.encodeToString(signature, Base64.NO_WRAP)
|
||||
|
||||
return JsonObject(mapOf("signature" to JsonPrimitive(signatureB64)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.handlers
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import xyz.self.sdk.bridge.BridgeDomain
|
||||
import xyz.self.sdk.bridge.BridgeHandler
|
||||
import xyz.self.sdk.bridge.BridgeHandlerException
|
||||
import xyz.self.sdk.webview.SelfVerificationActivity
|
||||
|
||||
class LifecycleHandler(private val activity: Activity) : BridgeHandler {
|
||||
override val domain = BridgeDomain.LIFECYCLE
|
||||
|
||||
override suspend fun handle(
|
||||
method: String,
|
||||
params: Map<String, JsonElement>,
|
||||
): JsonElement? = when (method) {
|
||||
"ready" -> null
|
||||
"dismiss" -> dismiss()
|
||||
"setResult" -> setResult(params)
|
||||
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown lifecycle method: $method")
|
||||
}
|
||||
|
||||
private fun dismiss(): JsonElement? {
|
||||
activity.runOnUiThread {
|
||||
activity.setResult(Activity.RESULT_CANCELED)
|
||||
activity.finish()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun setResult(params: Map<String, JsonElement>): JsonElement? {
|
||||
activity.runOnUiThread {
|
||||
val intent = Intent()
|
||||
val result = params["result"]
|
||||
if (result != null) {
|
||||
intent.putExtra(SelfVerificationActivity.EXTRA_RESULT_DATA, result.toString())
|
||||
val isSuccess = result.jsonObject["success"]?.jsonPrimitive?.booleanOrNull != false
|
||||
val resultCode = if (isSuccess) Activity.RESULT_OK else Activity.RESULT_FIRST_USER
|
||||
activity.setResult(resultCode, intent)
|
||||
} else {
|
||||
activity.setResult(Activity.RESULT_CANCELED)
|
||||
}
|
||||
activity.finish()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.handlers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import xyz.self.sdk.bridge.BridgeDomain
|
||||
import xyz.self.sdk.bridge.BridgeHandler
|
||||
import xyz.self.sdk.bridge.BridgeHandlerException
|
||||
|
||||
class SecureStorageHandler(context: Context) : BridgeHandler {
|
||||
override val domain = BridgeDomain.SECURE_STORAGE
|
||||
|
||||
private val prefs: SharedPreferences
|
||||
|
||||
// requireBiometric is intentionally ignored — device lock provides sufficient security per spec
|
||||
init {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"self_sdk_secure_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun handle(
|
||||
method: String,
|
||||
params: Map<String, JsonElement>,
|
||||
): JsonElement? = when (method) {
|
||||
"get" -> get(params)
|
||||
"set" -> set(params)
|
||||
"remove" -> remove(params)
|
||||
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown secureStorage method: $method")
|
||||
}
|
||||
|
||||
private fun get(params: Map<String, JsonElement>): JsonElement {
|
||||
val key = params["key"]?.jsonPrimitive?.content
|
||||
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
|
||||
val value = prefs.getString(key, null)
|
||||
return buildJsonObject {
|
||||
put("value", if (value != null) JsonPrimitive(value) else JsonNull)
|
||||
}
|
||||
}
|
||||
|
||||
private fun set(params: Map<String, JsonElement>): JsonElement? {
|
||||
val key = params["key"]?.jsonPrimitive?.content
|
||||
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
|
||||
val value = params["value"]?.jsonPrimitive?.content
|
||||
?: throw BridgeHandlerException("MISSING_VALUE", "Value parameter required")
|
||||
prefs.edit().putString(key, value).apply()
|
||||
return null
|
||||
}
|
||||
|
||||
private fun remove(params: Map<String, JsonElement>): JsonElement? {
|
||||
val key = params["key"]?.jsonPrimitive?.content
|
||||
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
|
||||
prefs.edit().remove(key).apply()
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.webview
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.http.SslError
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import xyz.self.sdk.bridge.MessageRouter
|
||||
|
||||
class AndroidWebViewHost(
|
||||
private val context: Context,
|
||||
private val router: MessageRouter,
|
||||
private val isDebugMode: Boolean = false,
|
||||
) {
|
||||
private lateinit var webView: WebView
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
fun createWebView(queryParams: String): WebView {
|
||||
val selfWalletHandler = WebViewAssetLoader.PathHandler { path ->
|
||||
try {
|
||||
val assetPath = "self-wallet/$path"
|
||||
val inputStream = context.assets.open(assetPath)
|
||||
val mimeType = when {
|
||||
path.endsWith(".js") -> "application/javascript"
|
||||
path.endsWith(".css") -> "text/css"
|
||||
path.endsWith(".html") -> "text/html"
|
||||
path.endsWith(".json") -> "application/json"
|
||||
path.endsWith(".woff2") -> "font/woff2"
|
||||
path.endsWith(".woff") -> "font/woff"
|
||||
path.endsWith(".otf") -> "font/otf"
|
||||
path.endsWith(".ttf") -> "font/ttf"
|
||||
path.endsWith(".png") -> "image/png"
|
||||
path.endsWith(".svg") -> "image/svg+xml"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
WebResourceResponse(mimeType, "UTF-8", inputStream)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val assetLoader = WebViewAssetLoader.Builder()
|
||||
.addPathHandler("/", selfWalletHandler)
|
||||
.build()
|
||||
|
||||
webView = WebView(context).apply {
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
|
||||
if (isDebugMode) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): WebResourceResponse? {
|
||||
request ?: return null
|
||||
return assetLoader.shouldInterceptRequest(request.url)
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return true
|
||||
if (url.startsWith("https://appassets.androidplatform.net/")) return false
|
||||
if (isDebugMode && url.startsWith("http://127.0.0.1:5173")) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: SslErrorHandler?,
|
||||
error: SslError?,
|
||||
) {
|
||||
handler?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
|
||||
|
||||
if (isDebugMode) {
|
||||
loadUrl("http://127.0.0.1:5173?$queryParams")
|
||||
} else {
|
||||
loadUrl("https://appassets.androidplatform.net/index.html?$queryParams")
|
||||
}
|
||||
}
|
||||
return webView
|
||||
}
|
||||
|
||||
fun evaluateJs(js: String) {
|
||||
if (!::webView.isInitialized) {
|
||||
android.util.Log.e("WebViewHost", "evaluateJs called but webView not initialized")
|
||||
return
|
||||
}
|
||||
webView.evaluateJavascript(js, null)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
if (!::webView.isInitialized) return
|
||||
webView.destroy()
|
||||
}
|
||||
|
||||
inner class BridgeJsInterface {
|
||||
@JavascriptInterface
|
||||
fun postMessage(json: String) {
|
||||
router.onMessageReceived(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.webview
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import xyz.self.sdk.bridge.MessageRouter
|
||||
import xyz.self.sdk.handlers.CryptoHandler
|
||||
import xyz.self.sdk.handlers.LifecycleHandler
|
||||
import xyz.self.sdk.handlers.SecureStorageHandler
|
||||
|
||||
class SelfVerificationActivity : AppCompatActivity() {
|
||||
private lateinit var webViewHost: AndroidWebViewHost
|
||||
private lateinit var router: MessageRouter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val isDebugMode = intent.getBooleanExtra(EXTRA_DEBUG_MODE, false)
|
||||
val teeUrl = intent.getStringExtra(EXTRA_TEE_URL) ?: ""
|
||||
val verificationId = intent.getStringExtra(EXTRA_VERIFICATION_ID) ?: ""
|
||||
val userId = intent.getStringExtra(EXTRA_USER_ID) ?: ""
|
||||
|
||||
router = MessageRouter(
|
||||
sendToWebView = { js ->
|
||||
runOnUiThread { webViewHost.evaluateJs(js) }
|
||||
},
|
||||
)
|
||||
|
||||
router.register(SecureStorageHandler(this))
|
||||
router.register(CryptoHandler())
|
||||
router.register(LifecycleHandler(this))
|
||||
|
||||
webViewHost = AndroidWebViewHost(this, router, isDebugMode)
|
||||
|
||||
val queryParams = buildString {
|
||||
append("teeUrl=").append(android.net.Uri.encode(teeUrl))
|
||||
append("&verificationId=").append(android.net.Uri.encode(verificationId))
|
||||
append("&userId=").append(android.net.Uri.encode(userId))
|
||||
}
|
||||
|
||||
val webView = webViewHost.createWebView(queryParams)
|
||||
setContentView(webView)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (::webViewHost.isInitialized) {
|
||||
webViewHost.destroy()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_DEBUG_MODE = "xyz.self.sdk.DEBUG_MODE"
|
||||
const val EXTRA_TEE_URL = "xyz.self.sdk.TEE_URL"
|
||||
const val EXTRA_VERIFICATION_ID = "xyz.self.sdk.VERIFICATION_ID"
|
||||
const val EXTRA_USER_ID = "xyz.self.sdk.USER_ID"
|
||||
const val EXTRA_RESULT_DATA = "xyz.self.sdk.RESULT_DATA"
|
||||
}
|
||||
}
|
||||
27
packages/native-shell-ios/Package.swift
Normal file
27
packages/native-shell-ios/Package.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
// swift-tools-version: 5.9
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "SelfNativeShell",
|
||||
platforms: [
|
||||
.iOS(.v15)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "SelfNativeShell",
|
||||
targets: ["SelfNativeShell"]
|
||||
)
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SelfNativeShell",
|
||||
path: ".",
|
||||
sources: ["Sources/SelfNativeShell"],
|
||||
resources: [
|
||||
.copy("Resources/self-sdk-web")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public final class SelfSdk {
|
||||
public static func createViewController(
|
||||
config: SelfSdkConfig,
|
||||
callback: SelfSdkCallback
|
||||
) -> UIViewController {
|
||||
let viewController = SelfSdkViewController(config: config, callback: callback)
|
||||
viewController.modalPresentationStyle = .fullScreen
|
||||
return viewController
|
||||
}
|
||||
}
|
||||
|
||||
final class SelfSdkViewController: UIViewController {
|
||||
private let config: SelfSdkConfig
|
||||
private weak var callback: SelfSdkCallback?
|
||||
private var webViewHost: SelfWebViewHost?
|
||||
|
||||
init(config: SelfSdkConfig, callback: SelfSdkCallback) {
|
||||
self.config = config
|
||||
self.callback = callback
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setupWebView()
|
||||
}
|
||||
|
||||
private func setupWebView() {
|
||||
let lifecycleHandler = LifecycleHandler(
|
||||
viewController: self,
|
||||
onResult: { [weak self] result in
|
||||
if let dict = result as? [String: Any] {
|
||||
self?.callback?.onSuccess(result: dict)
|
||||
} else {
|
||||
self?.callback?.onSuccess(result: [:])
|
||||
}
|
||||
},
|
||||
onDismiss: { [weak self] in
|
||||
self?.callback?.onCancelled()
|
||||
}
|
||||
)
|
||||
|
||||
let router = MessageRouter { [weak self] js in
|
||||
self?.webViewHost?.evaluateJs(js)
|
||||
}
|
||||
router.register(handler: SecureStorageHandler())
|
||||
router.register(handler: CryptoHandler())
|
||||
router.register(handler: lifecycleHandler)
|
||||
|
||||
let host = SelfWebViewHost(router: router, isDebugMode: config.isDebugMode)
|
||||
self.webViewHost = host
|
||||
|
||||
let webView = host.createWebView()
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(webView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
webView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||||
])
|
||||
|
||||
host.loadContent(queryParams: config.toQueryParams())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct SelfSdkConfig {
|
||||
public let teeUrl: String
|
||||
public let verificationId: String
|
||||
public let userId: String
|
||||
public let isDebugMode: Bool
|
||||
|
||||
public init(
|
||||
teeUrl: String,
|
||||
verificationId: String,
|
||||
userId: String,
|
||||
isDebugMode: Bool = false
|
||||
) {
|
||||
self.teeUrl = teeUrl
|
||||
self.verificationId = verificationId
|
||||
self.userId = userId
|
||||
self.isDebugMode = isDebugMode
|
||||
}
|
||||
|
||||
func toQueryParams() -> String {
|
||||
var components = URLComponents()
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "teeUrl", value: teeUrl),
|
||||
URLQueryItem(name: "verificationId", value: verificationId),
|
||||
URLQueryItem(name: "userId", value: userId)
|
||||
]
|
||||
return components.percentEncodedQuery ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
public protocol SelfSdkCallback: AnyObject {
|
||||
func onSuccess(result: [String: Any])
|
||||
func onFailure(error: Error)
|
||||
func onCancelled()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol BridgeHandler {
|
||||
var domain: BridgeDomain { get }
|
||||
func handle(method: String, params: [String: Any]?) async throws -> Any?
|
||||
}
|
||||
|
||||
enum BridgeHandlerError: Error, LocalizedError {
|
||||
case unknownMethod(String)
|
||||
case missingParam(String)
|
||||
case operationFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unknownMethod(let method): return "Unknown method: \(method)"
|
||||
case .missingParam(let param): return "Missing parameter: \(param)"
|
||||
case .operationFailed(let reason): return reason
|
||||
}
|
||||
}
|
||||
|
||||
var code: String {
|
||||
switch self {
|
||||
case .unknownMethod: return "UNKNOWN_METHOD"
|
||||
case .missingParam: return "MISSING_PARAM"
|
||||
case .operationFailed: return "OPERATION_FAILED"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
import Foundation
|
||||
|
||||
enum BridgeDomain: String, Codable {
|
||||
case nfc
|
||||
case biometrics
|
||||
case secureStorage
|
||||
case camera
|
||||
case crypto
|
||||
case haptic
|
||||
case analytics
|
||||
case lifecycle
|
||||
case documents
|
||||
case navigation
|
||||
}
|
||||
|
||||
struct BridgeRequest: Codable {
|
||||
let type: String
|
||||
let version: Int
|
||||
let id: String
|
||||
let domain: BridgeDomain
|
||||
let method: String
|
||||
let params: [String: AnyCodable]?
|
||||
let timestamp: Double
|
||||
}
|
||||
|
||||
struct BridgeResponse: Codable {
|
||||
let type: String
|
||||
let version: Int
|
||||
let id: String
|
||||
let domain: BridgeDomain
|
||||
let requestId: String
|
||||
let success: Bool
|
||||
let data: AnyCodable?
|
||||
let error: BridgeError?
|
||||
let timestamp: Double
|
||||
|
||||
static func success(request: BridgeRequest, result: Any?) -> BridgeResponse {
|
||||
BridgeResponse(
|
||||
type: "response",
|
||||
version: 1,
|
||||
id: UUID().uuidString,
|
||||
domain: request.domain,
|
||||
requestId: request.id,
|
||||
success: true,
|
||||
data: result.map { AnyCodable($0) },
|
||||
error: nil,
|
||||
timestamp: Date().timeIntervalSince1970 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
static func failure(request: BridgeRequest, code: String, message: String) -> BridgeResponse {
|
||||
BridgeResponse(
|
||||
type: "response",
|
||||
version: 1,
|
||||
id: UUID().uuidString,
|
||||
domain: request.domain,
|
||||
requestId: request.id,
|
||||
success: false,
|
||||
data: nil,
|
||||
error: BridgeError(code: code, message: message),
|
||||
timestamp: Date().timeIntervalSince1970 * 1000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct BridgeError: Codable {
|
||||
let code: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
// Type-erased Codable wrapper for heterogeneous JSON values
|
||||
struct AnyCodable: Codable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
value = NSNull()
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
value = bool
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
value = int
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
value = double
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
value = string
|
||||
} else if let array = try? container.decode([AnyCodable].self) {
|
||||
value = array.map { $0.value }
|
||||
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||
value = dict.mapValues { $0.value }
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON type")
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch value {
|
||||
case is NSNull:
|
||||
try container.encodeNil()
|
||||
case let bool as Bool:
|
||||
try container.encode(bool)
|
||||
case let int as Int:
|
||||
try container.encode(int)
|
||||
case let double as Double:
|
||||
try container.encode(double)
|
||||
case let string as String:
|
||||
try container.encode(string)
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dict as [String: Any]:
|
||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||
default:
|
||||
throw EncodingError.invalidValue(value, .init(codingPath: encoder.codingPath, debugDescription: "Unsupported type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
import Foundation
|
||||
|
||||
final class MessageRouter {
|
||||
private var handlers: [BridgeDomain: BridgeHandler] = [:]
|
||||
private let sendToWebView: (String) -> Void
|
||||
|
||||
init(sendToWebView: @escaping (String) -> Void) {
|
||||
self.sendToWebView = sendToWebView
|
||||
}
|
||||
|
||||
func register(handler: BridgeHandler) {
|
||||
handlers[handler.domain] = handler
|
||||
}
|
||||
|
||||
func onMessageReceived(rawJson: String) {
|
||||
guard let data = rawJson.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
let request: BridgeRequest
|
||||
do {
|
||||
request = try JSONDecoder().decode(BridgeRequest.self, from: data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
guard request.version == 1 else {
|
||||
let response = BridgeResponse.failure(
|
||||
request: request,
|
||||
code: "UNSUPPORTED_VERSION",
|
||||
message: "Protocol version \(request.version) is not supported"
|
||||
)
|
||||
sendResponse(response)
|
||||
return
|
||||
}
|
||||
|
||||
guard let handler = handlers[request.domain] else {
|
||||
let response = BridgeResponse.failure(
|
||||
request: request,
|
||||
code: "UNKNOWN_DOMAIN",
|
||||
message: "No handler for domain: \(request.domain.rawValue)"
|
||||
)
|
||||
sendResponse(response)
|
||||
return
|
||||
}
|
||||
|
||||
let params = request.params?.mapValues { $0.value }
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await handler.handle(method: request.method, params: params)
|
||||
let response = BridgeResponse.success(request: request, result: result)
|
||||
await MainActor.run { sendResponse(response) }
|
||||
} catch let error as BridgeHandlerError {
|
||||
let response = BridgeResponse.failure(
|
||||
request: request,
|
||||
code: error.code,
|
||||
message: error.errorDescription ?? "Unknown error"
|
||||
)
|
||||
await MainActor.run { sendResponse(response) }
|
||||
} catch {
|
||||
let response = BridgeResponse.failure(
|
||||
request: request,
|
||||
code: "HANDLER_ERROR",
|
||||
message: error.localizedDescription
|
||||
)
|
||||
await MainActor.run { sendResponse(response) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pushEvent(domain: BridgeDomain, event: String, data: Any?) {
|
||||
let eventDict: [String: Any] = [
|
||||
"type": "event",
|
||||
"version": 1,
|
||||
"domain": domain.rawValue,
|
||||
"event": event,
|
||||
"data": data as Any,
|
||||
"timestamp": Date().timeIntervalSince1970 * 1000
|
||||
]
|
||||
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: eventDict),
|
||||
let jsonStr = String(data: jsonData, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
let escaped = escapeForJs(jsonStr)
|
||||
let js = "window.SelfNativeBridge._handleEvent('\(escaped)')"
|
||||
sendToWebView(js)
|
||||
}
|
||||
|
||||
private func sendResponse(_ response: BridgeResponse) {
|
||||
guard let data = try? JSONEncoder().encode(response),
|
||||
let jsonStr = String(data: data, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
let escaped = escapeForJs(jsonStr)
|
||||
let js = "window.SelfNativeBridge._handleResponse('\(escaped)')"
|
||||
sendToWebView(js)
|
||||
}
|
||||
|
||||
private func escapeForJs(_ str: String) -> String {
|
||||
str.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "\\'")
|
||||
.replacingOccurrences(of: "\n", with: "\\n")
|
||||
.replacingOccurrences(of: "\r", with: "\\r")
|
||||
.replacingOccurrences(of: "\u{2028}", with: "\\u2028")
|
||||
.replacingOccurrences(of: "\u{2029}", with: "\\u2029")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user