Phase 1 — LIFF Wrap (1-2 days parallel, ~9 CC sessions)

Goal: Pasukuru FE runs inside LINE LIFF. User auto-identified by line_user_id. End-to-end browse → cart → checkout works inside LINE WebView.

Exit gate: Tap LINE rich menu → LIFF opens → user sees Pasukuru shop → adds to cart → completes Stripe Checkout → returns to LIFF → sees order confirmation. Same human in LineFollower table = same Pasukuru member.


Decisions in this phase

1.D-1 — Multi-tenant LIFF routing strategy ⚠️ BLOCKER

  • Status: open — must resolve before 1.F-4 / 1.F-10

  • Owner: Ryo + Julian + r_goto

  • Context: Pasukuru BE today resolves tenant from Host header via DomainAdminMiddleware. Inside LIFF, all users hit the same LIFF URL (e.g. https://liff.line.me/{liffId}), which redirects to a single endpoint URL. So Host won’t differentiate tenants.

  • Options:

    1. A — Per-tenant LIFF URL (subdomain)

      • Each Pasukuru tenant gets unique LIFF app + endpoint URL https://shop1.passkuru.com/liff/shop
      • Pros: zero code change, Host header keeps working
      • Cons: must register LIFF app per tenant in LINE Console (manual, scales poorly)
    2. B — Single LIFF, JWT-encoded shop_id from Curva

      • One LIFF app per FUNCTION (shop, cart, account). Curva injects signed JWT param ?token=... containing shop_id when generating LIFF URL
      • Pros: single LIFF registration per function; scales to N tenants without LINE Console changes
      • Cons: Pasukuru FE must parse + verify JWT → needs new middleware on Next.js side
    3. C — Path-based: https://shop.passkuru.com/{shopSlug}/...

      • One LIFF app, endpoint URL has {shopSlug} placeholder. Pasukuru FE reads slug from path, sends as header to BE.
      • Pros: no JWT crypto. Bookmarkable URLs.
      • Cons: shopSlug exposed (privacy?), all tenants share LIFF channel (LINE login consent shared)
  • Recommendation: Option B (JWT) for production scale. Combine with Option C path for human-readable URLs. Curva generates signed ?token=<jwt> → Pasukuru FE verifies + extracts shop_id, falls back to path slug if no token.

  • Reversibility: Hard — affects URL structure visible to LINE users. Decide once.

1.D-2 — LIFF idToken verification: Curva-side or LINE-direct?

  • Status: open
  • Options:
    1. Pasukuru BE → calls https://api.line.me/oauth2/v2.1/verify directly with idToken
    2. Pasukuru BE → calls Curva /integration/verify-liff-token (Curva calls LINE)
  • Recommendation: Option 1 — fewer hops, simpler debug. Curva’s API key auth still required for the broader bridge, but token verify is a LINE-public endpoint.
  • Blocks: 1.P-2
  • Status: open
  • Context: LIFF runs in iframe-like WebView. Auth cookies must work. iOS Safari WebView strict on third-party cookies.
  • Options:
    1. Cookies SameSite=None; Secure; HttpOnly
    2. Token in localStorage (no cookies)
    3. Authorization header from JS (token returned on identify, stored in memory)
  • Recommendation: Option 3 — most resilient to WebView quirks. Slight UX cost (lose session on tab close, but LIFF re-init re-issues).
  • Blocks: 1.F-5, 1.F-11

Tracking matrix

IDTitleTrackJiraCCRiskStatusDepends
1.P-2POST /integration/line/identifyPPASS-?1MBacklog0.P-1, 1.D-2
1.P-9Multi-tenant LIFF resolutionPPASS-?1.5HBacklog1.D-1
1.F-1Install @line/liffFPASS-?XSLBacklog
1.F-2LiffProvider componentFPASS-?0.5LBacklog1.F-1
1.F-3useLiff() hookFPASS-?0.5LBacklog1.F-2
1.F-4LIFF init on app bootFPASS-?0.5MBacklog1.F-3, 1.D-1
1.F-5Auto-identify flow → JWTFPASS-?1MBacklog1.F-4, 1.P-2, 1.D-3
1.F-6LIFF-aware cart routeFPASS-?1LBacklog1.F-5
1.F-7LIFF checkout + Stripe openWindowFPASS-?1MBacklog1.F-6
1.F-10Tenant resolution from LIFFFPASS-?1MBacklog1.F-4, 1.D-1
1.F-11Cookie/token strategy implementationFPASS-?0.5MBacklog1.D-3
1.C-12LIFF app registration helper (Curva)CCRV-?1LBacklog1.D-1

Total: 9.6 CC sessions = ~1.5-2 days parallel.


Items detail (selected critical ones)

1.P-2 — POST /api/v1/integration/line/identify

Track: Pasukuru BE CC: M (1) Depends: 0.P-1, 1.D-2 Risk: M Goal: Endpoint accepts LIFF idToken, verifies with LINE, upserts member, returns app JWT.

Files:

  • src/app/integration/integration.controller.ts (edit)
  • src/app/integration/services/line-id-token.service.ts (new)
  • src/app/integration/services/integration.service.ts (edit — add identifyByLineIdToken)
  • src/app/integration/dto/identify-line.dto.ts (new)
  • src/app/auth/services/jwt.service.ts (edit — add member token issue)

Endpoint: POST /api/v1/integration/line/identify Auth: none (public — token verifies caller) Body: { idToken: string, channelId: string } Returns: { token: string (JWT), member: {id, displayName, email, lineUserId} }

Verify flow:

  1. POST https://api.line.me/oauth2/v2.1/verify with id_token + client_id=channelId
  2. Validate iss == "https://access.line.me", aud == channelId, exp > now
  3. Extract sub (line_user_id), name, email?, picture?
  4. Resolve tenant: from Host header OR JWT shop_id from query (per 1.D-1)
  5. Upsert member by (adminId, sub) → existing service from 0.P-3 reused
  6. Issue Pasukuru member JWT (existing member-jwt.strategy)

Acceptance:

  • Valid idToken → 200 with JWT
  • Invalid sig / expired / wrong aud → 401
  • Idempotent: same idToken twice = same memberId
  • Rate limited (10/min per IP)
  • e2e test with mock LINE verify response

1.P-9 — Multi-tenant LIFF resolution

Track: Pasukuru BE CC: L (1.5) Depends: 1.D-1 Risk: H (touches every request path) Goal: Resolve tenant from JWT shop_id, fall back to Host header.

Files:

  • src/app/master/domain/middleware/domain-admin.middleware.ts (edit)
  • src/app/master/domain/middleware/tenant-from-line.middleware.ts (new)

Logic:

  1. New middleware TenantFromLineMiddleware runs BEFORE DomainAdminMiddleware
  2. Reads Authorization: Bearer <jwt> OR ?shop_token=<jwt> from query
  3. If JWT present + valid + has shop_id claim → set req.adminId directly, skip Host lookup
  4. Else → fall through to existing Host-based resolution

Acceptance:

  • LIFF request with shop_token → resolves to correct adminId
  • Web request without token → resolves via Host (no regression)
  • Invalid token → 401 (don’t fall through silently)
  • e2e test for both paths
  • Existing admin / member endpoints unaffected

Rollout:

  • Feature flag: tenant.line_resolution
  • Canary tenant first (1 staging tenant), monitor 24h, then enable globally

1.F-1 — Install LIFF SDK

Track: Pasukuru FE CC: XS (0.1) Goal: Add @line/liff to dependencies.

Steps:

  1. npm install @line/liff
  2. Add NEXT_PUBLIC_LIFF_ID_SHOP, NEXT_PUBLIC_LIFF_ID_CART, NEXT_PUBLIC_LIFF_ID_ACCOUNT to env.mjs schema
  3. Update .env.example

1.F-2 + 1.F-3 — LiffProvider + useLiff()

Track: Pasukuru FE CC: S each Goal: React context exposing { liff, profile, idToken, isInClient, isReady }.

Files:

  • src/providers/LiffProvider.tsx (new)
  • src/hooks/useLiff.ts (new)

Provider logic:

  1. On mount → if env LIFF_ID present → liff.init({ liffId })
  2. After init → if liff.isLoggedIn()liff.getProfile() + liff.getIDToken()
  3. Else → liff.login({ redirectUri })
  4. Expose state via context

1.F-5 — Auto-identify flow

Track: Pasukuru FE CC: M (1) Depends: 1.F-4, 1.P-2, 1.D-3 Goal: On LIFF ready → call identify endpoint → store JWT in memory + axios header.

Files:

  • src/services/auth/line-identify.service.ts (new)
  • src/repositories/auth/index.ts (edit — interceptor)
  • src/stores/auth.store.ts (edit or new — Zustand)

Steps:

  1. Hook useLiffIdentify() — runs when useLiff().isReady && idToken
  2. Calls POST /api/v1/integration/line/identify with { idToken, channelId }
  3. On 200 → store token in Zustand
  4. Axios interceptor injects Authorization: Bearer <token> for all subsequent BE calls
  5. On 401 → clear token, re-call identify

Acceptance:

  • Identify happens once per LIFF session
  • BE calls auto-authenticated
  • Manual web flow (no LIFF) unaffected — falls back to existing email/password auth
  • React Query cache cleared on identity change

1.F-7 — LIFF checkout + Stripe openWindow

Track: Pasukuru FE CC: M (1) Goal: Stripe Checkout opens in external browser via liff.openWindow, returns to LIFF on success.

Files:

  • src/app/checkout/page.tsx (edit)
  • src/services/order/checkout.service.ts (edit)

Steps:

  1. On checkout submit → BE creates Stripe Checkout Session
  2. If useLiff().isInClientliff.openWindow({ url: checkoutUrl, external: true })
  3. Else → window.location = checkoutUrl
  4. Stripe redirects success → https://shop.passkuru.com/checkout/success?session={ID} → which is also a LIFF endpoint → re-init → show confirmation
  5. Stripe webhook (existing) updates order status → triggers Phase 0 order.paid event → LINE chat receives Flex Msg

Acceptance:

  • Checkout works in LIFF + web
  • Success returns user inside LIFF
  • Order status correctly updated via existing Stripe webhook
  • Receipt Flex Msg arrives in LINE chat (manual test confirms Phase 0 wiring)

1.C-12 — LIFF app registration helper (Curva)

Track: Curva CC: M (1) Depends: 1.D-1 Goal: Admin UI button “Auto-register Pasukuru LIFF apps” creates 3 LineLiff rows per LineAccount with the correct LIFF IDs.

Files:

  • app/Actions/Member/LineAccount/Liff/AutoRegisterPasukuruLiffsAction.php (new)
  • app/Http/Controllers/Member/LineAccount/LineLiffController.php (edit — add autoRegisterPasukuru method)
  • routes/web-member.php (edit)
  • resources/js/Pages/Member/LineAccounts/Liffs/Index.vue (edit — add button)

Logic:

  1. Reads PartnerConnection for current LineAccount → gets partner_shop_id
  2. Generates 3 LIFF URLs (with shop_token JWT signed using shared secret):
    • shop: https://liff.line.me/{LIFF_SHOP}?shop_token=<jwt>
    • cart: https://liff.line.me/{LIFF_CART}?shop_token=<jwt>
    • account: https://liff.line.me/{LIFF_ACCOUNT}?shop_token=<jwt>
  3. Inserts 3 LineLiff rows with usage_type='pasukuru_shop|pasukuru_cart|pasukuru_account'

Acceptance:

  • Button creates 3 rows
  • Idempotent (clicking twice doesn’t duplicate)
  • JWT verifiable by Pasukuru BE (shared secret)
  • Pest test

Phase 1 smoke test

Setup:

  • Phase 0 complete + green
  • LIFF apps created in LINE Developers Console (3 apps: shop, cart, account)
  • Pasukuru staging deployed with feature flags ON
  • Curva staging has 1.C-12 registered LIFF rows

Procedure:

  1. User adds friend to Curva-managed LINE OA
  2. User taps Rich Menu “Open Shop” (URL action → LIFF shop URL)
  3. LINE opens LIFF → Pasukuru FE loads
  4. LIFF SDK initializes → idToken obtained
  5. Auto-identify call → Pasukuru BE creates Member with line_user_id
  6. User browses products (loaded from BE)
  7. Add to cart → cart page → checkout
  8. Stripe Checkout opens external → user pays test card → returns to LIFF
  9. Order created → Pasukuru emits order.created (Phase 0 wiring)
  10. Curva receives → currently Phase 0 stub Action just logs (Phase 2 will push Flex Msg)

Pass: all 10 steps green.


Risk register (Phase 1)

RiskLikelihoodImpactMitigation
LIFF SDK init fails on iOS SafariMHtest matrix: LINE iOS, LINE Android, LIFF browser, regular Safari
JWT secret leak between Curva ↔ PasukuruLHrotate secret quarterly; monitor logs for invalid tokens
Stripe redirect breaks return pathMMuse liff.openWindow({external:true}) not iframe
LINE login consent screen confuses usersLMminimize scopes (profile openid email)
Multi-tenant resolution fails for some tenantsMHcanary tenant first; rollback flag exists
localStorage/cookie quirks in WebViewMMuse Zustand in-memory store (per 1.D-3)

Phase 1 exit checklist

  • All items DoD
  • All 3 LIFF apps registered in LINE Console (manual, 3x)
  • Smoke test passed (LINE iOS + Android both)
  • No regression in web (non-LIFF) shop flow
  • Vault updated
  • User sign-off → Phase 2